Skip to content

feat(lifecycle): ownership-safe shared host helper contract and fixtures - #343

Merged
roodboi merged 5 commits into
nextfrom
wf/f19-host-lifecycle-b16
Oct 20, 2026
Merged

roodboi merged 5 commits into
nextfrom
wf/f19-host-lifecycle-b16

Conversation

@roodboi

@roodboi roodboi commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Linear: HACK-1142 (WU29, ownership-safe shared host lifecycles). This PR delivers the source contract and portable fixtures. It does not meet every HACK-1142 acceptance item on its own (see "Acceptance not met here").

Adds a source contract (docs/reference/shared-host-lifecycle.md) and its library (src/lib/shared-host-lifecycle.ts) for long-lived local helpers that several branches share through one loopback port. Examples are issuer bridges, proxies, port-forwards and opt-in tunnels. Nothing in the CLI or configuration calls it yet; WU18 consumers adopt it in their own changes. lifecycle.singleton is unchanged.

Final behavior:

  • States. Ownership is owned, adopted or disabled. Phase is starting, ready or failed. A disabled helper takes no lock, writes no claim and starts no child.
  • Serialized starters. Starters take the existing projects-registry lock protocol in the caller's private state directory. One deadline covers the lock wait, the port probe, the spawn and readiness.
  • Identity before adoption. A listener is adopted only after its identity endpoint names the expected helper and the branch's upstream. A wrong upstream, a different helper, an identity answer over 4 KiB, or a silent, connection-dropping or non-identity listener is refused and left running. A failed connection never proves the port is free by its error code; a loopback bind probe decides. No kill-by-port and no pkill.
  • Exact ownership. A 0600, at most 8 KiB claim records PID plus birth time. Births are read with TZ=UTC0 and LC_ALL=C (in process-tree.ts), so clients with different time zones agree. A dead or reused PID makes the claim stale: it is discarded and that PID is never signalled. A stale claim whose PID still answers as the helper is kept and reported claim-invalid, never adopted. Invalid, other-port, oversized or group/world-readable claims and an unavailable census fail closed.
  • Exec gate. The owned child is spawned in a new session behind an exec gate that waits on stdin until the claim is durable and cancellation has been checked.
  • Readiness. Readiness is the identity answer, never liveness. Retries back off under the deadline. Cancellation interrupts probes and backoff sleeps, then stops the freshly spawned child and removes its claim.
  • Lost parent. A starting claim found under the lock has lost its starter. If its helper still cannot become ready, the next starter stops it through the birth-verified tree and removes the claim.
  • Release. Release drops one holder. Only the last holder stops an owned helper (TERM, grace, KILL; each signal re-checks the birth time). Adopted helpers are never signalled. An unconfirmed stop keeps the claim.
  • Diagnostics. Failures and progress events are closed sets with fixed messages. An observer error is rethrown unchanged after cleanup. Other errors raised under the lock are claim-invalid or the new state-unavailable, never lock-unavailable.

Rework after verification (this push)

Verifier finding Fix Regression test
Birth time depended on TZ: a client in another zone saw a live owned helper as stale, re-adopted it, and the last release leaked it readProcessIdentities pins TZ=UTC0; a stale claim whose PID still answers is kept, not adopted "clients in different time zones…" (UTC0 vs HST10 starters); "a stale owned claim whose PID still answers…"
Lock wait used up the deadline, then a healthy helper was reported foreign-listener Deadline checked before the probe; a probe timeout shortened by the deadline is deadline "a lock wait that uses up the deadline…"; "a port probe cut short by the deadline…"
Starter lost after gate-opened left a stuck starting helper The next starter stops an abandoned starting helper that still cannot become ready "a starter lost after opening the gate…"
Concurrent-starter negative control relied on timing Starter A pauses holding the lock; B must show lock-waiting and not acquire it for 300 ms "independent concurrent starters…"
Documented checks with no test One test per check (see negative controls) 12 new tests
Observer or gate errors became lock-unavailable; a gate could wait while the starter lived Errors under the lock are classified separately; observer errors are rethrown after the gate is closed or the started helper stopped "an observer error is rethrown…"
Claim size checked after reading, by characters readReplaceablePrivateFile gains an optional byte bound checked on the opened file "a claim for another port, a group-readable claim and an oversized claim fail closed"
No Linux receipt Suite registered in the hosted linux-process-lifetime job. Its first run (head 5d0f337) failed 19 tests: on Linux a refused loopback fetch does not carry the macOS ECONNREFUSED code, so every free port read as a foreign listener. Absence is now decided by the bind probe on every platform (head d056fc8) hosted linux-process-lifetime on d056fc8: 63 pass, 0 fail; "foreign listeners…" now includes a connection-dropping listener

