Skip to content

Resource management and lifetime control in .NET systems

This topic is much bigger than IDisposable.

In real desktop systems, especially WPF apps that talk to machines, cameras, PLCs, and vendor SDKs, resource management is really about controlling lifetime. It is about making sure every important thing in the system has a clear beginning, a clear owner, and a predictable end.

That includes memory, but also much more than memory.

A senior engineer does not just ask, “Will this object be garbage collected?” They ask:

  • Who owns this thing?
  • How long should it live?
  • What happens if cleanup is delayed?
  • What happens if cleanup never runs?
  • What happens if something else is still using it?
  • What happens during cancellation, reconnect, shutdown, or error recovery?

That is the real subject.


Part 1 — Big picture

Why resource management matters so much in long-running desktop systems

In a short web request, a lot of bad lifecycle decisions are hidden by the request boundary. The process receives work, creates objects, does the job, returns the response, and much of the temporary state becomes unreachable quickly.

A long-running desktop system is different.

A WPF machine app may stay open for 8 hours, 24 hours, or even days. During that time it may:

  • connect and reconnect to hardware
  • open and close screens many times
  • start and stop inspection runs repeatedly
  • allocate large image buffers
  • subscribe to many event sources
  • spin background monitoring loops
  • hold native handles through vendor SDK wrappers

In that kind of system, small mistakes accumulate. A forgotten event unsubscription may keep a ViewModel alive forever. A timer may continue firing after a screen closes. A reconnect worker may keep trying forever after the operator leaves the page. A vendor session object may keep a native handle open even though the managed wrapper is no longer used.

These bugs do not always crash immediately. That is why they are dangerous. They slowly make the system unstable.

Why memory is only one part of the problem

Many engineers initially think resource management means “avoid memory leaks.” That is only part of the picture.

Real production failures are often caused by things like:

  • camera handles not released
  • sockets not closed
  • device sessions left open
  • file handles exhausted
  • pinned memory causing GC pain
  • event subscriptions retaining dead objects
  • timers still running
  • background loops still alive
  • channels and queues never completed
  • duplicate subscriptions causing duplicate commands or duplicate UI updates

So the real problem is not just “memory leak.” It is lifetime leak.

Something that should have stopped is still alive. Something that should have been released is still held. Something that should have died is still participating in system behavior.

Why industrial leaks are often non-memory leaks

Industrial systems are full of resources that matter operationally even if the managed heap looks fine.

Examples:

A camera SDK may expose a managed CameraSession object, but internally that object owns a device connection, one or more native buffers, a stream handle, and a callback registration into unmanaged code. If cleanup is delayed, the camera may remain “busy” and cannot be reopened on the next run.

A WPF inspection screen may subscribe to machine status events. If it forgets to unsubscribe, the closed screen may still receive status updates. That leads to stale UI activity, duplicate rendering work, and eventually retained memory.

A monitoring service may start a polling loop. If that loop survives screen shutdown, you now have invisible background activity consuming CPU, logs, network, or hardware bandwidth.

A long-running inspection session may keep image buffers longer than necessary because ownership is unclear between the acquisition layer, processing pipeline, and result viewer.

In production, these issues show up as “the app gets weird after a few hours,” not as a neat exception with a clear stack trace.


Part 2 — What “resource” really means in .NET

Managed memory vs unmanaged resources

Managed memory is memory used by normal .NET objects on the managed heap. The garbage collector can reclaim that memory when objects are no longer reachable.

Unmanaged resources are things outside normal GC control, such as:

  • OS handles
  • file handles
  • sockets
  • native SDK objects
  • device sessions
  • GPU buffers
  • pinned blocks
  • callback registrations in native code

The GC only understands reachability of managed objects. It does not understand the semantics of the external thing those objects may represent.

If a managed object wraps a native camera handle, the GC can eventually collect the wrapper object, but that does not mean the camera handle is released at the right time.

That timing matters.

Examples of important resource types

File handles

A managed stream object may wrap a file handle. If it is not disposed promptly, another part of the system may fail to open, write, rename, or delete the file.

Sockets and connections

A TCP connection to a PLC, controller, or host system is not just memory. It is an external live resource. Leaving it open too long may exhaust connection limits, keep stale sessions alive, or confuse reconnect logic.

Native SDK objects

Vendor SDKs often provide managed wrappers over C/C++ libraries. These wrappers may internally hold:

  • opaque handles
  • native allocated buffers
  • registered callbacks
  • worker threads
  • driver resources

If cleanup is not explicit and timely, the machine or device may remain in a bad state even though your .NET references seem gone.

Device sessions

A session with a camera, motion controller, or inspection head is often a logical resource. It may own multiple physical and software resources at once.

Event subscriptions

