← the Atlas

Bug — a gate-less socket squatter wedges kolu forever

Bugs·seedling·implemented·

A kaval orphan whose gate file is gone but which still holds the rendezvous socket wedges kolu permanently: recycle only ever kills gate-recorded pids, so the squatter survives every recycle and the incompatible card's renew loops forever (field case: sincereintent 25494, 8 days). The fix gives the supervisor identity over its OWN socket: OS socket-holder lookup + kaval handshake → recycle the verified orphan; a non-kaval process is never killed.

#1887

srid’s ruling, verbatim: “I want the bug to be fixed. WHY is a stray process breaking kolu?” The field incident was closed by hand-killing pid 25494 on sincereintent after it had wedged the box for 8 days — but a hand-reap is cheating. The product must converge on its own. This note is the plan of record for making it do so.

The wedge

The single-instance gate (kaval.pid) and the rendezvous socket (pty-host.sock) are two separate files. Normally they move together: the daemon holding the socket is the one the gate names. The bug is the state where they come apart — the gate file is gone (or names a different / dead pid) but a live orphan kaval still holds the socket. That state is representable, it happens in the field, and no supervisor arm handles it.

Trace it against the code at HEAD:

  1. A fresh kolu-server boots and runs its convergence probe. The orphan’s socket is accepting, so the probe reads its system.version, sees an older pty-host contract, and decide returns recycle — routing the boot to the endpoint’s adoptOrEnsure.
  2. adoptOrEnsureadoptSurvivor calls liveServingHolder, which is gate-pid-only: it reads gatePid(gatePath), finds the gate absent, and returns undefined. The recycle arm (killLiveHolder) only ever fires against a gate-recorded pid, so it never targets the orphan.
  3. With no gate holder, the boot falls to spawnConnectHold. It spawns a fresh kaval — which acquires the now-absent gate, then tries to bind the socket, is refused already-served by the live orphan, releases the gate and exits. The gate is empty again.
  4. spawnConnectHold meanwhile sees the socket accepting (the orphan), dials it, handshakes the orphan, gets the contract skew, and reports incompatible.
  5. The client shows the incompatible card. “Update & restart” re-runs the same boot. liveServingHolder reads the empty gate, returns undefined, and the loop repeats — forever. The socket is also non-durable (F1): if the orphan ever does close(), it unlinks whatever socket path a successor managed to bind.F1 is the file-ops half of the same wound — a successor that briefly binds by clearing a stale-looking inode can later have its socket path unlinked by the original squatter’s clean close(). The fix dissolves F1 by removal: the orphan is gone before the successor binds, so there is no later close() to unlink anything.

The fix — the supervisor owns its own socket namespace

Supervision of one’s own verified daemons is the supervisor’s job. When the gate gap opens but the socket is still held, the supervisor should identify the holder itself and recycle it — using identity, not bookkeeping, as the safety line.

boot policy: ensure() / adoptSurvivor() liveServingHolder(gate): gate names a live serving pid? recycle the gate holder killLiveHolder → spawn (today) yes socket ACCEPTING anyway? gate-less squatter check (NEW) no gate holder spawn fresh daemon no squatter — today's path dead / absent IDENTIFY: socketHolderPid(path) VERIFY: kaval handshake on the socket OS: /proc/net/unix · lsof + system.version yes — held FOREIGN → fail LOUD socket does not speak kaval: typed error names pid+command · NEVER kill no DUAL-ATTEST identity handshake self-reported pid == OS holder pid re-verify holder UNCHANGED right before kill speaks kaval (any version) RECYCLE the verified orphan SIGTERM · waitForPidGone (reused) bind FRESH — durable holder gone → no later close() can unlink (F1) mismatch / holder moved → abort kill, fail loud
The recovery arm (green/red) grafts onto the existing boot policy. It fires ONLY when the gate names no live serving holder yet the socket is still accepting — the exact gate-less-squatter state. Every other path is unchanged.

The arm fires only in the gate-less-squatter state and does three things:

We also never recover a socket held by our own process: the supervisor spawns its daemon as a separate process, so a holder equal to process.pid is never a real squatter — filtering it out is both correct and a self-kill guard.

This makes the existing incompatible → renewDaemon flow self-heal: a fresh padi boots → socket held gate-less → handshake → old kaval → recycle → bind the new contract → converge. It is srid’s “stop the old kaval, start the new kaval”, extended from gate-recorded daemons to verified orphans.

The identity model (the load-bearing decision)

