← the Atlas

Surface lifetime audit — who enforces "X dies when Y dies"

Analysis·budding·implemented·

A 29-seam sweep of the surface stack's lifetime contracts, hunting siblings of the stdio-orphan class — 10 framework-owned, 13 consumer-homework, 4 unenforced; two real siblings confirmed, one already fixed by

The stdio-orphan discovery (stdio-agent-lifetime, drishti#109) posed a generalizable question: for every resource the surface stack creates or holds, who enforces its death — the framework, the consumer’s discipline, or nobody? This note records the answer: a 3-phase adversarial sweep (seam inventory → constructible-orphan + promise-gap hunts → refuters) over surface links, surface-daemon, surface-remote, kaval, and padi.

The census

29 lifetime seams: 10 framework-owned · 13 consumer-homework · 4 unenforced. The framework-owned column is genuinely strong where it exists — server-side subscriptions abort with their transport by construction (peer.close() → every handler signal), per-connection serves die with their peer, the half-open-wire watchdog is unspellable through the blessed surfaceClient factories, and .use() subscriptions die with their reactive owner. The defect surface is the other 17 seams, where a documented death is somebody’s homework.Full seam-by-seam inventory with file:line contracts in the sweep artifact (workflow wf_55dd0375-f90, 10 agents, journal in the coordinator session). This note records the census, the confirmed findings, and the dispositions — not all 29 rows.

Confirmed siblings, dispositioned

The pattern the minors share

Ten minor findings, one shape: the framework mints a disposer and documents when it must be called, then hopes. serveOverUnixSocket’s close() stopped accepting but left established per-connection serves (and their timers) running — fixed by #1870, which made close() run the full teardown (stop accepting → disconnect established peers → release the inode); createLiveSignal’s watchdog outlives a discarded socket unless the consumer remembers dispose(); surface-daemon’s daemonMain resolves DaemonExit and leaves process exit to every new bin — the seed’s homework, re-assigned at the daemon layer; kaval’s ungraceful arm (SIGKILL/OOM) has no reaper at all (the known flake-campaign follow-up — #1851 closed the graceful half); inProcessPtyHost promises rc-file cleanup that an optional consumer hook enforces. Individually small; collectively the same P5 verdict as the seed — the knowing endpoint delegates.

The unifying mechanism — tenure, not transports (srid’s reframe)

The census’s real payoff is that the 17 non-framework-owned cells are one missing concept expressed 17 times, not 17 problems: a serving process’s tenure — what holds it alive, and the ordered teardown that must run when the last hold releases — regardless of how it was started (stdio, ssh, unix socket). The transports differ only in the holds:

assembly holds teardown chain today
stdio/ssh agent the one link framework-owned after stdio-agent-lifetime
socket daemon gate + policy (idle timeout, state-root watcher, parent pid — the boundToPid shape) framework-owned: close() stops accepting and disconnects established peer serves (#1870); dispose surface → exit owned by daemonProcessMain (#1862)
daemonMain bins already modeled (DaemonExit resolves on drain/idle/watch) framework-owned: daemonProcessMain owns the exit (#1862)

The follow-up this recommends: one shared piece of machinery in @kolu/surface-daemon where a daemon declares what holds it alive, and the framework runs the whole shutdown sequence (stop accepting → disconnect remaining clients → release resources → exit the process) when the last hold releases. But not yet — the house rule says don’t build shared machinery until two real things need it. So, in order: (1) the stdio-agent fix (#1858) ships now as a direct fix, no new abstraction. (2) When we fix the same “returns done-as-a-value instead of exiting” problem in the daemon binaries (daemonMain), that’s the second place needing identical logic — THAT is when the shared machinery gets built, extracted from the two working fixes — shipped as daemonProcessMain (#1862). (3) The unix-socket gap (closing a daemon doesn’t disconnect already-connected clients) — shipped by #1870, with one honest deviation from this plan’s wording: the disconnect needed no new machinery at all. A closure-scoped index of accepted sockets plus destroy() lets each connection’s existing settle chain (the step-1 teardown) do the whole job, and the ordered chain slots into daemonMain’s existing close() call — reuse of the machinery’s chain, not a new named piece of it.

What this buys

The census turns future lifetime questions from archaeology into lookup: a new consumer (kolu-cli’s faces, a fourth stdio agent) can be checked against the 17 non-framework-owned seams it touches, and each fix flips rows from homework to owned — with the tenure harness as the move that flips whole columns. The refuter discipline mattered: two over-claimed variants died in verification, and the one stale finding (kaval) was caught by checking the sweep’s tree against merged master before reporting.