Skip to content

Latest commit

 

History

History
97 lines (63 loc) · 6.19 KB

0068-deterministic-disposal-finalizer-safety-net.md

File metadata and controls

97 lines (63 loc) · 6.19 KB
id 0068-deterministic-disposal-finalizer-safety-net
title Deterministic disposal; the finalizer is a safety net, not the mechanism
status Accepted
author
Ian Cooper
created 2026-07-28
summary Pipelines and lifetime scopes release their resources on an explicit Dispose/DisposeAsync that drains every tracked object and surfaces failures together as an AggregateException; the finalizer only re-runs the drain best-effort and swallows, since a finalizer must never throw.
tags
lifetime
di
memory

68. Deterministic disposal; the finalizer is a safety net, not the mechanism

Date: 2026-07-28

Status

Accepted

Context

Scope: how TransformPipeline/TransformPipelineAsync, TransformLifetimeScope/TransformLifetimeScopeAsync and ServiceProviderLifetimeScope release the mapper/transform resolutions they own. "Scope" below means a DI scope (Microsoft's IServiceScope); the *LifetimeScope types are the helpers that own them, not scopes themselves (see Terms in ADR 0067).

Where this ADR sits

Four ADRs came out of the #4252 lifetime work, one decision each, and they are meant to be read in order:

ADR Decides
0066 what a factory returns, so that Release can name the resolution it is releasing
0067 that a Transient resolution gets its own DI scope, tracked by scope identity and released idempotently
0068 (this one) that disposal is deterministic on the explicit path and best-effort in the finalizer
0069 who owns, and therefore who disposes, the registry and the factories

ADRs 0070–0074 then build on all four: they give a pipeline its own DI scope, and let it join one the host already owns. The rule stated here is the one the pipeline scope's release has to satisfy.

Closing the per-message scope leak (ADR 0067) moved the release of a mapper/transform from something that used to happen incidentally to something a caller must do at a well-defined point. Two forces then apply at once:

  • A finalizer must never let an exception escape — an exception out of a finalizer terminates the process. Releasing a resolution through the synchronous path can throw: on netstandard2.0 the container's synchronous scope Dispose throws for a service that implements only IAsyncDisposable, and a user Dispose/DisposeAsync may throw.
  • An explicit dispose, by contrast, must be deterministic: it should release everything it can now and report failures, rather than stop at the first fault and leave the rest to a GC-timed finalizer.

Decision

Disposal is deterministic on the explicit path and best-effort on the finalizer, with the finalizer as a safety net only.

The mechanism, end to end

The same drain runs on both paths. What differs is what happens when a release throws: the explicit path reports every failure, the finalizer swallows every failure, and neither may leave the remainder unreleased.

flowchart TB
    d(["something must release its tracked resolutions"]) --> how{"explicit Dispose,<br/>or the finalizer?"}

    how -- "explicit — deterministic" --> e1["claim the release-once guard, Interlocked.Exchange"]
    e1 --> e2["for each tracked object: REMOVE it first, then release it<br/>inside a per-item try/catch, collecting failures rather than stopping"]
    e2 --> e3["throw one AggregateException carrying every failure"]
    e3 --> e4["GC.SuppressFinalize in a finally, so a throwing release<br/>still de-registers the object from finalization"]

    how -- "finalizer — best-effort" --> f1["run the same synchronous drain, inside a catch-all"]
    f1 --> f2["release what it can and swallow everything, including the<br/>AggregateException above: a finalizer must never throw"]

    e2 -. "remove-before-release is what reconciles the two:<br/>a finalizer retry re-runs over the shortened list" .-> f1
Loading

Explicit dispose drains everything and surfaces failures together

Dispose/DisposeAsync walks the tracked objects and releases each inside a per-item try/catch, collecting failures. After the loop it throws a single AggregateException carrying every failure. So one throwing release does not abort the drain and defer the remainder to the finalizer — a full call releases everything and the owner sees every failure.

The finalizer re-runs the drain best-effort and swallows

The finalizer runs the same synchronous drain inside try { … } catch { }. Finalization order is non-deterministic, so an orphaned scope can be finalized before its owner disposes it; the finalizer releases what it can and swallows any failure (including the AggregateException above), because it must not throw.

Idempotent, drain-as-you-go

  • A release-once guard (Interlocked.Exchange) makes an explicit dispose followed by another dispose, or by the finalizer, run the release body exactly once.
  • The drain removes each tracked object before releasing it, so a finalizer retry re-runs over the shortened list — a throwing release neither leaves the remainder unreleased nor re-releases an already-released object.
  • GC.SuppressFinalize runs in a finally on the explicit path, so a throwing release still de-registers the object from finalization.

This shape is applied uniformly to TransformLifetimeScope/TransformLifetimeScopeAsync, TransformPipeline/TransformPipelineAsync, and ServiceProviderLifetimeScope.Dispose.

Consequences

Positive

  • Teardown does not depend on GC timing; a caller that disposes deterministically frees the resolutions' scopes at once and is told about every failure.
  • A misbehaving user Dispose/DisposeAsync, or the netstandard2.0 sync-dispose-of-async-only throw, cannot crash the process from a finalizer nor silently orphan the other tracked objects.

Negative

  • An explicit dispose can now throw an AggregateException where a single throwing release previously surfaced its own exception type; callers already catch Exception, which covers it.

References