DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on AI-assisted

A Lifetime Is a Hold, Not the Worker

IDisposable and IAsyncDisposable make lifetime management feel pleasantly local: acquire something, use it, dispose it. That model is reliable when the acquired value has exclusive ownership of an independent resource.

It becomes dangerous when several activations share one background worker.

Mobile applications encounter this more often than it first appears. Returning online can reconcile a capability that is already running. A foreground transition can overlap an earlier activation. A bounded startup wait can stop waiting for slow native work, while that work completes after a newer activation has already joined the same resource.

If every returned lifetime means “stop the worker,” one participant can tear down work that another still needs. The sharper contract is: a returned lifetime is a hold on shared work. Disposing it releases only that participant’s hold. The worker stops when the final hold leaves.

The bug hides behind a reasonable API

Imagine a mobile shell with two optional capabilities: a small status refresher and a device-token relay. Each activation returns an asynchronous lifetime to the orchestrator. The orchestrator keeps the latest lifetime and disposes the one it replaces.

That sounds tidy, but activations are not necessarily independent. The second activation may join the refresher or event relay started by the first. If the first lifetime calls a global StopAsync, disposing the replaced lifetime stops the shared worker beneath the second activation.

The state machine can now tell a convincing lie. Its newest activation completed successfully, so the capability still appears ready. Underneath, polling has stopped or the event handler has been removed.

Warning, cancellation, and stale-completion paths deserve the same scrutiny. Cleanup code is often written defensively in several branches. If each branch performs terminal teardown, one failed attempt can shut down a resource held by a successful attempt.

Write the ownership sentence first

Before implementing the lifetime, describe what disposal means:

Disposing this value releases this activation’s participation. It does not necessarily stop the shared resource.

That sentence changes the design. Starting becomes “start or join, then take a hold.” Releasing becomes “release this hold, then stop only if it was the last.” Both transitions need one synchronization boundary.

It is not enough to increment a counter and subscribe in separate steps. Another thread can observe the gap. A release can unsubscribe after a new hold has been counted, or two starts can install duplicate listeners. The zero-to-one decision and the actual start belong in one atomic operation. So do the one-to-zero decision and the stop.

The hold itself should release at most once. A double Dispose must not decrement the count twice. A failed start must not record a hold, otherwise the next activation may “join” work that never started.

Old holds must not reach into new runs

Reference counting alone is not sufficient when the resource can stop and restart.

Suppose an old activation holds generation A. The application moves offline, so generation A is stopped. Later, the application returns online and starts generation B. If the late lifetime from A merely decrements a global count, it can steal a hold from B and stop the restarted worker.

Bind each hold to the run it joined. Releasing a hold from an ended run becomes a no-op for the current one.

The orchestrator may need a run identity too. Some stop events invalidate a broad session lease; others, such as backgrounding or going offline, may intentionally leave that session identity unchanged. A slow activation can therefore finish with a lease that still looks current even though the capability run it belonged to has ended. Tracking the capability run separately prevents that late completion from being adopted as ready.

This distinction is useful beyond mobile software. Connection pools, shared subscriptions, refresh loops, file watchers, and in-process event relays all have “same logical session, different resource run” failure modes.

Test transitions, not only outcomes

A happy-path test that starts and stops once proves very little about shared ownership.

Useful tests make each boundary visible:

  • take two holds, release one, and prove the worker remains active;
  • release the final hold and prove the worker stops exactly once;
  • dispose one hold twice and prove it releases once;
  • fail the first start and prove a later acquisition tries again;
  • restart the resource and prove an old hold cannot affect the new run;
  • complete an abandoned activation after a newer one and prove the newer work survives; and
  • repeatedly take, inspect, and release holds from two threads while the count crosses zero.

That last test is important. A single-thread loop can leave mutation survivors because it never exposes the gap between “count says active” and “listener is actually active.” Two competing threads make zero crossings collide with acquisition and release, which is where split synchronization shows itself.

Concurrency tests still need discipline: bounded rounds, a clear invariant, and no claim that passing them mathematically proves correctness. Their job is to exercise the race window while deterministic tests pin the state-machine rules.

The honest trade-off

Counted holds add a lock, a run identifier, idempotent release state, and more involved tests. They also force a clear distinction between releasing one participant and terminally disposing the owning dependency scope.

The simpler design is to prohibit overlap. That can be the right choice when the orchestrator can guarantee one activation at a time, cancel it reliably, and wait for completion before starting another. But if native calls can outlive a budget, or reconciliation can join already-running work, the simplicity is imaginary.

The practical takeaway is broader than reference counting: make ownership semantics part of the public contract. When a lifetime represents participation in shared work, call it a hold and test it like one. A local cleanup action should never silently become a global stop.

Top comments (0)