Event subscriptions are often overlooked because they do not look like resources. But they absolutely are. A subscription creates a reference path from publisher to subscriber. That path can keep objects alive and keep behavior active.

Timers

A timer is a living thing. It keeps firing until stopped and disposed. In long-running systems, forgotten timers become silent leaks of work.

Background tasks

A background loop or worker task is also a resource. It consumes execution, state, subscriptions, queues, cancellation registrations, and often external resources too.

Pinned buffers

Pinned buffers are especially important in image-heavy systems. Pinned memory prevents the GC from moving objects. Too much pinning or long-lived pinning can fragment memory and hurt GC efficiency.

Why the GC does not solve all of this

The GC solves one problem: reclaiming unreachable managed memory.

It does not guarantee:

  • timely release of native handles
  • timely disconnection from devices
  • unsubscription from events
  • stopping timers
  • completion of channels
  • cancellation of background tasks
  • shutdown ordering across related objects
  • release of unmanaged SDK resources at the moment you need them released

That is why experienced engineers treat GC as memory reclamation, not as lifecycle management.


Part 3 — Real problems in a wafer inspection desktop system

Let’s use this scenario:

A WPF desktop app controlling a wafer inspection machine

This is the kind of system where lifetime problems appear everywhere.

Camera/session not released cleanly after run

An inspection run starts camera acquisition, allocates native frame buffers, and opens a capture session. The run ends, but the code only drops references instead of explicitly closing the session.

Symptoms in production:

  • next run fails with “device busy”
  • reconnect works only after restarting the app
  • memory seems mostly fine, but hardware cannot be reopened
  • operator says “first run works, second run fails”

This is a classic ownership bug. The session existed, but nobody clearly owned shutdown.

Vendor SDK objects wrapping unmanaged handles

Suppose a vendor exposes something like this:

csharp
public sealed class VendorCamera : IDisposable
{
    public IntPtr Handle { get; }
    // ...
}

That wrapper may look small and harmless in managed code, but behind it is a real device resource. If multiple services hold references and nobody knows who disposes it, you either leak the camera or dispose it too early while someone else is still using it.

Symptoms:

  • random access violations from callbacks after shutdown
  • intermittent “invalid handle” failures
  • device reconnect becomes unreliable
  • crashes happen only after repeated open/close cycles

ViewModels or screens staying alive because of event subscriptions

This is extremely common in WPF.

A screen subscribes to machine status events:

csharp
_machineService.StatusChanged += OnStatusChanged;

The screen closes, but the event remains. The publisher is long-lived, so the closed screen remains alive.

Symptoms:

  • memory grows every time the screen is opened
  • stale screens still react to status updates
  • duplicate UI updates appear
  • operator notices strange lag after using the app for hours

Reconnect loop continuing after screen is closed

A machine page starts a reconnect loop for convenience. The operator navigates away, but the loop is still running in the background because cancellation was tied to the app lifetime instead of the screen lifetime.

Symptoms:

  • logs keep showing reconnect attempts from a closed feature
  • unexpected hardware traffic continues
  • CPU/network usage slowly grows
  • later opening the screen creates another loop, causing duplicate reconnect storms

Image buffers retained longer than needed

In inspection systems, images are large. A single design mistake around ownership can hold hundreds of megabytes too long.

Example failure pattern:

  • acquisition layer produces frame buffer
  • processing layer caches input “for debugging”
  • result viewer holds thumbnails
  • history screen also keeps a reference
  • run completes, but buffers remain reachable from multiple places

Symptoms:

  • memory climbs run after run
  • full GC frequency increases
  • UI stutters during heavy inspection
  • app becomes sluggish long before it truly runs out of memory

Resources surviving run completion because ownership is unclear

This is the core issue.

If nobody can clearly answer these questions, problems follow:

  • Who owns the inspection session?
  • Who closes the camera stream?
  • Who stops the polling timer?
  • Who completes the processing channel?
  • Who unsubscribes the UI handlers?
  • Who disposes per-run caches?
  • Who flushes and stops result persistence?

In production, unclear ownership leads to partial cleanup. Partial cleanup is worse than obvious failure, because the system limps along in a damaged state.

Why these are hard to diagnose

They are hard because the symptom usually appears far away from the cause.

A screen opened at 9 AM may leak one subscription. At 2 PM, the operator sees duplicate updates. At 5 PM, the app hangs on shutdown. The root cause was not the hang itself. It was the missing unsubscribe hours earlier.

The same is true for native resources. The code that forgot to close the session is not the code that later gets “device busy.”

Senior engineers learn to think in chains of ownership and lifetime, not just in local method correctness.


Part 4 — IDisposable in real systems

What IDisposable is really for

IDisposable is a way to express:

This object owns something that must be cleaned up deterministically.

That “something” might be:

  • unmanaged resources
  • subscriptions
  • timers
  • CancellationTokenSource
  • streams
  • native wrappers
  • child disposable objects
  • a logical activity that must be stopped

So Dispose is not only about memory. It is about end-of-life behavior.

Ownership and cleanup responsibility

The most important idea is ownership.

If a class creates and owns a resource, that class is usually responsible for disposing it.

If a class merely uses a shared resource owned elsewhere, it should usually not dispose it.

This is where many teams go wrong. They add IDisposable mechanically without deciding ownership.

Deterministic cleanup vs waiting for GC

In machine systems, waiting for GC is often unacceptable.

You do not want to say:

“Eventually the GC might collect this camera wrapper, and maybe then the finalizer or native cleanup will run.”

You want:

“When the run ends, the session is closed now.”

That is deterministic cleanup.

When to implement IDisposable

A class should often implement IDisposable when it owns resources that need explicit release, such as:

  • streams
  • native handles or wrappers
  • timers
  • subscriptions to long-lived publishers
  • CancellationTokenSource
  • child disposable services it created itself
  • per-operation resources with a real end-of-life

When a class should not implement IDisposable

A class should usually not implement it just because:

  • “it has fields”
  • “other classes do”
  • “maybe someday it might need cleanup”
  • “it uses injected services”
  • “it creates temporary objects that are already disposed locally”

Do not make IDisposable a ritual. Make it a real ownership contract.

Example: disposable inspection session

csharp
public sealed class InspectionRunSession : IDisposable
{
    private readonly CameraStream _cameraStream;
    private readonly ResultChannelPump _resultPump;
    private readonly CancellationTokenSource _cts = new();
    private readonly IDisposable _machineStatusSubscription;
    private bool _disposed;

    public InspectionRunSession(
        CameraStream cameraStream,
        ResultChannelPump resultPump,
        IDisposable machineStatusSubscription)
    {
        _cameraStream = cameraStream;
        _resultPump = resultPump;
        _machineStatusSubscription = machineStatusSubscription;
    }

    public void Start()
    {
        ThrowIfDisposed();
        _cameraStream.Start();
        _resultPump.Start(_cts.Token);
    }

    public void Stop()
    {
        if (_disposed) return;

        _cts.Cancel();
        _resultPump.Stop();
        _cameraStream.Stop();
    }

    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        try
        {
            _cts.Cancel();
        }
        catch
        {
        }

        _machineStatusSubscription.Dispose();
        _resultPump.Dispose();
        _cameraStream.Dispose();
        _cts.Dispose();
    }

    private void ThrowIfDisposed()
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
    }
}

This is realistic because the session owns real run-scoped things:

  • camera stream
  • background pump
  • cancellation source
  • event subscription

The session is a lifetime boundary, not just a bag of methods.

Example: wrapper around a native SDK object

csharp
public sealed class CameraDevice : IDisposable
{
    private readonly SafeCameraHandle _handle;
    private bool _disposed;

    public CameraDevice(SafeCameraHandle handle)
    {
        _handle = handle;
    }

    public Frame AcquireFrame()
    {
        ThrowIfDisposed();
        return NativeCameraApi.AcquireFrame(_handle);
    }

    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;
        _handle.Dispose();
    }

    private void ThrowIfDisposed()
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
    }
}

The wrapper owns the handle, so it disposes it.

Example: cleanup of timer and subscriptions

csharp
public sealed class MachineStatusViewModel : IDisposable
{
    private readonly IMachineStatusService _statusService;
    private readonly DispatcherTimer _refreshTimer;
    private bool _disposed;

    public MachineStatusViewModel(IMachineStatusService statusService)
    {
        _statusService = statusService;
        _statusService.StatusChanged += OnStatusChanged;

        _refreshTimer = new DispatcherTimer();
        _refreshTimer.Interval = TimeSpan.FromSeconds(1);
        _refreshTimer.Tick += OnTick;
        _refreshTimer.Start();
    }

    private void OnStatusChanged(object? sender, MachineStatus status)
    {
        // update properties
    }

    private void OnTick(object? sender, EventArgs e)
    {
        // periodic UI refresh logic
    }

    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        _statusService.StatusChanged -= OnStatusChanged;
        _refreshTimer.Stop();
        _refreshTimer.Tick -= OnTick;
    }
}

This is a good use of IDisposable even though it is not wrapping native memory directly. It owns active subscriptions and a timer.


Part 5 — IAsyncDisposable and async cleanup

When async disposal is needed

Async disposal matters when cleanup itself is asynchronous.

Examples:

  • background worker must stop and await completion
  • socket or stream shutdown is async
  • final buffered data must be flushed
  • channel pipeline must complete and drain
  • hardware disconnect sequence is async
  • you must await task completion to avoid shutdown races

In these cases, plain Dispose() is not enough.

Why async cleanup matters in real systems

A lot of production bugs happen because teams call Cancel() and assume the worker is gone. It is not gone yet. It may still be:

  • processing queued items
  • writing final results
  • receiving callbacks
  • holding a socket open
  • touching disposed objects during shutdown

Async disposal allows you to say:

Stop accepting new work, signal shutdown, and wait until the worker actually finishes.

Example: background worker with async disposal

csharp
public sealed class ReconnectWorker : IAsyncDisposable
{
    private readonly CancellationTokenSource _cts = new();
    private readonly Task _workerTask;
    private readonly IMachineConnector _connector;
    private bool _disposed;

    public ReconnectWorker(IMachineConnector connector)
    {
        _connector = connector;
        _workerTask = RunAsync(_cts.Token);
    }

    private async Task RunAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            try
            {
                if (!_connector.IsConnected)
                {
                    await _connector.TryReconnectAsync(cancellationToken);
                }

                await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                // log and continue carefully
                await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
            }
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;

        _cts.Cancel();

        try
        {
            await _workerTask.ConfigureAwait(false);
        }
        finally
        {
            _cts.Dispose();
        }
    }
}

This is much safer than “fire a task and forget it.”

Example: run-scoped async pipeline

csharp
public sealed class ResultPersistencePipeline : IAsyncDisposable
{
    private readonly Channel<InspectionResult> _channel =
        Channel.CreateBounded<InspectionResult>(100);

    private readonly CancellationTokenSource _cts = new();
    private readonly Task _consumerTask;
    private readonly IResultStore _resultStore;
    private bool _disposed;

    public ResultPersistencePipeline(IResultStore resultStore)
    {
        _resultStore = resultStore;
        _consumerTask = ConsumeAsync(_cts.Token);
    }

    public ValueTask QueueAsync(InspectionResult result, CancellationToken cancellationToken)
    {
        return _channel.Writer.WriteAsync(result, cancellationToken);
    }

    private async Task ConsumeAsync(CancellationToken cancellationToken)
    {
        await foreach (var result in _channel.Reader.ReadAllAsync(cancellationToken))
        {
            await _resultStore.SaveAsync(result, cancellationToken).ConfigureAwait(false);
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;

        _channel.Writer.TryComplete();
        _cts.Cancel();

        try
        {
            await _consumerTask.ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
        }
        finally
        {
            _cts.Dispose();
        }
    }
}

This pattern gives the pipeline a real lifecycle.

Example: safe async hardware disconnect

csharp
public sealed class MachineSession : IAsyncDisposable
{
    private readonly IVendorMachineClient _client;
    private bool _disposed;

    public MachineSession(IVendorMachineClient client)
    {
        _client = client;
    }

    public Task StartAsync(CancellationToken cancellationToken)
        => _client.ConnectAsync(cancellationToken);

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;

        try
        {
            await _client.StopStreamingAsync().ConfigureAwait(false);
        }
        catch
        {
            // log
        }

        try
        {
            await _client.DisconnectAsync().ConfigureAwait(false);
        }
        catch
        {
            // log
        }
    }
}

The key idea is not the syntax. The key idea is that shutdown is a real workflow.


Part 6 — Event subscriptions, references, and memory leaks

How event handlers create retention paths

When object A subscribes to an event on object B, object B now holds a reference to A through the delegate.

If B lives longer than A should, A cannot be collected.

That is why events are a very common leak source.

Why this leaks WPF screens and ViewModels

In WPF, publishers are often long-lived:

  • machine services
  • singleton status managers
  • event aggregators
  • application-wide services
  • static services

Subscribers are often short-lived:

  • screens
  • dialogs
  • tab ViewModels
  • per-run ViewModels
  • transient feature controllers

That is the perfect leak pattern: long-lived publisher, short-lived subscriber.

Example of a typical leak

csharp
public sealed class InspectionScreenViewModel
{
    private readonly IMachineService _machineService;

    public InspectionScreenViewModel(IMachineService machineService)
    {
        _machineService = machineService;
        _machineService.StatusChanged += OnStatusChanged;
    }

    private void OnStatusChanged(object? sender, MachineStatus status)
    {
        // update UI
    }
}

This leaks if the ViewModel is discarded without unsubscribing.

Static events and global singletons

Static events are especially dangerous because they are effectively app-lifetime publishers.

Global singleton services can cause the same problem. Teams often like them because they are convenient, but they create invisible retention roots.

How experienced engineers avoid this

They do one or more of these consistently:

  • unsubscribe explicitly on screen/ViewModel disposal
  • make subscription lifetime part of ownership design
  • return IDisposable subscription tokens instead of raw events
  • avoid long-lived publishers where not needed
  • use weak event patterns only where justified
  • review retention paths during design, not only after leaks happen

Better pattern: subscription returns IDisposable

csharp
public interface IMachineStatusSource
{
    IDisposable Subscribe(Action<MachineStatus> handler);
}

Implementation:

csharp
public sealed class MachineStatusSource : IMachineStatusSource
{
    private event Action<MachineStatus>? StatusChanged;

    public IDisposable Subscribe(Action<MachineStatus> handler)
    {
        StatusChanged += handler;
        return new Subscription(() => StatusChanged -= handler);
    }

    private sealed class Subscription : IDisposable
    {
        private readonly Action _unsubscribe;
        private int _disposed;

        public Subscription(Action unsubscribe)
        {
            _unsubscribe = unsubscribe;
        }

        public void Dispose()
        {
            if (Interlocked.Exchange(ref _disposed, 1) == 0)
            {
                _unsubscribe();
            }
        }
    }
}

Usage:

csharp
public sealed class InspectionScreenViewModel : IDisposable
{
    private readonly IDisposable _subscription;

    public InspectionScreenViewModel(IMachineStatusSource statusSource)
    {
        _subscription = statusSource.Subscribe(OnStatusChanged);
    }

    private void OnStatusChanged(MachineStatus status)
    {
        // update properties
    }

    public void Dispose()
    {
        _subscription.Dispose();
    }
}

This makes lifetime explicit and easier to reason about.


Part 7 — Service lifetimes and ownership

Lifetime boundaries in desktop systems

Desktop systems usually have several distinct lifetimes:

  • App lifetime: lives for the whole process
  • Screen lifetime: lives while a window/page/tab is open
  • Run lifetime: lives during one inspection run
  • Operation lifetime: lives for a single command or async operation

A lot of bugs come from mixing these.

Example lifetimes

App lifetime

Examples:

  • main logging infrastructure
  • global configuration provider
  • machine connection manager
  • app-wide diagnostics

Screen lifetime

Examples:

  • screen ViewModel
  • screen-specific subscriptions
  • screen timers
  • preview rendering state

Run lifetime

Examples:

  • camera acquisition session
  • per-run result cache
  • result persistence pipeline
  • cancellation source for the run

Operation lifetime

Examples:

  • one autofocus command
  • one stage move command
  • one file export

Why unclear ownership leads to leaks and shutdown bugs

If a screen starts a run-scoped resource and nobody documents who owns it, you get confusion like:

  • screen closes but run keeps going
  • run ends but screen still holds references
  • singleton service caches temporary state
  • one component disposes something still used elsewhere

Experienced engineers fight this by making lifetime boundaries obvious in the design.

Example design

  • MachineConnectionManager: singleton, app lifetime
  • InspectionRunSession: per run, owns run resources
  • InspectionViewModel: screen lifetime, owns only UI subscriptions and commands
  • MoveStageOperation: operation lifetime

This structure makes disposal responsibilities much easier to assign.

Good ownership rule

A very useful default rule is:

The creator is usually the owner, unless ownership is explicitly transferred.

Not always, but very often.


Part 8 — Native resources and interop safety

Why native SDK integration is especially risky

Native SDKs are risky because they bring problems the GC cannot understand well:

  • opaque handles
  • manual free/close rules
  • threading rules
  • callback lifetime rules
  • buffer ownership ambiguity
  • invalid access after disposal
  • undocumented shutdown ordering requirements

Managed code gives you some safety, but once you cross into native integration, lifetime mistakes become more expensive.

Handle ownership

The first question with native integration is:

Who owns this handle?

If the wrapper owns it, the wrapper must release it exactly once.

If the handle is borrowed from elsewhere, the wrapper must not release it.

This sounds simple, but many vendor APIs make it unclear.

Finalizers vs SafeHandle at a high level

At a high level:

  • finalizers are a last-resort cleanup mechanism
  • they are not deterministic
  • they add complexity and GC cost
  • they are easy to get wrong

SafeHandle is generally the preferred .NET-level abstraction for unmanaged handle ownership because it encapsulates handle release more safely.

You do not need to manually write fragile finalizer logic in most cases if you can model the native handle with SafeHandle.

Example sketch with SafeHandle

csharp
public sealed class SafeCameraHandle : SafeHandle
{
    public SafeCameraHandle() : base(IntPtr.Zero, ownsHandle: true)
    {
    }

    public override bool IsInvalid => handle == IntPtr.Zero;

    protected override bool ReleaseHandle()
    {
        return NativeCameraApi.CloseCamera(handle) == 0;
    }
}

Then higher-level wrappers own this SafeHandle and dispose it.

Native image acquisition buffers

Buffers are another danger zone.

Questions you must answer:

  • Who allocates the buffer?
  • Who owns the buffer?
  • When may it be reused?
  • When is it safe to free?
  • Is it pinned?
  • Is native code still using it asynchronously?

