Skip to content

symfonic.kernel.finalizers

finalizers

The run's finalizer stack, unwound in two phases around the drain (RCX-10).

Its own module because the stack and the coordinator answer different questions. :class:~symfonic.kernel.lifecycle.RunLifecycle decides what a run owns and in what order the pieces of teardown happen; this module knows only how one pile of callables is popped, bounded, and reported. Keeping the second here is what lets the first stay readable as a sequence of teardown steps.

The phase split is the load-bearing idea. Owned background work is drained between the two passes, so post_drain — the default — keeps a resource alive for as long as the run's own tasks might still write through it, while pre_drain covers the inverse dependency: a finalizer the drain is itself waiting on.

FinalizerStack

FinalizerStack(*, grace_seconds: float)

Cleanup registered by a run, popped LIFO within each phase.

Source code in src/symfonic/kernel/finalizers.py
def __init__(self, *, grace_seconds: float) -> None:
    self._grace = grace_seconds
    self._entries: list[tuple[FinalizerPhase, str, Callable[[], Any]]] = []

unwind async

unwind(phase: FinalizerPhase) -> tuple[int, tuple[FinalizerFailure, ...]]

Pop this phase's finalizers LIFO, never letting one stop the rest.

A finalizer that raises is recorded, not propagated: teardown runs on the failure path too, and a cleanup error that replaced the original exception would hide the reason the run failed behind the reason the cleanup did. Finalizers belonging to the other phase are put back in registration order, so the second pass still unwinds LIFO.

Source code in src/symfonic/kernel/finalizers.py
async def unwind(
    self, phase: FinalizerPhase
) -> tuple[int, tuple[FinalizerFailure, ...]]:
    """Pop this phase's finalizers LIFO, never letting one stop the rest.

    A finalizer that raises is recorded, not propagated: teardown runs on
    the failure path too, and a cleanup error that replaced the original
    exception would hide the reason the run failed behind the reason the
    cleanup did. Finalizers belonging to the other phase are put back in
    registration order, so the second pass still unwinds LIFO.
    """
    ran = 0
    failures: list[FinalizerFailure] = []
    deferred: list[tuple[FinalizerPhase, str, Callable[[], Any]]] = []
    while self._entries:
        entry = self._entries.pop()
        if entry[0] != phase:
            deferred.append(entry)
            continue
        ran += 1
        failure = await self._run(entry[1], entry[2])
        if failure is not None:
            failures.append(failure)
    self._entries.extend(reversed(deferred))
    return ran, tuple(failures)

teardown_budget

teardown_budget(grace_seconds: float) -> asyncio.Timeout

The teardown bound, or an unexpiring scope when the grace is zero.

0 is documented as "cancel owned work immediately" (RCX-10), and the background drain honours exactly that. Applying it to teardown's awaits as well would cancel every flush, close, release, and terminal delivery that suspends even once — silent data loss sold as a bound. asyncio.timeout(None) expresses "no bound" without a second code path, and reports expired() is False forever, which is the truth.

Source code in src/symfonic/kernel/finalizers.py
def teardown_budget(grace_seconds: float) -> asyncio.Timeout:
    """The teardown bound, or an unexpiring scope when the grace is zero.

    ``0`` is documented as "cancel owned work immediately" (RCX-10), and the
    background drain honours exactly that. Applying it to teardown's *awaits*
    as well would cancel every flush, close, release, and terminal delivery
    that suspends even once — silent data loss sold as a bound.
    ``asyncio.timeout(None)`` expresses "no bound" without a second code path,
    and reports ``expired() is False`` forever, which is the truth.
    """
    return asyncio.timeout(grace_seconds if grace_seconds > 0 else None)