Validation

Hosted CI on head d056fc8: linux-process-lifetime (Ubuntu 24.04, Bun 1.4.2) passed, 63 pass and 0 fail across its 6 files, including this suite. When this was written, the macOS test job, the docker-e2e shards and the macOS compiler/candidate jobs were still running or queued.

Local runs on macOS (Darwin 25.5, arm64), Bun 1.4.3:

  • bun test tests/shared-host-lifecycle.test.ts: 32 pass, 0 fail. On 5d0f337 it passed 4 times in the system zone and once with TZ=America/Los_Angeles; on the final head d056fc8 it passed twice. No fixture, gate or starter process was left running afterwards.
  • tests/integration-ownership.test.ts, tests/cli-spawn-env-isolation.test.ts, tests/private-env-file.test.ts, tests/dependency-startup-progress.test.ts: 34 pass. Run 19 times; one run reported 1 failure that I did not capture or reproduce in the next 18 runs.
  • tests/native-cpu-diagnostics.test.ts, tests/native-compose-adoption-files.test.ts, tests/project-env-worktree-key.test.ts, tests/project-lifecycle-singleton.test.ts (other users of the changed helpers): pass.
  • tests/prerelease-plan.test.ts, tests/ci-bun-crash-census.test.ts (read the CI job list): pass. actionlint 1.7.12 on ci.yml: clean.
  • bun run --cwd packages/cli typecheck: exit 0. bun run --cwd packages/cli check: 0 errors, 34 warnings, all in other files. bunx ultracite check on the 6 changed source/test files: clean.
  • bun run privacy:check with all changes staged: ok.
  • The verifier's TZ and edge probes rerun against this head: the second client now reuses the owned helper and both releases stop it; EDGE1 returns deadline; EDGE2 later starters return deadline and the abandoned helper is stopped and its claim removed; EDGE3 rethrows the observer error.
  • No CLI surface or agent-guidance change, so no CLI reference or plugin regeneration.

Negative controls

Each mutation was applied to the source alone and the named test was run (bun test … -t <name>). The source was restored and checked with cmp afterwards. Every mutation below failed its test.

Mutation Failing test
drop TZ=UTC0 from the census different time zones
no lock for ensure (5 runs, 5 failed) concurrent starters
keep an abandoned starting helper lost parent after gate
original deadline handling (Math.max(1, remaining), timeout = foreign; 3 runs) lock wait uses up deadline
remove only the pre-probe deadline check lock wait uses up deadline
a deadline-shortened probe timeout counts as foreign probe cut short
adopt a stale claim whose PID answers stale PID still answering
ignore the claim port invalid claims
ignore the claim mode invalid claims
no claim byte bound invalid claims
no child-exited check exits during startup
no abort check between claim and gate cancel before the gate
no SIGKILL escalation SIGTERM ignored
child inherits the runner environment session and environment
not detached session and environment
backoff sleep ignores the signal cancel during backoff
no lock for release release lock
no 4 KiB identity bound identity over 4 KiB
unconfirmed stop deletes the claim stop unconfirmed
observer error path leaves the gate open observer error
observer error swallowed into a result observer error
no bind probe after a failed connection foreign listeners (connection-dropping listener)
a failed connection counts as foreign owned start (proves absence goes through the bind probe on macOS too)

The earlier controls (pid-only identity, adopting unidentified listeners, ignoring the upstream, gate opened before claim, liveness as readiness, release ignoring holders, keeping a failed child, root-only stop, trusting an empty census, ignoring enabled, ignoring an invalid claim) use tests that this push did not weaken. I did not rerun them against this head.

Acceptance not met here

  • Public tunnel readiness against the intended external application route, with the trust that route needs. Host- and provider-bound; left to consumer WU18-P06.
  • Branch-specific URL derivation. Callers derive the expected upstream per branch; the endpoint this library returns is always http://127.0.0.1:<port>.
  • Wiring into hack.config.json, hack up/down or the daemon, and migrating lifecycle.singleton configurations.