Getting this wrong causes:

  • memory retention
  • corruption
  • access violations
  • intermittent crashes after cancellation or shutdown

Production risks of getting this wrong

These are some of the ugliest bugs in machine systems:

  • native callback fires after managed object disposed
  • handle released twice
  • handle never released
  • buffer freed while hardware DMA still uses it
  • managed side assumes copy semantics, native side uses shared buffer
  • shutdown order causes native worker thread to call into dead managed state

These bugs are often rare, timing-sensitive, and hard to reproduce in development.


Part 9 — Background tasks, timers, and logical leaks

What is a logical leak?

A logical leak is when behavior remains alive longer than intended, even if memory is not exploding.

Examples:

  • loop still polling
  • reconnect task still retrying
  • timer still firing
  • channel consumer still waiting
  • background save pipeline still alive after cancellation
  • closed screen still reacting to events

These are operational leaks.

Leaked background loops

A common anti-pattern:

csharp
_ = Task.Run(async () =>
{
    while (true)
    {
        await PollMachineAsync();
        await Task.Delay(1000);
    }
});

This is a leak waiting to happen.

Problems:

  • no ownership
  • no cancellation
  • no shutdown path
  • exceptions may be lost or fragmented
  • loop may survive long after the feature is gone

Timers still running after shutdown

Timers are easy to forget because they look small.

But a forgotten timer means the system still does work. That can cause:

  • extra logs
  • stale UI updates
  • duplicate refreshes
  • CPU waste
  • racing behavior against newly created timers

Channels and tasks still alive after workflow completion

Pipelines often outlive their purpose because the writer was never completed or the consumer was never cancelled and awaited.

Then you get “ghost workers” hanging around.

Examples of operational problems

Reconnect loop keeps running forever

This can create reconnect storms, fill logs, and interfere with normal connection ownership.

Polling timer survives machine disconnect

You disconnect the machine but polling continues and spams warnings or triggers unnecessary recovery flows.

Save pipeline remains alive after cancellation

The operator cancels the run, but the result-saving pipeline still holds buffers or waits indefinitely for completion.

How experienced engineers prevent logical leaks

They design every worker around these questions:

  • Who starts it?
  • Who owns it?
  • How is it cancelled?
  • How is completion observed?
  • Who waits for it to stop?
  • What happens if stop is called twice?
  • What resources does it hold while running?

That is lifecycle thinking.


Part 10 — How we use this in .NET, practically

Clean ownership boundaries

A strong design might look like this:

  • singleton MachineConnectionManager owns physical machine connection
  • per-run InspectionRunSession owns acquisition, processing, persistence
  • per-screen InspectionViewModel owns UI subscriptions only
  • each worker or timer is owned by a containing lifetime object
  • native resources are wrapped in explicit disposable abstractions

Using using and await using correctly

Use using when the scope is synchronous and clearly bounded.

csharp
using var session = _cameraFactory.OpenCalibrationSession();
session.CaptureCalibrationImage();

Use await using when cleanup is asynchronous.

csharp
await using var runSession = await _runFactory.StartAsync(recipe, cancellationToken);
await runSession.ExecuteAsync(cancellationToken);

These patterns are valuable because they tie lifetime to scope.

Designing disposable services responsibly

Do not make every service disposable. Make services disposable when they truly own disposable state.

Bad:

  • service implements IDisposable only because it has injected dependencies

Better:

  • service implements IDisposable because it owns subscriptions, timers, workers, or native wrappers it created

Cleanup patterns for subscriptions

Pattern 1: explicit unsubscribe in Dispose

Pattern 2: keep all subscriptions in a collection and dispose together

csharp
public sealed class CompositeDisposable : IDisposable
{
    private readonly List<IDisposable> _items = new();
    private bool _disposed;

    public void Add(IDisposable item)
    {
        if (_disposed) throw new ObjectDisposedException(nameof(CompositeDisposable));
        _items.Add(item);
    }

    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        foreach (var item in _items)
        {
            item.Dispose();
        }

        _items.Clear();
    }
}

Usage:

csharp
public sealed class InspectionViewModel : IDisposable
{
    private readonly CompositeDisposable _cleanup = new();

    public InspectionViewModel(IMachineStatusSource statusSource, IAlarmSource alarmSource)
    {
        _cleanup.Add(statusSource.Subscribe(OnStatusChanged));
        _cleanup.Add(alarmSource.Subscribe(OnAlarmRaised));
    }

    private void OnStatusChanged(MachineStatus status) { }
    private void OnAlarmRaised(Alarm alarm) { }

    public void Dispose() => _cleanup.Dispose();
}

Cleanup patterns for machine sessions and run-scoped resources

