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:
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:
_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
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
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
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
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
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
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
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
IDisposablesubscription 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
public interface IMachineStatusSource
{
IDisposable Subscribe(Action<MachineStatus> handler);
}Implementation:
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:
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 lifetimeInspectionRunSession: per run, owns run resourcesInspectionViewModel: screen lifetime, owns only UI subscriptions and commandsMoveStageOperation: 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
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:
_ = 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
MachineConnectionManagerowns physical machine connection - per-run
InspectionRunSessionowns acquisition, processing, persistence - per-screen
InspectionViewModelowns 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.
using var session = _cameraFactory.OpenCalibrationSession();
session.CaptureCalibrationImage();Use await using when cleanup is asynchronous.
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
IDisposableonly because it has injected dependencies
Better:
- service implements
IDisposablebecause 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
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:
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
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:
- stop accepting new work
- signal cancellation
- complete writer/input
- await worker completion
- release owned resources
- 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:
- What resources does it own?
- Are any of them unmanaged, asynchronous, or long-lived?
- What is this component’s intended lifetime?
- Who disposes or stops it?
- Does it subscribe to anything longer-lived than itself?
- Does it start any worker, timer, task, or channel consumer?
- Does it hold large buffers or pinned memory?
- Does it wrap a native handle, callback, or session?
- What happens on cancellation, error, reconnect, and shutdown?
- 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:
- an interview-style Q&A version, or
- a condensed “important points to memorize” version.