The orchestration teardown law — agents kill only the pids they recorded — does not govern the product’s own supervisor. A supervisor recycling a daemon it has just proven, over the wire, to be its own kind is doing its job, not violating a bookkeeping rule. The safety line is IDENTITY, made of three independent attestations that must agree before any signal is sent:

Attestation Source Guarantees
The socket speaks kaval the version handshake succeeds (compatible or skewed) it is our daemon, not a stranger
A specific pid holds this socket the OS socket-holder lookup we know exactly whom to signal
That pid is the one that answered handshake self-reports its pidkaval’s system.version returns { contractVersion, pid, startedAt, identity?, lifetime? } — the daemon’s own process.pid. pid is a required wire field, present since the first standalone kaval (#1301) — so it holds for every real orphan, including a 25494-era 5.0-contract skew. The recycle target is thus a pid the daemon named over the very socket we are recycling, cross-checked against the OS’s independent view. No new wire field is added (the field already exists); the added exported-type surface is an additive pid? on DaemonContractSkewError — see “Exported-API additions”. the process answering IS the process holding the socket

Only when all three agree — and are re-confirmed unchanged immediately before the SIGTERM — is the orphan recycled. Any disagreement (foreign process, pid the OS doesn’t corroborate, holder that moved between identify and kill) aborts the kill and fails loud. We never kill anything that didn’t handshake as kaval, and we never touch another daemon’s socket namespace.

The pid-absent arm is FOREIGN, by construction — not a weaker fallback. Because pid is a required field of the system.version schema, a response that omits it (a hypothetical pre-#1301 daemon, or any non-conforming speaker that merely accepts the socket) fails oRPC output validation — the read throws, which is exactly the “did not answer the handshake” path that classifies the holder as foreign: a loud typed error naming pid + command, and never a kill. So the identity model never degrades to a two-attestation kill when attestation 3 is missing — a missing self-reported pid is itself the proof the holder is not a conforming kaval of this estate, and the fail-fast / no-fallbacks rule holds (a degrade-to-2 path would have been the very fallback this codebase forbids). This is stated and pinned, not left as emergent behaviour.

Where it lives (structure)

The recovery arm grafts onto the endpoint spine (@kolu/surface-daemon-supervisor), beside the machinery it reuses — the endpoint already owns killLiveHolder, waitForPidGone, spawnConnectHold, and the gate read. It is a Node-only module (it already imports gatePid / isHolderLive and dials node:net), so a new sibling leaf is a clean fit:

Exported-API additions (named honestly for the consumer gates)

The recycle only ever fires on a skew, and a skew throws DaemonContractSkewError before a DaemonConnection is ever built — so the pid the kill cross-checks rides on the error, not the connection. The surface additions to @kolu/surface-daemon-supervisor are therefore:

A pid on the connection (DaemonConnection) was not added: a compatible holder is adopted, never killed, so nothing would ever read it — a dead field is worse than none.Caught by the architecture-first-principles gate (C2 consumer-ergonomics): the first cut added pid to DaemonConnection too, but the recovery reads the pid only off the skew error (the kill path), and the adopt path disposes the connection without touching it — a field with zero readers and a doc comment that lied about who read it. Deleted. The DaemonContractSkewError.pid change is additive-optional, so no existing consumer breaks — verified, not asserted: the drishti and odu consumer greps found neither repo depends on @kolu/surface-daemon-supervisor, so the additive change has zero cross-repo impact. ref-surface-supervisor.mdx is updated in the same PR. The one genuinely new capability is the OS socket-holder lookup.

Design philosophy

Acceptance — the pins

Each pin is red on today’s code before the fix and green after.

Pin Manufacture Today After
The squatter spawn a real kaval, delete its gate file, boot/recycle wedged: fresh cannot bind; card loops holder identified → handshaked → recycled; fresh binds; durable (old holder gone)
Foreign holder a plain non-kaval net server bound at the path (falls through / opaque) not killed; loud typed error naming pid + command
Pid-absent speaker a socket that accepts but whose system.version omits pid (fails schema) (falls through / opaque) classified foreignnot killed; loud typed error
Compatible holder a gate-less holder that speaks a compatible contract (connected/adopted) adopted, not killed — PTYs preserved (no regression)
Gate-present unchanged the existing killLiveHolder / adopt suites green green
Version-agnostic an OLD-contract kaval (handshake succeeds, version skews) wedged (this IS 25494) identified as kaval → recycled

The end-to-end proof, on a pu box (or sincereintent if offered): manufacture the gate-less squatter, then drive the real UI flow — the incompatible card’s “Update & restart” — and watch it converge one click, no manual reaping. The point is not that a test passes; it is that the product heals itself.

The adversaries this must survive (for the lens gate)