csharp
public sealed class InspectionRunSession : IAsyncDisposable
{
    private readonly CameraDevice _camera;
    private readonly ResultPersistencePipeline _pipeline;
    private readonly CancellationTokenSource _cts = new();
    private readonly Task _processingTask;
    private bool _disposed;

    public InspectionRunSession(CameraDevice camera, ResultPersistencePipeline pipeline)
    {
        _camera = camera;
        _pipeline = pipeline;
        _processingTask = ProcessAsync(_cts.Token);
    }

    private async Task ProcessAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            var frame = _camera.AcquireFrame();
            try
            {
                var result = Inspect(frame);
                await _pipeline.QueueAsync(result, cancellationToken);
            }
            finally
            {
                frame.Dispose();
            }
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _disposed = true;

        _cts.Cancel();

        try
        {
            await _processingTask.ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
        }

        await _pipeline.DisposeAsync();
        _camera.Dispose();
        _cts.Dispose();
    }

    private InspectionResult Inspect(Frame frame)
    {
        // real processing
        return new InspectionResult();
    }
}

Shutting down background workers safely

Good shutdown usually has phases:

  1. stop accepting new work
  2. signal cancellation
  3. complete writer/input
  4. await worker completion
  5. release owned resources
  6. log shutdown outcome

That sequence matters.


Part 11 — Common mistakes

Assuming GC will clean everything important

This is probably the biggest mistake.

What it causes:

  • device busy errors
  • file locks
  • stale sockets
  • long delays before release
  • unpredictable cleanup timing

Implementing IDisposable mechanically without ownership thinking

This creates code that looks correct but is not.

Typical problems:

  • objects dispose things they do not own
  • objects forget to dispose what they do own
  • team does not know who is responsible for cleanup
  • shutdown order becomes random

Forgetting to unsubscribe events

This causes:

  • retained screens/ViewModels
  • duplicate event handling
  • stale UI updates
  • hidden memory growth

Holding references too long in singletons

Singletons are powerful leak amplifiers. If a singleton caches a run object, screen object, or per-operation state accidentally, that state effectively becomes app-lifetime.

Not disposing CancellationTokenSource, timers, native wrappers

These are commonly forgotten because they do not look “heavy,” but they still matter.

Disposing shared objects too early

This is the opposite failure. One component thinks it owns a shared object and disposes it while others still use it.

Symptoms:

  • random ObjectDisposedException
  • intermittent hardware errors
  • nondeterministic failures depending on timing

Unclear lifetime boundaries

This is the root cause behind many others. Teams often know the classes but not the lifetimes.

Background services surviving after they are no longer needed

This causes operational noise, CPU waste, reconnect storms, shutdown hangs, and confusion during debugging.


Part 12 — DI, lifetimes, and resource management

How DI lifetimes relate to resource ownership

DI lifetime is not just a container detail. It shapes ownership.

In desktop systems, this matters a lot because there is no natural request scope like a web app.

Singleton vs transient disposal behavior

Singleton

Lives for container lifetime, usually app lifetime.

Good for:

  • global coordination services
  • configuration
  • app-wide machine connection manager

Danger:

  • can accidentally retain short-lived objects
  • event subscriptions from transient/screen objects to singleton publishers leak easily
  • if it owns too much state, it becomes a lifetime sink

Transient

Created often, short-lived in theory.

Danger:

  • in desktop systems, transients may actually live much longer than expected if stored or subscribed
  • disposal may not be obvious if resolved from root container carelessly

Why root-scoped objects can cause hidden leaks

If you resolve objects from the root provider and keep them around, they may effectively become app-lifetime even if conceptually they were meant to be shorter-lived.

That is why desktop apps need explicit lifetime design, not assumptions borrowed from web apps.

Desktop apps need careful scope modeling

In a web app, request scope is built in.

In a desktop app, you often need to model your own scopes:

  • app scope
  • screen scope
  • run scope

That can be done with explicit factories, child scopes, or composition patterns.

Example

Machine connection service

Singleton. Owns physical connection and app-wide status publication.

Per-run orchestration service

Created per run. Owns run-specific cancellation, processing pipeline, temporary buffers, result aggregation.

ViewModel with subscriptions

Created per screen. Must unsubscribe/dispose when screen closes.

If you register everything as singleton “for convenience,” you flatten lifetime boundaries and invite leaks.


Part 13 — Diagnosing leaks and lifetime problems

How these bugs appear in production

Usually as symptoms, not as direct errors:

  • memory grows after each run
  • UI still receives updates from closed screens
  • handlers seem to fire twice, then three times, then four times
  • handles or connections are exhausted
  • reconnect storms happen after navigation
  • app shutdown hangs
  • machine reconnect only works after restart
  • logs show background activity long after feature shutdown

Practical debugging mindset