Remaining gaps

  • Linux coverage is the hosted job only. The Linux receipt is one linux-process-lifetime pass. The mutation controls ran on macOS only.
  • Linux lstart drift. Linux ps derives lstart from the boot time, which can move when the wall clock is changed. Start then fails closed if the helper reports its PID, but a release would treat the claim as stale and clear it without stopping the helper. macOS is not affected.
  • PID reuse within one lstart second is not defended, as in the existing process-tree helpers.
  • host-command-observation.ts and native-compose-private-state.ts run their own ps lstart without a fixed TZ. They are outside this change; whether their persisted births have the same issue is not checked here.
  • A release by a holder that is not recorded reports retained and changes nothing; it is not a distinct state.
  • No TLA+ model. The executable negative controls are the oracle for the adoption and release races.
  • Native (macOS VM) and container-runtime effects are not covered.

🤖 Generated with Claude Code

roodboi and others added 3 commits October 19, 2026 11:51
Add a library contract for long-lived helpers shared across branches on one
loopback port (HACK-1142, WU29). Starters are serialized by the
projects-registry lock protocol; a listener is adopted only after its
identity endpoint names the expected helper and branch upstream; owned
helpers are recorded as PID plus process birth time and spawned behind an
exec gate that only opens once the claim is durable, so a lost starter never
leaves an unclaimed child. Readiness is the identity answer, bounded by
backoff and one deadline, with cancellation stopping the owned child.
Release drops one holder and stops an owned helper's birth-verified tree
only for the last holder; adopted, foreign and stale-PID processes are never
signalled. Failures are a closed, value-free set.

Portable process fixtures cover adoption, wrong upstream, foreign
listeners, concurrent independent starters, stale/reused PIDs, child
restart, cancellation during retry, placeholder liveness, lost parent
before and after the gate, descendants, impostor identity, invalid claims
and an unavailable census.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and lost starts

Read process births in UTC so clients with different TZ values agree on a
live owned helper; previously a second client saw it as stale, re-adopted it
and the last release leaked it. A stale owned claim whose PID still answers
as the helper is now kept (claim-invalid) instead of adopted.

Report deadline (not foreign-listener) when lock contention or the deadline
cut the port probe short. Stop a helper left in phase starting by a lost
starter once it still cannot become ready. Rethrow observer errors after
closing the gate or stopping the started helper, and report state errors
raised under the lock as claim-invalid/state-unavailable rather than
lock-unavailable. Bound the claim read by bytes before reading it.

Add a lock barrier to the concurrent-starters fixture and regression tests
for every documented check; register the suite in the Linux process job.

Refs: HACK-1142

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y platform

The hosted Linux job showed that a refused loopback fetch does not carry the
macOS ECONNREFUSED code there, so every free port was misread as a foreign
listener. A failed connection now never proves absence by its error code:
the loopback bind probe decides (occupied is foreign-listener, free spawns).
Add a listener that drops every connection to the foreign-listener fixture.

Refs: HACK-1142

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@blacksmith-sh

This comment has been minimized.

roodboi and others added 2 commits October 19, 2026 13:44
The "stop that cannot be confirmed" test froze its starter with
Subprocess.kill("SIGSTOP"). On macOS, the pinned Bun 1.4.2 delivers
SIGCONT for that call, so the starter kept running, reaped the helper
after TERM, and release reported `stopped`. CI uses the pinned Bun, so
the test failed there; local runs passed only on an unpinned Bun 1.4.3.

The scenario also showed a product defect. The census counted a zombie
(an exited, unreaped process) as live. A stop that left the helper a
zombie reported `stop-unconfirmed`. A `ready` claim whose helper crashed
under a suspended starter looked live, so other clients could not
restart the helper until that starter resumed. A zombie runs no code
and holds no listener, and its PID cannot be reused until it is reaped.
The census now reads `ps` state and treats zombies as exited.

The unconfirmed-stop test now injects the census and signal boundary
through a narrow `processes` seam on release, because no real process
can portably survive SIGKILL. A separate real-process test freezes the
starter with process.kill and asserts that the helper is an unreaped
zombie and that release reports `stopped`.

Refs HACK-1142

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clients of a helper port coordinate only through the claim and lock
under `<stateDir>/<name>`. The previous JSDoc called stateDir a
"caller-owned private directory", which suggested one directory per
caller. A client with its own directory finds no claim. It adopts the
running owned helper, and the owner's last release then stops a helper
that client still holds. Its starts are also not serialized with the
owner's.

The spec JSDoc, the module contract and the reference doc now state that
every client of one port must pass the same stateDir and name, derived
from the helper and port rather than the branch.

Refs HACK-1142

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 20, 2026 13:57
@roodboi
roodboi merged commit 717acac into next Oct 20, 2026
16 checks passed
@roodboi
roodboi deleted the wf/f19-host-lifecycle-b16 branch October 20, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant