Repository navigation
feat(lifecycle): ownership-safe shared host helper contract and fixtures - #343
Merged
Merged
Conversation
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>
This comment has been minimized.
This comment has been minimized.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.singletonis unchanged.Final behavior:
owned,adoptedordisabled. Phase isstarting,readyorfailed. A disabled helper takes no lock, writes no claim and starts no child.pkill.TZ=UTC0andLC_ALL=C(inprocess-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 reportedclaim-invalid, never adopted. Invalid, other-port, oversized or group/world-readable claims and an unavailable census fail closed.startingclaim 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.claim-invalidor the newstate-unavailable, neverlock-unavailable.Rework after verification (this push)
TZ: a client in another zone saw a live owned helper as stale, re-adopted it, and the last release leaked itreadProcessIdentitiespinsTZ=UTC0; a stale claim whose PID still answers is kept, not adoptedUTC0vsHST10starters); "a stale owned claim whose PID still answers…"foreign-listenerdeadlinegate-openedleft a stuckstartinghelperstartinghelper that still cannot become readylock-waitingand not acquire it for 300 mslock-unavailable; a gate could wait while the starter livedreadReplaceablePrivateFilegains an optional byte bound checked on the opened filelinux-process-lifetimejob. Its first run (head5d0f337) failed 19 tests: on Linux a refused loopback fetch does not carry the macOSECONNREFUSEDcode, so every free port read as a foreign listener. Absence is now decided by the bind probe on every platform (headd056fc8)linux-process-lifetimeond056fc8: 63 pass, 0 fail; "foreign listeners…" now includes a connection-dropping listenerValidation
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 macOStestjob, 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. On5d0f337it passed 4 times in the system zone and once withTZ=America/Los_Angeles; on the final headd056fc8it 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.actionlint1.7.12 onci.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 checkon the 6 changed source/test files: clean.bun run privacy:checkwith all changes staged: ok.deadline; EDGE2 later starters returndeadlineand the abandoned helper is stopped and its claim removed; EDGE3 rethrows the observer error.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 withcmpafterwards. Every mutation below failed its test.TZ=UTC0from the censusensure(5 runs, 5 failed)startinghelperMath.max(1, remaining), timeout = foreign; 3 runs)child-exitedcheckreleaseThe 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
http://127.0.0.1:<port>.hack.config.json,hack up/downor the daemon, and migratinglifecycle.singletonconfigurations.Remaining gaps
linux-process-lifetimepass. The mutation controls ran on macOS only.lstartdrift. Linuxpsderiveslstartfrom 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.lstartsecond is not defended, as in the existing process-tree helpers.host-command-observation.tsandnative-compose-private-state.tsrun their ownps lstartwithout a fixedTZ. They are outside this change; whether their persisted births have the same issue is not checked here.retainedand changes nothing; it is not a distinct state.🤖 Generated with Claude Code