Experienced engineers ask:

  • What was supposed to stop but did not stop?
  • What object should have died but is still referenced?
  • What long-lived publisher might be holding short-lived subscribers?
  • What worker/task/timer might still be running?
  • What native or OS-level resource might still be open?
  • Where is ownership unclear?

That mindset is more useful than staring at memory graphs alone.

Useful signals and diagnostics

Memory growth pattern

Does memory grow per screen open/close? Per run? Per reconnect? That often tells you the leaking lifetime boundary.

Duplicate logs or duplicate UI reactions

These are strong indicators of duplicate subscriptions or duplicate workers.

Handle/resource counters

If file/socket/device handle counts rise over time, you likely have deterministic cleanup failures.

Shutdown logs

A well-designed system logs worker start/stop, subscription attach/detach, connection open/close, session begin/end. Without that, lifecycle debugging is much harder.

Task and timer visibility

It helps a lot to have naming and instrumentation for background workers, so you know what is alive.

What experienced engineers often do

They add observability around lifecycle events:

  • “Inspection run created: Run-1842”
  • “Camera session opened”
  • “Result pipeline started”
  • “Reconnect worker started for Machine A”
  • “Inspection screen subscribed to status source”
  • “Inspection screen disposed”
  • “Reconnect worker stopped”
  • “Camera session closed”

This sounds simple, but it makes invisible lifetime behavior visible.


Part 14 — Trade-offs

Deterministic cleanup vs simplicity

Deterministic cleanup makes systems safer, but it adds code and discipline.

If you ignore it, code looks simpler at first, but production behavior becomes less predictable.

Ownership clarity vs abstraction convenience

Heavy abstraction can hide ownership. That feels elegant until nobody knows who disposes what.

Sometimes a slightly more explicit design is better than a very abstract one.

Reuse vs lifetime safety

Reusing services, buffers, and sessions can improve performance, but it increases lifetime complexity.

Short-lived isolated objects are often easier to reason about, but may cost more in setup/teardown.

Singleton reuse vs isolation

Singletons reduce creation overhead and centralize control, but they are dangerous if they retain temporary state or expose events carelessly.

Per-run isolation is often safer for processing pipelines and temporary session state.

Aggressive disposal vs disposing shared resources too early

You want timely cleanup, but not reckless cleanup.

The danger is disposing an object that other parts still use. That is why ownership rules matter more than “dispose everything aggressively.”


Part 15 — Senior engineer mental model

An experienced engineer thinks about resource management like this:

1. Everything important has a lifetime

Not just memory objects. Sessions, handles, subscriptions, timers, workers, buffers, and pipelines all have lifetimes.

2. Every lifetime needs an owner

If nobody owns cleanup, cleanup will be partial, delayed, or forgotten.

3. Lifetime boundaries should match the business/system model

App lifetime, screen lifetime, run lifetime, and operation lifetime should be visible in the design.

4. Deterministic cleanup is part of system correctness

In hardware-integrated systems, cleanup is not optional polish. It is correctness.

5. The GC is not your lifecycle manager

It reclaims unreachable managed memory. That is useful, but it is not enough.

6. Events and background work are first-class leak sources

Senior engineers treat subscriptions and loops as real resources.

7. Native integration raises the cost of being sloppy

Unmanaged handles, pinned buffers, and callbacks make ownership and shutdown much more critical.

8. Simplicity comes from clear ownership, not from pretending ownership does not exist

The cleanest systems are usually the ones where you can answer clearly:

  • who creates it
  • who owns it
  • who uses it
  • who stops it
  • who disposes it
  • how long it should live

That is the core mental model.


A practical design checklist

When designing a component in this kind of system, ask:

  1. What resources does it own?
  2. Are any of them unmanaged, asynchronous, or long-lived?
  3. What is this component’s intended lifetime?
  4. Who disposes or stops it?
  5. Does it subscribe to anything longer-lived than itself?
  6. Does it start any worker, timer, task, or channel consumer?
  7. Does it hold large buffers or pinned memory?
  8. Does it wrap a native handle, callback, or session?
  9. What happens on cancellation, error, reconnect, and shutdown?
  10. Can I prove that after this component ends, nothing important remains alive accidentally?

If your team consistently thinks this way, most lifecycle bugs get prevented before production.


Final takeaway

In real .NET desktop systems, resource management is not mainly about Dispose() syntax.

It is about ownership, lifetime boundaries, and predictable shutdown.

That is how senior engineers keep machine systems stable after hours or days of runtime.

They do not rely on hope, GC timing, or vague conventions.

They design so that every important resource has:

  • a clear owner
  • a clear lifetime
  • a clear cleanup path
  • a clear stop/dispose rule

And they make those rules simple enough that the whole team can follow them.

If you want, next I can turn this into:

  1. an interview-style Q&A version, or
  2. a condensed “important points to memorize” version.

Docs-first project memory for AI-assisted implementation.