DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on

Completion Is an Ownership Boundary

An async method can finish its useful work and still be holding lifecycle state that tells the rest of the system it is active.

That sounds like a tiny implementation detail. Under concurrency, it becomes a contract bug.

I recently inspected a committed C# fix where an attempt could settle while two paths still believed they owned the same cancellation source. One path was normal attempt teardown. The other was component disposal. A narrow scheduling window could let one path dispose the source while the other still intended to cancel it.

The lesson is broader than cancellation: completion is an ownership boundary. Before an operation becomes observably complete, its shared lifecycle resources and active-state markers should already have reached their next valid owner or terminal state.

The dangerous gap after the work is done

Consider a reusable client-side workflow that starts one attempt at a time. It stores:

  • the current task;
  • a cancellation source for that attempt;
  • an active-attempt identifier; and
  • a snapshot published to observers.

The network work finishes. The code builds a settled snapshot, publishes it, completes a TaskCompletionSource, and only then clears the active task and disposes the cancellation source.

For a moment, callers can observe “finished” while internal state still says “active.” A retry may join the stale completed task. Concurrent disposal may capture a source that the attempt is about to dispose. These failures are rare because the state is individually valid on both sides of the gap; only the ordering is wrong.

Give the resource exactly one owner

A cancellation source should not be “shared until somebody disposes it.” It should have one owner at each point in the lifecycle.

Here is deliberately invented C# to show the handoff pattern:

CancellationTokenSource? TakeAttemptCancellation()
{
    lock (_stateLock)
    {
        var owned = _attemptCancellation;
        _attemptCancellation = null;
        return owned;
    }
}

async ValueTask DisposeAsync()
{
    var owned = TakeAttemptCancellation();

    owned?.Cancel();
    await DrainActiveAttemptWithinBudgetAsync();
    owned?.Dispose();
}
Enter fullscreen mode Exit fullscreen mode

The important operation is not Cancel or Dispose. It is the atomic transfer: read the shared field and clear it while holding the state lock. After that, the local variable is the sole owner. Another path entering the same lock finds null and knows it owns nothing.

Cancellation and disposal can then happen outside the lock, avoiding callbacks or slow work while shared state is protected.

Release before publishing completion

The attempt path needs the complementary rule. Clear the active task and detach or dispose its cancellation source before any observer can see the settled result.

In generalized form:

Result? settled = null;

try
{
    settled = await ExecuteAsync();
}
finally
{
    lock (_stateLock)
    {
        _activeAttempt = null;
        _attemptCancellation?.Dispose();
        _attemptCancellation = null;
    }

    if (settled is not null)
    {
        Publish(settled);
        _completion.TrySetResult(settled);
    }
}
Enter fullscreen mode Exit fullscreen mode

This ordering gives completion a useful meaning: if an observer has seen the result or awaited the task, the object is already idle. The next operation cannot accidentally coalesce onto stale work.

This is not a universal instruction to dispose every resource inside a lock. The point is to make the ownership transition atomic. What happens after the transition depends on whether disposal can block, invoke callbacks, or acquire other locks.

Test the ordering, not the odds

A stress loop is useful as a backstop, but it may never hit an extremely narrow scheduling window. A deterministic regression test should control the boundary.

One approach is to block inside the observer that receives the settled snapshot. While publication is paused, inspect the object from another thread. The invariant should already hold:

  • the previous attempt is no longer active;
  • a new start cannot receive the stale completed task;
  • disposal can run without competing for the same source; and
  • repeated disposal remains harmless.

Also cover disposal while work is in flight. Those tests describe ownership much better than “run this race many times and hope.”

The engineering trade-off

Explicit ownership adds code. The order of lock-protected mutations becomes part of the design, and reviewers must reason about publication, task completion, cancellation, disposal, and retries together.

The return is a lifecycle contract people can explain: one owner at a time; observable completion implies released attempt state; disposal is idempotent; immediate retry starts new work.

The committed change I inspected applied singular cancellation-source ownership in two similar async workflows. In the detailed workflow behind this example, active state is also released before snapshot publication; the sibling workflow releases ownership before completing its returned task while retaining its existing publication path. The committed tests include deterministic settlement-boundary coverage for the detailed workflow plus repeated- and in-flight-disposal coverage across both. I inspected the implementation and test assertions, but I did not rerun the suite or verify runtime behaviour.

A practical review checklist

For every async operation with reusable state, ask:

  1. What exactly does “complete” promise to observers?
  2. Who owns each cancellation source before, during, and after settlement?
  3. Can retry, disposal, or a callback enter between publication and teardown?
  4. Can the ownership transfer be made atomic while expensive work stays outside the lock?
  5. Is there a deterministic test that pauses at the boundary?

Rare races often survive because each line looks reasonable in isolation. Naming the ownership boundary makes the ordering reviewable.

Where does your API announce completion before it has finished releasing the attempt?

Top comments (0)