From 0c7acc00d3831a7456ab6074512d5f9fac1ec4e9 Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 19:03:30 -0500
Subject: [PATCH 01/55] feat(lifecycle): add standalone Rust process supervisor
---
.github/workflows/lifecycle-supervisor.yml | 46 +
docs/diagrams/lifecycle-supervisor.mmd | 27 +
docs/diagrams/lifecycle-supervisor.svg | 66 +
docs/lifecycle-supervisor.md | 169 +++
docs/lifecycle.md | 4 +
docs/reference/v5-model-coverage.md | 2 +
packages/lifecycle-supervisor/Cargo.lock | 114 ++
packages/lifecycle-supervisor/Cargo.toml | 15 +
packages/lifecycle-supervisor/README.md | 37 +
packages/lifecycle-supervisor/src/main.rs | 42 +
packages/lifecycle-supervisor/src/os.rs | 559 +++++++++
packages/lifecycle-supervisor/src/protocol.rs | 235 ++++
packages/lifecycle-supervisor/src/storage.rs | 218 ++++
.../lifecycle-supervisor/src/supervisor.rs | 523 ++++++++
.../lifecycle-supervisor/tests/fake_caller.rs | 1084 +++++++++++++++++
scripts/lib/tla-runtime-models.ts | 8 +
.../tla/lifecycle-supervisor/COVERAGE.md | 38 +
.../LifecycleSupervisor.tla | 85 ++
.../tla/lifecycle-supervisor/negative.cfg | 4 +
.../tla/lifecycle-supervisor/positive.cfg | 4 +
20 files changed, 3280 insertions(+)
create mode 100644 .github/workflows/lifecycle-supervisor.yml
create mode 100644 docs/diagrams/lifecycle-supervisor.mmd
create mode 100644 docs/diagrams/lifecycle-supervisor.svg
create mode 100644 docs/lifecycle-supervisor.md
create mode 100644 packages/lifecycle-supervisor/Cargo.lock
create mode 100644 packages/lifecycle-supervisor/Cargo.toml
create mode 100644 packages/lifecycle-supervisor/README.md
create mode 100644 packages/lifecycle-supervisor/src/main.rs
create mode 100644 packages/lifecycle-supervisor/src/os.rs
create mode 100644 packages/lifecycle-supervisor/src/protocol.rs
create mode 100644 packages/lifecycle-supervisor/src/storage.rs
create mode 100644 packages/lifecycle-supervisor/src/supervisor.rs
create mode 100644 packages/lifecycle-supervisor/tests/fake_caller.rs
create mode 100644 tests/models/tla/lifecycle-supervisor/COVERAGE.md
create mode 100644 tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla
create mode 100644 tests/models/tla/lifecycle-supervisor/negative.cfg
create mode 100644 tests/models/tla/lifecycle-supervisor/positive.cfg
diff --git a/.github/workflows/lifecycle-supervisor.yml b/.github/workflows/lifecycle-supervisor.yml
new file mode 100644
index 000000000..54bd02643
--- /dev/null
+++ b/.github/workflows/lifecycle-supervisor.yml
@@ -0,0 +1,46 @@
+name: Lifecycle supervisor
+
+on:
+ pull_request:
+ paths:
+ - packages/lifecycle-supervisor/**
+ - .github/workflows/lifecycle-supervisor.yml
+ push:
+ branches: [next, main]
+ paths:
+ - packages/lifecycle-supervisor/**
+ - .github/workflows/lifecycle-supervisor.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: lifecycle-supervisor-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ contract:
+ name: Supervisor (${{ matrix.os }})
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-15, macos-15-intel]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 15
+ env:
+ HACK_DAEMON_AUTOSTART_DISABLE: '1'
+ CARGO_TARGET_DIR: ${{ github.workspace }}/.hack-local/target/lifecycle-supervisor
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@1.97.1
+ with:
+ components: rustfmt, clippy
+ - name: Check formatting
+ run: cargo fmt --manifest-path packages/lifecycle-supervisor/Cargo.toml --check
+ - name: Lint standalone supervisor
+ run: cargo clippy --locked --manifest-path packages/lifecycle-supervisor/Cargo.toml --all-targets -- -D warnings
+ - name: Test standalone supervisor with private fake callers
+ run: cargo test --locked --manifest-path packages/lifecycle-supervisor/Cargo.toml -- --test-threads=4
+ - name: Build standalone release executable
+ run: cargo build --locked --release --manifest-path packages/lifecycle-supervisor/Cargo.toml
diff --git a/docs/diagrams/lifecycle-supervisor.mmd b/docs/diagrams/lifecycle-supervisor.mmd
new file mode 100644
index 000000000..06cb2bd80
--- /dev/null
+++ b/docs/diagrams/lifecycle-supervisor.mmd
@@ -0,0 +1,27 @@
+---
+title: Lifecycle supervisor · ownership and recovery
+---
+stateDiagram-v2
+ direction TB
+ [*] --> Absent
+ Absent --> Starting : Reserve [locks acquired]
+ Starting --> Running : Spawn [identity durable]
+ Starting --> Exited : StartFailure
+ Running --> Recoverable : SupervisorDeath
+ Recoverable --> Recoverable : Retry [duplicate refused]
+ Running --> Orphaned : GuardDeath [members live]
+ Recoverable --> Orphaned : GuardDeath [members live]
+ Recoverable --> Stopping : Stop [identity matches]
+ Running --> Stopping : Stop [identity matches]
+ Running --> Exited : Reap [no live members]
+ Stopping --> Exited : Reap [no live members]
+ Stopping --> KillPending : Kill [grace elapsed]
+ KillPending --> Exited : Reap [no live members]
+ KillPending --> StopUnknown : StopUnknown [members live]
+ note right of Starting
+ INV identity precedes command
+ end note
+ note right of Orphaned
+ Unknown identity refuses stop
+ Live session refuses duplicate
+ end note
diff --git a/docs/diagrams/lifecycle-supervisor.svg b/docs/diagrams/lifecycle-supervisor.svg
new file mode 100644
index 000000000..32561f1ad
--- /dev/null
+++ b/docs/diagrams/lifecycle-supervisor.svg
@@ -0,0 +1,66 @@
+Lifecycle supervisor · ownership and recovery Reserve [locks acquired] Spawn [identity durable] StartFailure SupervisorDeath Retry [duplicate refused] GuardDeath [members live] GuardDeath [members live] Stop [identity matches] Stop [identity matches] Reap [no live members] Reap [no live members] Kill [grace elapsed] Reap [no live members] StopUnknown [members live] Absent Running Exited Recoverable Stopping KillPending StopUnknown Starting Orphaned INV identity precedes command Unknown identity refuses stop Live session refuses duplicate
\ No newline at end of file
diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md
new file mode 100644
index 000000000..f9158d940
--- /dev/null
+++ b/docs/lifecycle-supervisor.md
@@ -0,0 +1,169 @@
+# Lifecycle supervisor: standalone contract
+
+This first slice builds a standalone Rust supervisor with a fake caller. It does
+not change `hack up`, configuration, or the default runner. CLI integration follows
+lifecycle retirement changes; packaging is a separate change. The current default
+continues to use the lifecycle mux session described in [lifecycle](./lifecycle.md).
+
+## Artifact choice
+
+`packages/lifecycle-supervisor` builds `hack-lifecycle-supervisor`. A separate crate
+adds one executable, but compiles only its small process-management dependency set.
+A config-compiler subcommand would save that artifact while mixing pure compilation
+with process, signal and recovery code. A runtime-core subcommand would bring the
+broader native runtime dependency graph into Compose installations. The separate
+crate keeps those build costs and failure boundaries independent.
+
+The supported build targets are `aarch64-apple-darwin`, `x86_64-apple-darwin`, and
+`x86_64-unknown-linux-gnu`. A successful Linux run does not qualify macOS behavior.
+
+## Protocol and private state
+
+The binary accepts `run`, `stop`, and `status`. Input and output are one four-byte
+big-endian length followed by that many JSON bytes. Frames are bounded to 1 MiB;
+each frame read or write has one 10-second deadline. A complete frame, rather than descriptor close,
+commits delivery. Environment values travel only in the input frame and process
+memory. They are never serialized into state, command-line arguments, or errors.
+Authored commands can themselves print values; output remains unredacted and private.
+
+`run` accepts `{version: 1, root, ownerToken, processes}`. `root` is an absolute,
+existing, owner-only directory for one project instance. `ownerToken` is a fresh
+opaque ownership token supplied by the caller. Each process has `name`, `argv`,
+absolute `cwd`, and `env`. Names are safe file identifiers and must also be unique
+without regard to ASCII case, including on case-sensitive filesystems. Commands execute argv
+directly. A shell-authored command can explicitly use `sh -c`; the supervisor adds
+no shell wrapper. The full environment is explicit, with no ambient inheritance.
+
+`stop` and `status` accept `{version: 1, root, ownerToken}`. Read-only `status`
+returns persisted receipts; a receipt is not a health check. It creates no files,
+processes or ownership. Stop
+requires matching ownership and a live identity before sending a signal. Unknown
+identity is a refusal, never proof that a process is absent. A different boot or a
+leader with a different start identity proves that the old session ended; it
+grants no authority over the replacement. Old terminal receipts are retained and
+ignored only after native absence is proved.
+
+Replies contain `version`, `supervisorPid`, `processes`, and `outcome` (`ready`,
+`observed`, `stopped`, or `unknown`). A stop whose confirmation times out emits
+`unknown` and exits nonzero. Callers must inspect both the receipt and exit status.
+`run` must be spawned as a fresh child without making it a process-group leader:
+the supervisor calls `setsid` itself. A caller using `detached: true` or
+`setpgid(0, 0)` is refused before authored code starts.
+
+The root contains per-command state and lock files, an instance lock and
+`lifecycle.log`. Directories are mode 0700; files are mode 0600. Symlinks,
+hard-linked files, foreign owners and broad permissions are refused. State contains
+name, ownership token, guard PID, process group, OS start identity, boot identity,
+UID, command PID when known, phase, and the direct command's exit code or signal
+when observed. It contains no command or environment.
+State is atomically published and synchronized before authored code can run.
+The guard leads a new session in its own process group. The authored command
+starts in a separate group within that session. Cleanup covers every live group
+in the session, so a command moving into another process group stays owned.
+
+The log uses the existing lifecycle reader contract:
+`UTC timestampservicestdout|stderr|metamessage`.
+Stdout and stderr drain independently; tabs in messages become four spaces. Lines
+longer than 64 KiB become consecutive records so memory remains bounded. An
+unterminated final line is retained. Records from different streams can interleave;
+there is no total ordering guarantee between stdout and stderr.
+
+All descriptors the supervisor opens are close-on-exec. The command receives only
+its intended standard descriptors: null stdin, piped stdout and stderr. On macOS,
+creation and setting close-on-exec are not always atomic. Delivery uses explicit
+frames with deadlines; process liveness comes from an OS census, and post-exit
+output draining is bounded. None depends on another process closing an accidentally
+inherited descriptor.
+
+## State model
+
+This transition table is the first model. Each row maps to a fake-caller regression
+in `packages/lifecycle-supervisor/tests/fake_caller.rs`. `guard` means a resident Rust
+child that owns the command's session and process group. The project supervisor
+owns and reaps guards; each guard owns and reaps its direct command child.
+The table's labels describe abstract control states and request outcomes. Persisted
+phases use lowercase names; `Orphaned` and `StopUnknown` are recovery observations,
+not claims that a dead or paused guard rewrote its receipt.
+
+| Before | Event and guard | Effect | After | Regression |
+| --- | --- | --- | --- | --- |
+| Absent | Start, instance and command locks acquired | Guard creates session; durable identity precedes command spawn | Starting | `starts_and_records_identity` |
+| Starting | Command spawn succeeds | Publish command PID; deliver ready receipt | Running | `starts_and_records_identity` |
+| Starting | Invalid executable or publication fails | No unrecorded command; terminate owned partial start | FailedStart/refused | `failed_start_runs_no_later_command` |
+| Running | Stop with exact owner, boot and start identity | TERM authored session members | Stopping | `stop_after_caller_closed` |
+| Stopping | No non-zombie members remain | Reap child; retain terminal evidence | Exited | `stop_after_caller_closed` |
+| Stopping | Five-second grace expires, identity still matches | KILL authored session members, keeping the guard alive | KillPending | `term_ignoring_command_is_killed_after_grace` |
+| KillPending | Census confirms no live members | Guard reaps and releases command lock | Exited | `term_ignoring_command_is_killed_after_grace` |
+| Stopping/KillPending | Confirmation deadline expires with live members | Retain identity and command lock; report uncertainty | StopUnknown | `unresponsive_guard_stop_remains_unknown` |
+| Running | Project supervisor dies | Guard and durable identity remain | Running/recoverable | `supervisor_kill_then_recover_stop` |
+| Recoverable | Another start sees held command lock or live old identity | Refuse duplicate; preserve original | Recoverable | `supervisor_kill_then_recover_stop` |
+| Running/recoverable | Guard itself is separately killed with live session members | Retain recorded session, refuse duplicate and unsafe stop | Orphaned | `dead_guard_with_live_session_refuses_duplicate_and_stop` |
+| Any live recorded | Owner differs or identity is unknown | Refuse; signal nothing | Unchanged | `foreign_owner_cannot_stop` |
+| Any recorded | Boot differs or leader PID is reused | Treat old session as absent; signal no replacement | Unchanged | `stale_reused_session_leader_does_not_block_fresh_launch` |
+| Running | Command exits; only zombies remain | Reap direct child; zombies do not hold liveness | Exited | `zombies_do_not_hold_liveness` |
+
+
+
+A resident guard closes the publication gap: command ownership is durable before
+spawn, and the group leader survives the project supervisor's death. The guard
+keeps the command lock until its session has no live members, so a restarted project
+supervisor cannot launch a second copy. If a guard itself is separately killed
+while its command survives, identity becomes unknown; recovery refuses unsafe
+signals and duplicate launch. This is a distinct failure from killing the project
+supervisor and remains visible to callers rather than being reported as a
+successful stop. Launch and stop share a bounded lock so stop cannot race past a
+partially launched plan.
+
+The table models process death and retry, not filesystem hardware failure, hostile
+same-user mutation, commands that deliberately create new sessions, or power-loss
+recovery of live processes. Commands that daemonize out of their session are outside
+this ownership contract. Native tests qualify only their host OS. Darwin has no
+pidfd: its identity-to-signal interval, and numeric group signaling on both
+platforms, retain a small kernel observation/effect race. The model assumes the
+observed identity remains valid across that interval.
+
+## Terminal behavior and later work
+
+The current mux runner supplies a terminal, though its FIFO wrapper redirects
+command output. A command that prompts or requires a controlling terminal can
+therefore depend on that mux environment today. The standalone supervisor provides
+no PTY: stdin is `/dev/null`, output is piped, and there is no controlling terminal.
+Such a command sees EOF or its own non-TTY failure. It must use a noninteractive
+mode or remain in an interactive workspace until PTY support exists.
+
+Later work can add an explicit PTY mode and a small attach client so a persistent
+interactive workspace can exist without tmux. An outside-tool provider interface
+should deliver environment on stdin, with explicit framing and launch receipts.
+Neither feature is part of this unit; interactive workspaces keep tmux by default.
+
+## Delivery gates
+
+The CLI opt-in is planned as `lifecycle.runner = "mux" | "supervisor"`, default
+`mux`, plus a test environment override. Both runners must pass existing lifecycle
+unit and e2e scenarios before that integration is ready. Packaging must include the
+binary wherever the Compose CLI installs: stable archives, prerelease bundles,
+Homebrew, self-upgrade and supported container distributions. Existing prerelease
+packaging is macOS arm64 only; Intel/Linux prerelease support needs its own explicit
+qualification rather than assuming the stable archive matrix covers it.
+
+The packaging PR inventory is:
+
+- New `scripts/build-lifecycle-supervisor.ts`, root `package.json`, and CLI build
+ wiring in `packages/cli/package.json` where needed.
+- `scripts/build-release.ts`, `scripts/update-homebrew-tap.ts`,
+ `src/lib/release-install.ts`, and `src/lib/release-staging.ts`.
+- `scripts/build-native-candidate.sh`, `scripts/prerelease-plan.ts`,
+ `scripts/installer/integrity.ts`, `scripts/installer/bundle.ts`, and retained
+ `scripts/install-prerelease.py` compatibility inventories.
+- `.github/workflows/ci.yml`, `.github/workflows/release.yml`,
+ `.github/workflows/prerelease.yml`, and `docker/slim-runtime/Dockerfile`.
+- `.hack/toolchain/run.sh`, `.hack/README.md`, and install/prerelease docs.
+- Archive, Homebrew, upgrade and prerelease inventory regression tests, plus an
+ installed supervisor artifact check analogous to the config compiler check.
+
+Default migration removes the Python/FIFO wrapper in
+`src/lib/project-lifecycle-controller.ts` and the shell execution gate in
+`src/lib/shared-host-lifecycle.ts`. Finite shell-authored hooks continue to use
+POSIX `sh -c` intentionally. Interactive tmux environment delivery stays separate.
+Zellij removal, its breaking-change notice, and the workspace-only `MuxBackend`
+contract belong to the third unit.
diff --git a/docs/lifecycle.md b/docs/lifecycle.md
index 37e052814..c30bce50b 100644
--- a/docs/lifecycle.md
+++ b/docs/lifecycle.md
@@ -4,6 +4,10 @@ Many projects need host-side setup around `hack up` (auth, local proxies, tunnel
Lifecycle is configured in `.hack/hack.config.json` under `lifecycle`.
+The [standalone Rust supervisor contract](./lifecycle-supervisor.md) describes
+the separately tested supervisor work. It does not change the current mux runner
+or expose a new configuration setting in this slice.
+
With the Compose backend, the first Ctrl-C during foreground `hack up` asks
Compose to stop the project containers. Hack waits for Compose to finish stopping
them and drains its logs before exiting with code 130. A second Ctrl-C is forwarded
diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md
index d96eb13a3..d55c0653b 100644
--- a/docs/reference/v5-model-coverage.md
+++ b/docs/reference/v5-model-coverage.md
@@ -36,6 +36,7 @@ must be attached to the eventual exact integration revision.
| Property and critical states | Model coverage | Implementation/regression boundary | Remaining qualification |
| --- | --- | --- | --- |
+| Standalone lifecycle supervisor: durable pre-launch identity, retained guard lock, project-supervisor loss, separately lost guard, verified stop and unknown stop | [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/COVERAGE.md) | `packages/lifecycle-supervisor` and its fake-caller contract tests; no CLI integration or default change in this slice | One command name, at most two starts, matching/foreign/unknown identities, arbitrary process exit and supervisor/guard loss; 129 states. Negative control removes the live-session retry fence and violates `NoDuplicate`. Native PID identity, filesystem synchronization, descriptors, output and macOS effects require implementation tests. |
| Native status across startup lease stages; failed start with running services (HACK-2110) | [native-status-start](../../tests/models/tla/native-status-start/COVERAGE.md) | Observer-only presentation, stable journal fence, distinct authored Observe/Status replies; real-flock held-reader start test and expiry diagnostic; plain-down exact-proof recovery tests. Failure capture occurs at startup/effect verification and owned cleanup, never status; first failure wins, incomplete-rebind capture and owner-crash reconstruction are excluded | Seven acquisition stages, one reader, one start; 110 states. Negative control fails after services start. Second-mutator refusal and complete recovery proof are assumptions checked separately in code, not independently proved by this model. No new control-loop tick or lease; cleanup settle is bounded at ten seconds per exited listener. Lead Mac gate: ps every 0.5 s across ten starts, cross-project polling and failed-start plain down. |
| Guest control ready before exposed bridge sockets bind; identity changes or the startup deadline expires | No new TLA+ model; deterministic clock schedules both readiness orders against real Unix socket nodes | `provider/bridge.rs` waits only in fresh `finish_boot`, before adoption, and keeps running audits immediate. `bridge_startup_tests.rs` covers two slots, late creation and mode tightening, permanent absence/unsafe modes, type/UID/link refusals, provider loss before/after observation and late success rejection. No wait mutates sockets or grants cleanup authority; the existing verified-provider stop handles failure. `lifecycle/boot_sequence_tests.rs` drives the real waiter through the production fresh-boot sequence and rejects audit/adoption on timeout, unsafe sockets or provider loss. `native-runtime-client.test.ts` checks the fixed runtime recover command and identical-root hint reach the frontend | Two sockets, finite scheduled events, injected provider verification and monotonic clock. Kernel process identity, provider scheduling, disk durability and successful recovery remain host boundaries. Require ten consecutive real-VM Mac Ctrl-C scenarios with pool stop/start; portable tests are not that evidence. |
| CI cancellation must not lose merged-change coverage; only exact-head full evidence may qualify a prerelease | No maintained GitHub scheduler model. Finite fixture interleavings cover predecessor success/failure/cancellation, two competing full/scoped runs and two rerun attempts | `ci.yml` serializes next pushes in one non-cancelling group (GitHub cancels only the replaced pending run), cancels stale PR runs, and requires a successful before run for selection. `selectPrereleaseCiRun` reads current attempts; `requireBranchChecks` binds suite and job check IDs. `ci-trusted-policy`, `prerelease-plan` and `release-prepare-gate` regressions reject cancelled predecessors, green subsets, stale checks, skipped hosts and changed attempts | API snapshots and GitHub group/event semantics are assumptions; races after the last read remain possible. Publication rechecks the frozen protected head and full evidence but does not transactionally lock GitHub. Hosted dispatch/cancellation checks qualify wiring; no scheduler liveness or formal refinement claim. |
@@ -79,6 +80,7 @@ liveness/reachability controls have their own documented assumptions.
| Model | Positive distinct states | Configurations |
| --- | ---: | ---: |
+| [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla) | 129 | 2 |
| [native-status-start](../../tests/models/tla/native-status-start/StatusStart.tla) | 110 | 2 |
| [native-publisher-stop](../../tests/models/tla/native-publisher-stop/PublisherStop.tla) | 168 | 7 |
| [shutdown-rebase](../../tests/models/tla/shutdown-rebase/ShutdownRebase.tla) | 43 | 3 |
diff --git a/packages/lifecycle-supervisor/Cargo.lock b/packages/lifecycle-supervisor/Cargo.lock
new file mode 100644
index 000000000..665862df1
--- /dev/null
+++ b/packages/lifecycle-supervisor/Cargo.lock
@@ -0,0 +1,114 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "hack-lifecycle-supervisor"
+version = "0.1.0"
+dependencies = [
+ "libc",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
+
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/packages/lifecycle-supervisor/Cargo.toml b/packages/lifecycle-supervisor/Cargo.toml
new file mode 100644
index 000000000..1fdb83721
--- /dev/null
+++ b/packages/lifecycle-supervisor/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "hack-lifecycle-supervisor"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.97.1"
+publish = false
+
+[dependencies]
+libc = "0.2"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+
+[profile.release]
+lto = "thin"
+strip = true
diff --git a/packages/lifecycle-supervisor/README.md b/packages/lifecycle-supervisor/README.md
new file mode 100644
index 000000000..3fc78f6d8
--- /dev/null
+++ b/packages/lifecycle-supervisor/README.md
@@ -0,0 +1,37 @@
+# Standalone lifecycle supervisor
+
+This private crate builds `hack-lifecycle-supervisor`, an internal host-process
+owner for Linux and macOS. It has no dependency on runtime-core, a terminal
+multiplexer, Python, or a shell wrapper. Authored shell commands can still
+explicitly select a shell in their argv.
+
+The first slice is exercised by a fake caller. It does not select a new CLI runner
+or install the executable. See [the protocol, state model and delivery gates](../../docs/lifecycle-supervisor.md).
+
+Build and verify from the repository root with the pinned toolchain:
+
+```sh
+cargo +1.97.1 fmt --manifest-path packages/lifecycle-supervisor/Cargo.toml --check
+cargo +1.97.1 clippy --locked --manifest-path packages/lifecycle-supervisor/Cargo.toml --target-dir .hack-local/target/lifecycle-supervisor --all-targets -- -D warnings
+cargo +1.97.1 test --locked --manifest-path packages/lifecycle-supervisor/Cargo.toml --target-dir .hack-local/target/lifecycle-supervisor
+cargo +1.97.1 build --locked --release --manifest-path packages/lifecycle-supervisor/Cargo.toml --target-dir .hack-local/target/lifecycle-supervisor
+```
+
+Tests create private temporary roots and explicit fake command environments. They
+need no network, Docker, user registry or running Hack daemon. Each test owns its
+processes and cleans them up. CI runs the same crate on Linux x86_64, macOS arm64,
+and macOS x86_64. A cross-target `cargo check` checks compilation only; it does not
+qualify platform signal, process identity, output or recovery behavior.
+
+`run` must be spawned as a fresh child without the caller making it a process-group
+leader. The supervisor calls `setsid` itself and detaches its standard descriptors
+after the framed ready receipt. `stop` and `status` are finite framed requests.
+Frames, not pipe closure, delimit delivery. The per-command guard publishes kernel
+identity before executing authored argv and retains ownership after the project
+supervisor dies. A killed guard with surviving session members is an unknown
+ownership state: retry and unsafe stop are refused. A command that creates another
+session with `setsid` leaves this ownership boundary and is unsupported.
+
+The caller supplies a fresh ownership token and an existing private instance root.
+No command/environment is persisted. Log output is unredacted and mode 0600.
+There is no PTY: stdin is null and stdout/stderr are pipes.
diff --git a/packages/lifecycle-supervisor/src/main.rs b/packages/lifecycle-supervisor/src/main.rs
new file mode 100644
index 000000000..41a8850f9
--- /dev/null
+++ b/packages/lifecycle-supervisor/src/main.rs
@@ -0,0 +1,42 @@
+//! Standalone lifecycle owner; no terminal, shell wrapper, or environment file.
+//!
+//! Transition table (specified before implementation):
+//! | From | Guard / action | To | Durable consequence |
+//! | absent | command lock acquired, record guard identity | starting | identity before execution |
+//! | starting | authored spawn succeeds | running | command PID, no environment |
+//! | starting | authored spawn fails | exited | no authored process |
+//! | running | verified owner requests TERM | stopping | retained ownership |
+//! | stopping | no live authored session members | exited | retained terminal receipt |
+//! | stopping | five-second grace expires | kill-pending | KILL authored session groups |
+//! | kill-pending | no live authored session members | killed | confirmed terminal receipt |
+//! | kill-pending | live members remain | kill-pending | retain guard and lock; stop unknown |
+//! | starting/running | project owner dies | same | resident guard retains lock and identity |
+//! | any live | duplicate command / lock busy | refused | no second execution |
+//! | any | token or identity mismatch | refused | no signal |
+//! | any | guard dead and authored session live | orphaned | duplicate and stop refused |
+//!
+//! A resident guard is the session leader in its own process group. Authored
+//! commands receive a different group in that session. It publishes its kernel
+//! identity before starting authored code, and remains present while any live
+//! session member remains. A project-owner crash therefore leaves a verifiable
+//! leader. Killing that guard separately is an unknown-ownership outcome and
+//! fails closed when a live group remains.
+
+mod os;
+mod protocol;
+mod storage;
+mod supervisor;
+
+fn main() {
+ if supervisor::main().is_err() {
+ // Inputs can contain credentials. Never include parser or OS error text.
+ eprintln!("Lifecycle supervisor refused or failed; values omitted.");
+ std::process::exit(1);
+ }
+}
+
+type Result = std::result::Result;
+
+fn refused() -> std::io::Error {
+ std::io::Error::other("Lifecycle operation refused; values omitted")
+}
diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs
new file mode 100644
index 000000000..98bc95f9d
--- /dev/null
+++ b/packages/lifecycle-supervisor/src/os.rs
@@ -0,0 +1,559 @@
+use crate::{Result, refused};
+#[cfg(target_os = "linux")]
+use std::fs;
+use std::{
+ io,
+ os::fd::AsRawFd,
+ sync::atomic::{AtomicBool, Ordering},
+};
+
+pub static STOP: AtomicBool = AtomicBool::new(false);
+
+extern "C" fn stop_signal(_: i32) {
+ STOP.store(true, Ordering::Relaxed);
+}
+
+pub fn signals() -> Result<()> {
+ // SAFETY: sigaction is initialized, handler has C ABI and only stores an
+ // atomic flag; the kernel copies the action and retains no Rust pointer.
+ unsafe {
+ let mut action: libc::sigaction = std::mem::zeroed();
+ action.sa_sigaction = libc::SIG_DFL;
+ libc::sigemptyset(&mut action.sa_mask);
+ if libc::sigaction(libc::SIGCHLD, &action, std::ptr::null_mut()) != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ // A caller's blocked signal mask survives exec. Supervision must still
+ // receive stop requests and must be able to reap its direct children.
+ if libc::sigprocmask(libc::SIG_SETMASK, &action.sa_mask, std::ptr::null_mut()) != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ action.sa_sigaction = stop_signal as *const () as usize;
+ libc::sigemptyset(&mut action.sa_mask);
+ for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] {
+ if libc::sigaction(signal, &action, std::ptr::null_mut()) != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ }
+ }
+ Ok(())
+}
+
+pub fn session() -> Result<()> {
+ // SAFETY: setsid has no pointer preconditions; this process is the caller's
+ // freshly spawned child, never an interactive terminal owner.
+ if unsafe { libc::setsid() } < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+}
+
+#[derive(Clone, Debug)]
+pub struct Identity {
+ pub pid: i32,
+ pub group: i32,
+ pub session: i32,
+ pub start: u64,
+ pub uid: u32,
+ pub zombie: bool,
+}
+
+#[cfg(target_os = "linux")]
+pub fn inspect(pid: i32) -> Result> {
+ use std::os::unix::fs::MetadataExt;
+ let path = format!("/proc/{pid}/stat");
+ let text = match fs::read_to_string(&path) {
+ Ok(value) => value,
+ Err(error) if absent_error(&error) => return Ok(None),
+ Err(error) => return Err(error),
+ };
+ let tail = text.rsplit_once(") ").ok_or_else(refused)?.1;
+ let fields: Vec<_> = tail.split_whitespace().collect();
+ let group = fields
+ .get(2)
+ .and_then(|v| v.parse().ok())
+ .ok_or_else(refused)?;
+ let start = fields
+ .get(19)
+ .and_then(|v| v.parse().ok())
+ .ok_or_else(refused)?;
+ let session = fields
+ .get(3)
+ .and_then(|v| v.parse().ok())
+ .ok_or_else(refused)?;
+ let metadata = match fs::metadata(&path) {
+ Ok(value) => value,
+ Err(error) if absent_error(&error) => return Ok(None),
+ Err(error) => return Err(error),
+ };
+ Ok(Some(Identity {
+ pid,
+ group,
+ session,
+ start,
+ uid: metadata.uid(),
+ zombie: fields.first() == Some(&"Z") || fields.first() == Some(&"X"),
+ }))
+}
+
+#[cfg(target_os = "linux")]
+pub fn census() -> Result> {
+ let mut result = Vec::new();
+ for entry in fs::read_dir("/proc")? {
+ let entry = entry?;
+ if let Some(pid) = entry.file_name().to_str().and_then(|v| v.parse().ok())
+ && let Some(identity) = inspect(pid)?
+ {
+ result.push(identity);
+ }
+ }
+ Ok(result)
+}
+
+#[cfg(target_os = "linux")]
+pub fn boot() -> Result {
+ Ok(fs::read_to_string("/proc/sys/kernel/random/boot_id")?
+ .trim()
+ .into())
+}
+
+#[cfg(target_os = "macos")]
+pub fn inspect(pid: i32) -> Result> {
+ let mut info = std::mem::MaybeUninit::::zeroed();
+ // SAFETY: buffer is writable for its exact size, and no pointer is retained.
+ let size = unsafe {
+ libc::proc_pidinfo(
+ pid,
+ libc::PROC_PIDTBSDINFO,
+ 0,
+ info.as_mut_ptr().cast(),
+ std::mem::size_of::() as i32,
+ )
+ };
+ if size as usize != std::mem::size_of::() {
+ let error = io::Error::last_os_error();
+ if size == 0 && error.raw_os_error() == Some(libc::ESRCH) {
+ return Ok(None);
+ }
+ if size == 0 && error.raw_os_error() == Some(libc::EPERM) {
+ return Err(error);
+ }
+ return Err(refused());
+ }
+ // SAFETY: the kernel initialized exactly the expected struct size above.
+ let info = unsafe { info.assume_init() };
+ // SAFETY: getsid only observes the named PID. ESRCH is an exit race.
+ let session = unsafe { libc::getsid(pid) };
+ if session < 0 {
+ let error = io::Error::last_os_error();
+ if error.raw_os_error() == Some(libc::ESRCH) {
+ return Ok(None);
+ }
+ return Err(error);
+ }
+ Ok(Some(Identity {
+ pid,
+ group: info.pbi_pgid as i32,
+ session,
+ start: info
+ .pbi_start_tvsec
+ .checked_mul(1_000_000)
+ .and_then(|v| v.checked_add(info.pbi_start_tvusec))
+ .ok_or_else(refused)?,
+ uid: info.pbi_uid,
+ zombie: info.pbi_status == libc::SZOMB as u32,
+ }))
+}
+
+#[cfg(target_os = "macos")]
+pub fn census() -> Result> {
+ const PROC_ALL_PIDS: u32 = 1;
+ // SAFETY: querying required bytes uses a null pointer and zero buffer size.
+ let size = unsafe { libc::proc_listpids(PROC_ALL_PIDS, 0, std::ptr::null_mut(), 0) };
+ if size <= 0 {
+ return Err(refused());
+ }
+ let mut pids = vec![0i32; size as usize / 4 + 1024];
+ // SAFETY: vector is initialized and live for the byte size supplied.
+ let written = unsafe {
+ libc::proc_listpids(
+ PROC_ALL_PIDS,
+ 0,
+ pids.as_mut_ptr().cast(),
+ (pids.len() * 4) as i32,
+ )
+ };
+ if written <= 0 || written as usize >= pids.len() * 4 {
+ return Err(refused());
+ }
+ let mut result = Vec::new();
+ for pid in pids
+ .into_iter()
+ .take(written as usize / 4)
+ .filter(|pid| *pid > 0)
+ {
+ match inspect(pid) {
+ Ok(Some(identity)) => result.push(identity),
+ Ok(None) => (),
+ // Unrelated protected processes cannot belong to this user's
+ // authored session. Exact guard reads remain strict in inspect.
+ Err(error) if error.raw_os_error() == Some(libc::EPERM) => (),
+ Err(error) => return Err(error),
+ }
+ }
+ Ok(result)
+}
+
+#[cfg(target_os = "macos")]
+pub fn boot() -> Result {
+ let mut bytes = [0u8; 64];
+ let mut length = bytes.len();
+ // SAFETY: read-only sysctl with writable bounded buffer, no retained pointer.
+ if unsafe {
+ libc::sysctlbyname(
+ c"kern.bootsessionuuid".as_ptr(),
+ bytes.as_mut_ptr().cast(),
+ &mut length,
+ std::ptr::null_mut(),
+ 0,
+ )
+ } != 0
+ {
+ return Err(refused());
+ }
+ if length == 0 || length > bytes.len() {
+ return Err(refused());
+ }
+ Ok(std::str::from_utf8(&bytes[..length])
+ .map_err(|_| refused())?
+ .trim_end_matches('\0')
+ .to_ascii_lowercase())
+}
+
+pub fn same(state: &crate::storage::State, identity: &Identity) -> Result<()> {
+ if state.guard_pid != identity.pid
+ || state.process_group_id != identity.group
+ || state.guard_pid != identity.session
+ || state.guard_start_time != identity.start
+ || state.uid != identity.uid
+ || state.boot_id != boot()?
+ || identity.zombie
+ {
+ return Err(refused());
+ }
+ Ok(())
+}
+
+pub fn live_group(group: i32, excluding: Option) -> Result {
+ Ok(census()?
+ .iter()
+ .any(|p| p.session == group && !p.zombie && Some(p.pid) != excluding))
+}
+
+pub fn absent_error(error: &io::Error) -> bool {
+ error.kind() == io::ErrorKind::NotFound
+ || matches!(error.raw_os_error(), Some(libc::ESRCH) | Some(libc::ENOENT))
+}
+
+/// A boot change or a reused leader PID proves the old session is gone. A
+/// missing leader does not: descendants retain its SID and require a census.
+pub fn recorded_live(state: &crate::storage::State, excluding_guard: bool) -> Result {
+ if state.boot_id != boot()? {
+ return Ok(false);
+ }
+ if let Some(identity) = inspect(state.guard_pid)?
+ && (identity.start != state.guard_start_time
+ || identity.uid != state.uid
+ || identity.session != state.guard_pid)
+ {
+ return Ok(false);
+ }
+ live_group(state.guard_pid, excluding_guard.then_some(state.guard_pid))
+}
+
+/// The guard is never a member of an authored process group. It retains the
+/// command lock while KILL is confirmed, including uninterruptible processes.
+pub fn session_signal(session: i32, signal: i32) -> Result<()> {
+ let members: Vec<_> = census()?
+ .into_iter()
+ .filter(|p| p.session == session && p.pid != session && !p.zombie)
+ .collect();
+ let groups: std::collections::BTreeSet<_> = members
+ .iter()
+ .filter(|p| p.group != session)
+ .map(|p| p.group)
+ .collect();
+ for member in members.iter().filter(|p| p.group == session) {
+ signal_member(member, signal)?;
+ }
+ for group in groups {
+ group_signal(group, signal)?;
+ }
+ Ok(())
+}
+
+fn signal_member(expected: &Identity, signal: i32) -> Result<()> {
+ #[cfg(target_os = "linux")]
+ {
+ use std::os::fd::{FromRawFd, OwnedFd};
+ // SAFETY: pidfd_open returns a newly owned CLOEXEC descriptor.
+ let raw = unsafe { libc::syscall(libc::SYS_pidfd_open, expected.pid, 0u32) };
+ if raw < 0 {
+ let error = io::Error::last_os_error();
+ if absent_error(&error) {
+ return Ok(());
+ }
+ return Err(error);
+ }
+ let fd = unsafe { OwnedFd::from_raw_fd(raw as i32) };
+ let Some(current) = inspect(expected.pid)? else {
+ return Ok(());
+ };
+ if current.start != expected.start
+ || current.session != expected.session
+ || current.uid != expected.uid
+ {
+ return Ok(());
+ }
+ // SAFETY: the pidfd pins the verified process; null siginfo is ordinary delivery.
+ if unsafe {
+ libc::syscall(
+ libc::SYS_pidfd_send_signal,
+ fd.as_raw_fd(),
+ signal,
+ std::ptr::null::(),
+ 0u32,
+ )
+ } < 0
+ {
+ let error = io::Error::last_os_error();
+ if !absent_error(&error) {
+ return Err(error);
+ }
+ }
+ Ok(())
+ }
+ #[cfg(target_os = "macos")]
+ {
+ let Some(current) = inspect(expected.pid)? else {
+ return Ok(());
+ };
+ if current.start != expected.start
+ || current.session != expected.session
+ || current.uid != expected.uid
+ {
+ return Ok(());
+ }
+ // SAFETY: exact incarnation and session were rechecked immediately above.
+ if unsafe { libc::kill(expected.pid, signal) } != 0 {
+ let error = io::Error::last_os_error();
+ if !absent_error(&error) {
+ return Err(error);
+ }
+ }
+ Ok(())
+ }
+}
+
+/// Authored commands start a separate group and restore inherited ignored
+/// dispositions, which exec itself is not required to reset.
+pub fn command_setup() -> Result<()> {
+ // SAFETY: async-signal-safe calls in the post-fork, single-threaded child.
+ unsafe {
+ if libc::setpgid(0, 0) != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ let mut action: libc::sigaction = std::mem::zeroed();
+ action.sa_sigaction = libc::SIG_DFL;
+ libc::sigemptyset(&mut action.sa_mask);
+ #[cfg(target_os = "linux")]
+ let highest = libc::SIGRTMAX();
+ #[cfg(target_os = "macos")]
+ let highest = 31;
+ for signal in 1..=highest {
+ if signal != libc::SIGKILL
+ && signal != libc::SIGSTOP
+ && libc::sigaction(signal, &action, std::ptr::null_mut()) != 0
+ {
+ #[cfg(target_os = "linux")]
+ if (signal == 32 || signal == 33)
+ && io::Error::last_os_error().raw_os_error() == Some(libc::EINVAL)
+ {
+ continue;
+ }
+ return Err(io::Error::last_os_error());
+ }
+ }
+ let mut mask = std::mem::zeroed();
+ libc::sigemptyset(&mut mask);
+ if libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()) != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ }
+ Ok(())
+}
+
+pub fn subreaper() -> Result<()> {
+ #[cfg(target_os = "linux")]
+ // SAFETY: process-local prctl enabling orphan adoption, integer options only.
+ if unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+}
+
+pub fn reap() -> Result> {
+ use std::os::unix::process::ExitStatusExt;
+ let mut reaped = Vec::new();
+ loop {
+ let mut status = 0;
+ // SAFETY: waitpid writes one live integer; WNOHANG never blocks.
+ let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) };
+ if pid == 0 {
+ return Ok(reaped);
+ }
+ if pid < 0 {
+ let error = io::Error::last_os_error();
+ if error.raw_os_error() == Some(libc::ECHILD) {
+ return Ok(reaped);
+ }
+ if error.kind() == io::ErrorKind::Interrupted {
+ continue;
+ }
+ return Err(error);
+ }
+ reaped.push((pid, std::process::ExitStatus::from_raw(status)));
+ }
+}
+
+pub fn close_inherited() -> Result<()> {
+ #[cfg(target_os = "linux")]
+ {
+ // SAFETY: called before opening application descriptors. Only inherited
+ // descriptors >=3 are closed; the three protocol/std descriptors remain.
+ if unsafe { libc::syscall(libc::SYS_close_range, 3u32, u32::MAX, 0u32) } == 0 {
+ return Ok(());
+ }
+ }
+ // Portable fallback is bounded by the process's descriptor limit and does
+ // not allocate or retain descriptor references while closing them.
+ let mut limit: libc::rlimit = unsafe { std::mem::zeroed() };
+ // SAFETY: getrlimit initializes one live rlimit value.
+ if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ if limit.rlim_cur > 1_048_576 {
+ return Err(refused());
+ }
+ for fd in 3..limit.rlim_cur as i32 {
+ // SAFETY: startup owns no fd >=3; failed closes are harmless gaps.
+ unsafe {
+ libc::close(fd);
+ }
+ }
+ Ok(())
+}
+
+pub fn signal_guard(state: &crate::storage::State) -> Result<()> {
+ #[cfg(target_os = "linux")]
+ {
+ use std::os::fd::{FromRawFd, OwnedFd};
+ // SAFETY: pidfd_open pins this PID incarnation and returns a fresh CLOEXEC fd.
+ let raw = unsafe { libc::syscall(libc::SYS_pidfd_open, state.guard_pid, 0u32) };
+ if raw < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ let fd = unsafe { OwnedFd::from_raw_fd(raw as i32) };
+ same(state, &inspect(state.guard_pid)?.ok_or_else(refused)?)?;
+ // SAFETY: pidfd is live; null siginfo requests ordinary signal delivery.
+ if unsafe {
+ libc::syscall(
+ libc::SYS_pidfd_send_signal,
+ fd.as_raw_fd(),
+ libc::SIGTERM,
+ std::ptr::null::(),
+ 0u32,
+ )
+ } < 0
+ {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+ #[cfg(target_os = "macos")]
+ {
+ same(state, &inspect(state.guard_pid)?.ok_or_else(refused)?)?;
+ // SAFETY: guarded by the exact kernel identity check above. Darwin has no pidfd.
+ if unsafe { libc::kill(state.guard_pid, libc::SIGTERM) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+ }
+}
+
+pub fn group_signal(group: i32, signal: i32) -> Result<()> {
+ if group <= 1 {
+ return Err(refused());
+ }
+ // SAFETY: validated positive group number is negated for group delivery.
+ if unsafe { libc::kill(-group, signal) } != 0 {
+ let error = io::Error::last_os_error();
+ if error.raw_os_error() != Some(libc::ESRCH) {
+ return Err(error);
+ }
+ }
+ Ok(())
+}
+
+pub fn nonblocking(fd: &impl AsRawFd) -> Result<()> {
+ // SAFETY: fcntl borrows a live descriptor and supplies integer flags only.
+ let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFL) };
+ if flags < 0
+ || unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0
+ {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(())
+}
+
+pub fn timestamp() -> Result {
+ // SAFETY: time returns current seconds; gmtime_r writes one live tm struct,
+ // strftime writes at most the supplied buffer length and retains no pointer.
+ unsafe {
+ let now = libc::time(std::ptr::null_mut());
+ let mut tm: libc::tm = std::mem::zeroed();
+ if libc::gmtime_r(&now, &mut tm).is_null() {
+ return Err(refused());
+ }
+ let mut bytes = [0u8; 32];
+ let count = libc::strftime(
+ bytes.as_mut_ptr().cast(),
+ bytes.len(),
+ c"%Y-%m-%dT%H:%M:%SZ".as_ptr(),
+ &tm,
+ );
+ if count == 0 {
+ return Err(refused());
+ }
+ Ok(std::str::from_utf8(&bytes[..count])
+ .map_err(|_| refused())?
+ .into())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn proc_exit_during_open_or_read_is_absent_not_census_failure() {
+ for errno in [libc::ENOENT, libc::ESRCH] {
+ assert!(super::absent_error(&std::io::Error::from_raw_os_error(
+ errno
+ )));
+ }
+ for errno in [libc::EPERM, libc::EIO] {
+ assert!(!super::absent_error(&std::io::Error::from_raw_os_error(
+ errno
+ )));
+ }
+ }
+}
diff --git a/packages/lifecycle-supervisor/src/protocol.rs b/packages/lifecycle-supervisor/src/protocol.rs
new file mode 100644
index 000000000..0a795a3e2
--- /dev/null
+++ b/packages/lifecycle-supervisor/src/protocol.rs
@@ -0,0 +1,235 @@
+use crate::{Result, refused};
+use serde::{Deserialize, Serialize, de::DeserializeOwned};
+use std::{
+ collections::BTreeMap,
+ io::{Read, Write},
+ os::fd::AsRawFd,
+ path::PathBuf,
+ time::{Duration, Instant},
+};
+
+pub const LIMIT: usize = 1024 * 1024;
+pub const DEADLINE: Duration = Duration::from_secs(10);
+
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Launch {
+ pub version: u32,
+ pub root: PathBuf,
+ pub owner_token: String,
+ pub processes: Vec,
+}
+
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct Process {
+ pub name: String,
+ pub argv: Vec,
+ pub cwd: PathBuf,
+ pub env: BTreeMap,
+}
+
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Control {
+ pub version: u32,
+ pub root: PathBuf,
+ pub owner_token: String,
+}
+
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct GuardLaunch {
+ pub version: u32,
+ pub root: PathBuf,
+ pub owner_token: String,
+ pub process: Process,
+}
+
+pub fn valid_name(name: &str) -> bool {
+ !name.is_empty()
+ && name.as_bytes()[0].is_ascii_alphanumeric()
+ && name.len() <= 96
+ && name
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
+ && name != "."
+ && name != ".."
+}
+
+pub fn validate_control(version: u32, root: &std::path::Path, token: &str) -> Result<()> {
+ if version != 1
+ || !root.is_absolute()
+ || token.is_empty()
+ || token.len() > 256
+ || token.contains('\0')
+ {
+ return Err(refused());
+ }
+ Ok(())
+}
+
+impl Process {
+ pub fn validate(&self) -> Result<()> {
+ if !valid_name(&self.name)
+ || !self.cwd.is_absolute()
+ || self.argv.is_empty()
+ || self.argv[0].is_empty()
+ || self.argv.iter().any(|v| v.contains('\0'))
+ || self.env.iter().any(|(key, value)| {
+ let mut bytes = key.bytes();
+ !bytes
+ .next()
+ .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
+ || !bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
+ || value.contains('\0')
+ })
+ {
+ return Err(refused());
+ }
+ Ok(())
+ }
+}
+
+/// Length and deadline, never descriptor closure, delimit a handoff. Polling
+/// handles Darwin's brief inheritable-descriptor window without waiting on EOF.
+pub fn read(input: &mut (impl Read + AsRawFd)) -> Result {
+ let deadline = Instant::now() + DEADLINE;
+ let mut header = [0; 4];
+ exact(input, &mut header, deadline)?;
+ let length = u32::from_be_bytes(header) as usize;
+ if length == 0 || length > LIMIT {
+ return Err(refused());
+ }
+ let mut bytes = vec![0; length];
+ exact(input, &mut bytes, deadline)?;
+ let parsed = serde_json::from_slice(&bytes).map_err(|_| refused());
+ bytes.fill(0);
+ parsed
+}
+
+fn exact(input: &mut (impl Read + AsRawFd), bytes: &mut [u8], deadline: Instant) -> Result<()> {
+ let mut offset = 0;
+ while offset < bytes.len() {
+ let remaining = deadline
+ .checked_duration_since(Instant::now())
+ .ok_or_else(refused)?;
+ let mut fd = libc::pollfd {
+ fd: input.as_raw_fd(),
+ events: libc::POLLIN,
+ revents: 0,
+ };
+ // SAFETY: poll borrows one initialized descriptor record for this call.
+ let result = unsafe {
+ libc::poll(
+ &mut fd,
+ 1,
+ remaining.as_millis().min(i32::MAX as u128) as i32,
+ )
+ };
+ if result < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
+ continue;
+ }
+ if result <= 0 {
+ return Err(refused());
+ }
+ // Read exactly the polled kernel descriptor. Stdin's high-level Read
+ // buffers ahead: polling after a four-byte header would then wait for
+ // data already held inside its user-space buffer.
+ // SAFETY: the destination slice is writable for its supplied length;
+ // read borrows a live descriptor and retains no pointer.
+ let count = unsafe {
+ libc::read(
+ input.as_raw_fd(),
+ bytes[offset..].as_mut_ptr().cast(),
+ bytes.len() - offset,
+ )
+ };
+ let result = if count < 0 {
+ Err(std::io::Error::last_os_error())
+ } else {
+ Ok(count as usize)
+ };
+ match result {
+ Ok(0) => return Err(refused()),
+ Ok(count) => offset += count,
+ Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
+ Err(error) => return Err(error),
+ }
+ }
+ Ok(())
+}
+
+pub fn write(output: &mut (impl Write + AsRawFd), value: &impl Serialize) -> Result<()> {
+ let mut bytes = serde_json::to_vec(value).map_err(|_| refused())?;
+ if bytes.is_empty() || bytes.len() > LIMIT {
+ return Err(refused());
+ }
+ let fd = output.as_raw_fd();
+ // SAFETY: fcntl borrows this live, exclusively used protocol descriptor.
+ let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
+ if flags < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ // SAFETY: only integer descriptor flags are changed for bounded writes.
+ if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let deadline = Instant::now() + DEADLINE;
+ let result = send(fd, &(bytes.len() as u32).to_be_bytes(), deadline)
+ .and_then(|()| send(fd, &bytes, deadline));
+ // SAFETY: restore the original flags before returning the borrowed fd.
+ let restore = unsafe { libc::fcntl(fd, libc::F_SETFL, flags) };
+ bytes.fill(0);
+ result?;
+ if restore < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+}
+
+fn send(fd: i32, bytes: &[u8], deadline: Instant) -> Result<()> {
+ let mut offset = 0;
+ while offset < bytes.len() {
+ let remaining = deadline
+ .checked_duration_since(Instant::now())
+ .ok_or_else(refused)?;
+ let mut record = libc::pollfd {
+ fd,
+ events: libc::POLLOUT,
+ revents: 0,
+ };
+ // SAFETY: poll borrows one initialized descriptor record.
+ let ready = unsafe {
+ libc::poll(
+ &mut record,
+ 1,
+ remaining.as_millis().min(i32::MAX as u128) as i32,
+ )
+ };
+ if ready < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
+ continue;
+ }
+ if ready <= 0 {
+ return Err(refused());
+ }
+ // SAFETY: bytes remain live for the supplied length; fd is nonblocking.
+ let count =
+ unsafe { libc::write(fd, bytes[offset..].as_ptr().cast(), bytes.len() - offset) };
+ if count < 0 {
+ let error = std::io::Error::last_os_error();
+ if matches!(
+ error.kind(),
+ std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock
+ ) {
+ continue;
+ }
+ return Err(error);
+ }
+ if count == 0 {
+ return Err(refused());
+ }
+ offset += count as usize;
+ }
+ Ok(())
+}
diff --git a/packages/lifecycle-supervisor/src/storage.rs b/packages/lifecycle-supervisor/src/storage.rs
new file mode 100644
index 000000000..593560a0c
--- /dev/null
+++ b/packages/lifecycle-supervisor/src/storage.rs
@@ -0,0 +1,218 @@
+use crate::{Result, refused};
+use serde::{Deserialize, Serialize};
+use std::{
+ fs::{File, OpenOptions},
+ io::{Read, Write},
+ os::{
+ fd::{AsRawFd, FromRawFd, OwnedFd},
+ unix::fs::{MetadataExt, OpenOptionsExt},
+ },
+ path::{Path, PathBuf},
+};
+
+#[derive(Deserialize, Serialize, Clone, Debug)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct State {
+ pub version: u32,
+ pub name: String,
+ pub owner_token: String,
+ pub guard_pid: i32,
+ pub process_group_id: i32,
+ pub guard_start_time: u64,
+ pub boot_id: String,
+ pub uid: u32,
+ pub command_pid: Option,
+ pub phase: String,
+ #[serde(default)]
+ pub exit_code: Option,
+ #[serde(default)]
+ pub signal: Option,
+}
+
+/// Operations use an opened O_NOFOLLOW directory and relative openat/renameat;
+/// changing a pathname after admission cannot redirect state/log writes.
+pub struct Root {
+ directory: File,
+ pub path: PathBuf,
+}
+
+impl Root {
+ pub fn open(path: &Path) -> Result {
+ let directory = OpenOptions::new()
+ .read(true)
+ .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
+ .open(path)?;
+ let metadata = directory.metadata()?;
+ // SAFETY: geteuid has no preconditions.
+ if !metadata.is_dir()
+ || metadata.uid() != unsafe { libc::geteuid() }
+ || metadata.mode() & 0o777 != 0o700
+ {
+ return Err(refused());
+ }
+ Ok(Self {
+ directory,
+ path: path.into(),
+ })
+ }
+
+ pub fn file(&self, name: &str, flags: i32) -> Result {
+ let name = std::ffi::CString::new(name).map_err(|_| refused())?;
+ // SAFETY: directory descriptor is live; name is NUL-terminated; returned
+ // descriptor is newly owned and immediately wrapped in RAII.
+ let fd = unsafe {
+ libc::openat(
+ self.directory.as_raw_fd(),
+ name.as_ptr(),
+ flags | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
+ 0o600,
+ )
+ };
+ if fd < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ // SAFETY: fd was just returned by openat and has no other owner.
+ let file = File::from(unsafe { OwnedFd::from_raw_fd(fd) });
+ let metadata = file.metadata()?;
+ // SAFETY: geteuid has no preconditions.
+ if !metadata.is_file()
+ || metadata.uid() != unsafe { libc::geteuid() }
+ || metadata.nlink() != 1
+ || metadata.mode() & 0o777 != 0o600
+ {
+ return Err(refused());
+ }
+ Ok(file)
+ }
+
+ pub fn lock(&self, name: &str) -> Result {
+ let file = self.file(name, libc::O_RDWR | libc::O_CREAT)?;
+ // SAFETY: flock operates only on this live descriptor.
+ if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
+ return Err(refused());
+ }
+ Ok(file)
+ }
+
+ pub fn lock_wait(&self, name: &str) -> Result {
+ let file = self.file(name, libc::O_RDWR | libc::O_CREAT)?;
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
+ loop {
+ // SAFETY: flock only borrows the live owned descriptor.
+ if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
+ return Ok(file);
+ }
+ let error = std::io::Error::last_os_error();
+ if !matches!(
+ error.kind(),
+ std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
+ ) {
+ return Err(error);
+ }
+ if std::time::Instant::now() >= deadline {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "Lifecycle lock wait expired",
+ ));
+ }
+ std::thread::sleep(std::time::Duration::from_millis(20));
+ }
+ }
+
+ pub fn save(&self, state: &State) -> Result<()> {
+ let final_name = format!("{}.json", state.name);
+ // Refuse unsafe pre-existing targets before atomically replacing an owned
+ // regular receipt. Never truncate through a link.
+ match self.file(&final_name, libc::O_RDONLY) {
+ Ok(_) => (),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
+ Err(error) => return Err(error),
+ }
+ static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+ let mut attempts = 0;
+ let (temporary_name, mut temporary) = loop {
+ attempts += 1;
+ if attempts > 16 {
+ return Err(refused());
+ }
+ let clock = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_err(|_| refused())?
+ .as_nanos();
+ let serial = SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let name = format!(
+ ".{}.{}.{clock}.{serial}.tmp",
+ state.name,
+ std::process::id()
+ );
+ match self.file(&name, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL) {
+ Ok(file) => break (name, file),
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
+ Err(error) => return Err(error),
+ }
+ };
+ let bytes = serde_json::to_vec(state).map_err(|_| refused())?;
+ temporary.write_all(&bytes)?;
+ temporary.sync_all()?;
+ let temporary_name = std::ffi::CString::new(temporary_name).map_err(|_| refused())?;
+ let final_name = std::ffi::CString::new(final_name).map_err(|_| refused())?;
+ // SAFETY: both names are NUL terminated and relative to this live root.
+ if unsafe {
+ libc::renameat(
+ self.directory.as_raw_fd(),
+ temporary_name.as_ptr(),
+ self.directory.as_raw_fd(),
+ final_name.as_ptr(),
+ )
+ } != 0
+ {
+ return Err(std::io::Error::last_os_error());
+ }
+ self.directory.sync_all()
+ }
+
+ pub fn load(&self, name: &str) -> Result> {
+ let mut file = match self.file(&format!("{name}.json"), libc::O_RDONLY) {
+ Ok(file) => file,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+ Err(error) => return Err(error),
+ };
+ if file.metadata()?.len() > 8192 {
+ return Err(refused());
+ }
+ let mut bytes = Vec::new();
+ Read::by_ref(&mut file).take(8193).read_to_end(&mut bytes)?;
+ let state: State = serde_json::from_slice(&bytes).map_err(|_| refused())?;
+ if state.version != 1
+ || state.name != name
+ || state.guard_pid <= 1
+ || state.process_group_id != state.guard_pid
+ || state.guard_start_time == 0
+ {
+ return Err(refused());
+ }
+ Ok(Some(state))
+ }
+
+ pub fn states(&self) -> Result> {
+ let mut states = Vec::new();
+ // Enumeration is followed by descriptor-relative checked reads. Root path
+ // replacement can at most change the candidate names, never read location.
+ for entry in std::fs::read_dir(&self.path)? {
+ let entry = entry?;
+ if let Some(name) = entry
+ .file_name()
+ .to_str()
+ .and_then(|v| v.strip_suffix(".json"))
+ {
+ if !crate::protocol::valid_name(name) {
+ return Err(refused());
+ }
+ if let Some(state) = self.load(name)? {
+ states.push(state);
+ }
+ }
+ }
+ Ok(states)
+ }
+}
diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs
new file mode 100644
index 000000000..d52babf29
--- /dev/null
+++ b/packages/lifecycle-supervisor/src/supervisor.rs
@@ -0,0 +1,523 @@
+use crate::{
+ Result, os,
+ protocol::{self, Control, GuardLaunch, Launch},
+ refused,
+ storage::{Root, State},
+};
+use serde::Serialize;
+use std::{
+ fs::File,
+ io::{Read, Write},
+ os::{
+ fd::AsRawFd,
+ unix::process::{CommandExt, ExitStatusExt},
+ },
+ process::{Child, Command, Stdio},
+ sync::atomic::Ordering,
+ thread,
+ time::{Duration, Instant},
+};
+
+const GRACE: Duration = Duration::from_secs(5);
+const TICK: Duration = Duration::from_millis(20);
+const LINE_LIMIT: usize = 64 * 1024;
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Receipt {
+ version: u32,
+ supervisor_pid: u32,
+ outcome: &'static str,
+ processes: Vec,
+}
+
+pub fn main() -> Result<()> {
+ os::close_inherited()?;
+ let args: Vec<_> = std::env::args_os().skip(1).collect();
+ if args.len() != 1 {
+ return Err(refused());
+ }
+ match args[0].to_str() {
+ Some("run") => run(protocol::read(&mut std::io::stdin())?),
+ Some("guard") => guard(protocol::read(&mut std::io::stdin())?),
+ Some("stop") | Some("status") => {
+ let request: Control = protocol::read(&mut std::io::stdin())?;
+ protocol::validate_control(request.version, &request.root, &request.owner_token)?;
+ let root = Root::open(&request.root)?;
+ let _stop_lock = if args[0] == "stop" {
+ Some(root.lock_wait(".stop.lock")?)
+ } else {
+ None
+ };
+ let mut states = Vec::new();
+ for state in root.states()? {
+ if state.owner_token == request.owner_token {
+ states.push(state);
+ } else if os::recorded_live(&state, false)? {
+ // Old terminal receipts survive renames and new generations.
+ // Only a kernel census can prove they no longer hold work;
+ // a persisted terminal phase alone is never sufficient.
+ return Err(refused());
+ }
+ }
+ let result = if args[0] == "stop" {
+ stop(&states)
+ } else {
+ Ok(())
+ };
+ protocol::write(
+ &mut std::io::stdout(),
+ &Receipt {
+ version: 1,
+ supervisor_pid: 0,
+ outcome: if result.is_err() {
+ "unknown"
+ } else if args[0] == "stop" {
+ "stopped"
+ } else {
+ "observed"
+ },
+ processes: root
+ .states()?
+ .into_iter()
+ .filter(|state| state.owner_token == request.owner_token)
+ .collect(),
+ },
+ )?;
+ result
+ }
+ _ => Err(refused()),
+ }
+}
+
+fn run(request: Launch) -> Result<()> {
+ protocol::validate_control(request.version, &request.root, &request.owner_token)?;
+ if request.processes.is_empty() || request.processes.len() > 128 {
+ return Err(refused());
+ }
+ let mut names = std::collections::BTreeSet::new();
+ for process in &request.processes {
+ process.validate()?;
+ if !names.insert(process.name.to_ascii_lowercase()) {
+ return Err(refused());
+ }
+ }
+ let root = Root::open(&request.root)?;
+ let _instance_lock = root.lock(".instance.lock")?;
+ let launch_lock = root.lock_wait(".stop.lock")?;
+ // Validate every old claim before starting any new command. A guard survives
+ // project-owner death; its command lock also closes concurrent restart races.
+ for state in root.states()? {
+ if os::recorded_live(&state, false)? {
+ return Err(refused());
+ }
+ }
+ os::session()?;
+ os::signals()?;
+ let mut children = Vec::new();
+ let outcome = (|| {
+ let mut states = Vec::new();
+ for process in request.processes {
+ if os::STOP.load(Ordering::Relaxed) {
+ return Err(refused());
+ }
+ let mut command = Command::new(std::env::current_exe()?);
+ command
+ .arg("guard")
+ .env_clear()
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null());
+ // SAFETY: setsid is async-signal-safe, and this closure allocates no
+ // application state. It runs in the freshly forked child only.
+ unsafe {
+ command.pre_exec(os::session);
+ }
+ let child = command.spawn()?;
+ children.push(child);
+ let child = children.last_mut().ok_or_else(refused)?;
+ let plan = GuardLaunch {
+ version: 1,
+ root: request.root.clone(),
+ owner_token: request.owner_token.clone(),
+ process,
+ };
+ protocol::write(&mut child.stdin.take().ok_or_else(refused)?, &plan)?;
+ let state: State = protocol::read(&mut child.stdout.take().ok_or_else(refused)?)?;
+ if state.guard_pid != child.id() as i32 || state.owner_token != request.owner_token {
+ return Err(refused());
+ }
+ if state.phase == "failed-start" {
+ return Err(refused());
+ }
+ states.push(state);
+ }
+ protocol::write(
+ &mut std::io::stdout(),
+ &Receipt {
+ version: 1,
+ supervisor_pid: std::process::id(),
+ outcome: "ready",
+ processes: states,
+ },
+ )?;
+ drop(launch_lock);
+ detach_stdio()?;
+ loop {
+ if os::STOP.load(Ordering::Relaxed) {
+ match root.lock_wait(".stop.lock") {
+ Ok(_lock) => {
+ stop(&root.states()?)?;
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::TimedOut => (),
+ Err(error) => return Err(error),
+ }
+ }
+ let mut running = false;
+ for child in &mut children {
+ if child.try_wait()?.is_none() {
+ running = true;
+ }
+ }
+ if !running {
+ return Ok(());
+ }
+ thread::sleep(TICK);
+ }
+ })();
+ if outcome.is_err() {
+ // Rollback only this start's unreaped children. Never infer authority from
+ // another root entry while handling a launch failure.
+ rollback(&mut children);
+ }
+ outcome
+}
+
+fn rollback(children: &mut [Child]) {
+ for child in children.iter_mut() {
+ if child.try_wait().ok().flatten().is_some() {
+ continue;
+ }
+ let pid = child.id() as i32;
+ // An unreaped direct Child pins the PID; only the guard receives TERM.
+ // SAFETY: the child has not been reaped, so this PID cannot be reused.
+ unsafe {
+ libc::kill(pid, libc::SIGTERM);
+ }
+ }
+ let deadline = Instant::now() + GRACE + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ if children
+ .iter_mut()
+ .all(|child| child.try_wait().ok().flatten().is_some())
+ {
+ break;
+ }
+ thread::sleep(TICK);
+ }
+ // A guard still alive after KILL confirmation remains the owner of unknown
+ // or uninterruptible work. Do not kill it and discard its lock.
+}
+
+fn guard(request: GuardLaunch) -> Result<()> {
+ protocol::validate_control(request.version, &request.root, &request.owner_token)?;
+ request.process.validate()?;
+ let root = Root::open(&request.root)?;
+ let _command_lock = root.lock(&format!("{}.lock", request.process.name))?;
+ if let Some(previous) = root.load(&request.process.name)?
+ && os::recorded_live(&previous, false)?
+ {
+ return Err(refused());
+ }
+ let pid = std::process::id() as i32;
+ let identity = os::inspect(pid)?.ok_or_else(refused)?;
+ if identity.group != pid {
+ return Err(refused());
+ }
+ os::signals()?;
+ os::subreaper()?;
+ let mut state = State {
+ version: 1,
+ name: request.process.name.clone(),
+ owner_token: request.owner_token,
+ guard_pid: pid,
+ process_group_id: pid,
+ guard_start_time: identity.start,
+ boot_id: os::boot()?,
+ uid: identity.uid,
+ command_pid: None,
+ phase: "starting".into(),
+ exit_code: None,
+ signal: None,
+ };
+ let mut log = root.file(
+ "lifecycle.log",
+ libc::O_WRONLY | libc::O_APPEND | libc::O_CREAT,
+ )?;
+ root.save(&state)?;
+ let outcome = guard_command(&request.process, &root, &mut state, &mut log);
+ if outcome.is_err() && state.command_pid.is_some() {
+ // Keep ownership while confirming internal-error cleanup. If even the
+ // census fails, retain the guard and lock rather than silently orphan.
+ let _ = os::session_signal(pid, libc::SIGTERM);
+ thread::sleep(GRACE);
+ loop {
+ let _ = os::session_signal(pid, libc::SIGKILL);
+ let _ = os::reap();
+ if matches!(os::live_group(pid, Some(pid)), Ok(false)) {
+ break;
+ }
+ thread::sleep(TICK);
+ }
+ }
+ outcome
+}
+
+fn guard_command(
+ process: &protocol::Process,
+ root: &Root,
+ state: &mut State,
+ log: &mut File,
+) -> Result<()> {
+ let mut command = Command::new(&process.argv[0]);
+ command
+ .args(&process.argv[1..])
+ .current_dir(&process.cwd)
+ .env_clear()
+ .envs(&process.env)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ // SAFETY: command_setup uses only async-signal-safe operations after fork.
+ unsafe {
+ command.pre_exec(os::command_setup);
+ }
+ let mut child = match command.spawn() {
+ Ok(child) => child,
+ Err(_) => {
+ state.phase = "failed-start".into();
+ root.save(state)?;
+ protocol::write(&mut std::io::stdout(), state)?;
+ return Err(refused());
+ }
+ };
+ state.command_pid = Some(child.id() as i32);
+ state.phase = "running".into();
+ root.save(state)?;
+ let mut stdout = child.stdout.take().ok_or_else(refused)?;
+ let mut stderr = child.stderr.take().ok_or_else(refused)?;
+ os::nonblocking(&stdout)?;
+ os::nonblocking(&stderr)?;
+ protocol::write(&mut std::io::stdout(), state)?;
+ detach_stdio()?;
+ let mut out = Lines::default();
+ let mut err = Lines::default();
+ let mut stopping = None;
+ let mut killed = false;
+ let mut direct_exited = false;
+ let mut drained_after_exit = None;
+ loop {
+ // Alternate bounded reads so stdout bursts cannot starve stderr/stop.
+ out.drain(&mut stdout, log, &state.name, "stdout")?;
+ err.drain(&mut stderr, log, &state.name, "stderr")?;
+ if !direct_exited && let Some(status) = child.try_wait()? {
+ direct_exited = true;
+ state.exit_code = status.code();
+ state.signal = status.signal();
+ }
+ // Linux's subreaper may adopt exited grandchildren while the direct
+ // command remains alive. Reap continuously, retaining the direct child's
+ // wait status if it exits between try_wait and this raw waitpid census.
+ for (pid, status) in os::reap()? {
+ if pid == child.id() as i32 {
+ direct_exited = true;
+ state.exit_code = status.code();
+ state.signal = status.signal();
+ }
+ }
+ let live = os::live_group(state.process_group_id, Some(state.guard_pid))?;
+ if os::STOP.swap(false, Ordering::Relaxed) && stopping.is_none() {
+ state.phase = "stopping".into();
+ root.save(state)?;
+ stopping = Some(Instant::now());
+ os::session_signal(state.guard_pid, libc::SIGTERM)?;
+ }
+ if !live && direct_exited {
+ let since = *drained_after_exit.get_or_insert_with(Instant::now);
+ // Never wait on EOF: foreign fork/exec activity can transiently
+ // inherit a pipe on Darwin. Drain a bounded final interval instead.
+ if since.elapsed() >= Duration::from_millis(100) {
+ if !out.pending.is_empty() {
+ out.flush(log, &state.name, "stdout", true)?;
+ }
+ if !err.pending.is_empty() {
+ err.flush(log, &state.name, "stderr", true)?;
+ }
+ state.phase = if killed { "killed" } else { "exited" }.into();
+ root.save(state)?;
+ return Ok(());
+ }
+ }
+ if stopping.is_some_and(|start| start.elapsed() >= GRACE) {
+ state.phase = "kill-pending".into();
+ if !killed {
+ root.save(state)?;
+ killed = true;
+ }
+ os::session_signal(state.guard_pid, libc::SIGKILL)?;
+ }
+ thread::sleep(TICK);
+ }
+}
+
+fn stop(states: &[State]) -> Result<()> {
+ let mut active = Vec::new();
+ // Validate the full set before taking the first effect.
+ for state in states {
+ if !os::recorded_live(state, false)? {
+ continue;
+ }
+ let identity = os::inspect(state.guard_pid)?.ok_or_else(refused)?;
+ os::same(state, &identity)?;
+ active.push(state.clone());
+ }
+ for state in &active {
+ if let Err(error) = os::signal_guard(state) {
+ // Natural exit can race between admission and pidfd delivery.
+ // Only a new kernel absence proof turns that race into success.
+ if os::recorded_live(state, false)? {
+ return Err(error);
+ }
+ }
+ }
+ let deadline = Instant::now() + GRACE + Duration::from_secs(2);
+ while Instant::now() < deadline {
+ let mut live = false;
+ for state in &active {
+ live |= os::recorded_live(state, false)?;
+ }
+ if !live {
+ return Ok(());
+ }
+ thread::sleep(TICK);
+ }
+ // Unknown outcome: the guard retains its command lock while any live
+ // member remains. Neither timeout nor zombie-only existence implies alive.
+ Err(refused())
+}
+
+fn detach_stdio() -> Result<()> {
+ let null = std::fs::OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open("/dev/null")?;
+ for fd in 0..=2 {
+ // SAFETY: dup2 replaces only this process's standard descriptors with
+ // the borrowed live /dev/null descriptor; no pointer is retained.
+ if unsafe { libc::dup2(null.as_raw_fd(), fd) } < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ }
+ Ok(())
+}
+
+#[derive(Default)]
+struct Lines {
+ pending: Vec,
+}
+
+impl Lines {
+ fn drain(
+ &mut self,
+ input: &mut impl Read,
+ log: &mut File,
+ name: &str,
+ stream: &str,
+ ) -> Result<()> {
+ let mut buffer = [0u8; 8192];
+ for _ in 0..32 {
+ match input.read(&mut buffer) {
+ Ok(0) => break,
+ Ok(count) => {
+ for byte in &buffer[..count] {
+ if *byte == b'\n' {
+ self.flush(log, name, stream, true)?;
+ } else {
+ self.pending.push(*byte);
+ if self.pending.len() == LINE_LIMIT {
+ let tail_length = incomplete_utf8_tail(&self.pending);
+ let tail = self.pending.split_off(self.pending.len() - tail_length);
+ self.flush(log, name, stream, false)?;
+ self.pending = tail;
+ }
+ }
+ }
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break,
+ Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
+ Err(error) => return Err(error),
+ }
+ }
+ Ok(())
+ }
+
+ fn flush(&mut self, log: &mut File, name: &str, stream: &str, trim_cr: bool) -> Result<()> {
+ let bytes = if trim_cr {
+ self.pending.strip_suffix(b"\r").unwrap_or(&self.pending)
+ } else {
+ &self.pending
+ };
+ let message = String::from_utf8_lossy(bytes).replace('\t', " ");
+ let record = format!("{}\t{name}\t{stream}\t{message}\n", os::timestamp()?);
+ // One append per complete record; concurrent guards share the same
+ // flock so records larger than PIPE_BUF never interleave.
+ // SAFETY: flock borrows this live file descriptor until LOCK_UN.
+ if unsafe { libc::flock(log.as_raw_fd(), libc::LOCK_EX) } != 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let result = log.write_all(record.as_bytes());
+ // SAFETY: unlock the same live descriptor after the write completes.
+ let unlocked = unsafe { libc::flock(log.as_raw_fd(), libc::LOCK_UN) };
+ result?;
+ if unlocked != 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ self.pending.clear();
+ Ok(())
+ }
+}
+
+fn incomplete_utf8_tail(bytes: &[u8]) -> usize {
+ for index in (bytes.len().saturating_sub(4)..bytes.len()).rev() {
+ let byte = bytes[index];
+ if byte & 0xc0 == 0x80 {
+ continue;
+ }
+ let expected = match byte {
+ 0xc2..=0xdf => 2,
+ 0xe0..=0xef => 3,
+ 0xf0..=0xf4 => 4,
+ _ => 1,
+ };
+ let available = bytes.len() - index;
+ return if expected > available { available } else { 0 };
+ }
+ 0
+}
+
+#[cfg(test)]
+mod tests {
+ use super::incomplete_utf8_tail;
+
+ #[test]
+ fn chunk_boundary_retains_only_incomplete_codepoints() {
+ for text in ["a", "é", "€", "🦀"] {
+ let bytes = text.as_bytes();
+ assert_eq!(incomplete_utf8_tail(bytes), 0);
+ for length in 1..bytes.len() {
+ assert_eq!(incomplete_utf8_tail(&bytes[..length]), length);
+ }
+ }
+ assert_eq!(incomplete_utf8_tail(b"abc\xff"), 0);
+ }
+}
diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs
new file mode 100644
index 000000000..bb2683c59
--- /dev/null
+++ b/packages/lifecycle-supervisor/tests/fake_caller.rs
@@ -0,0 +1,1084 @@
+//! Real-process qualification through the framed public protocol. No shell,
+//! network, daemon, user HOME or registry is involved.
+use serde_json::{Value, json};
+use std::{
+ cell::RefCell,
+ fs,
+ io::{Read, Write},
+ os::{fd::AsRawFd, unix::fs::MetadataExt},
+ path::{Path, PathBuf},
+ process::{Child, Command, Stdio},
+ sync::atomic::{AtomicU64, Ordering},
+ thread,
+ time::{Duration, Instant, SystemTime, UNIX_EPOCH},
+};
+
+static SEQUENCE: AtomicU64 = AtomicU64::new(0);
+const TOKEN: &str = "synthetic-owner-token";
+const TIMEOUT: Duration = Duration::from_secs(15);
+
+struct Fixture {
+ root: PathBuf,
+ owned: RefCell>,
+}
+
+impl Fixture {
+ fn new() -> Self {
+ let parent = std::env::var_os("TMPDIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(std::env::temp_dir);
+ let nonce = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ let root = parent.join(format!(
+ "supervisor-test-{}-{nonce}-{}",
+ std::process::id(),
+ SEQUENCE.fetch_add(1, Ordering::Relaxed)
+ ));
+ fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
+ Self {
+ root,
+ owned: RefCell::new(Vec::new()),
+ }
+ }
+
+ fn process(&self, name: &str, mode: &str) -> Value {
+ json!({"name":name,"argv":[std::env::current_exe().unwrap(),"--exact","fixture_process","--nocapture"],"cwd":self.root,"env":{"HOME":self.root,"PATH":"/usr/bin:/bin","HACK_DAEMON_AUTOSTART_DISABLE":"1","SUPERVISOR_FIXTURE":mode}})
+ }
+
+ fn launch(&self, processes: Vec) -> Value {
+ json!({"version":1,"root":self.root,"ownerToken":TOKEN,"processes":processes})
+ }
+
+ fn control(&self, token: &str) -> Value {
+ json!({"version":1,"root":self.root,"ownerToken":token})
+ }
+
+ fn request(&self, action: &str, request: &Value) -> (Child, Value) {
+ let mut child = command(&self.root, action).spawn().unwrap();
+ send(child.stdin.as_mut().unwrap(), request);
+ // Deliberately retain stdin: frame receipt must not depend on EOF.
+ let reply = receive(child.stdout.as_mut().unwrap());
+ self.remember(&reply);
+ (child, reply)
+ }
+
+ fn remember(&self, reply: &Value) {
+ for state in reply["processes"].as_array().into_iter().flatten() {
+ for field in ["guardPid", "commandPid"] {
+ if let Some(id) = state[field].as_i64().and_then(|id| identity(id as i32)) {
+ self.owned.borrow_mut().push(id);
+ }
+ }
+ }
+ }
+
+ fn start(&self, processes: Vec) -> (Child, Value) {
+ self.request("run", &self.launch(processes))
+ }
+
+ fn stop(&self) {
+ let mut child = command(&self.root, "stop").spawn().unwrap();
+ send(child.stdin.as_mut().unwrap(), &self.control(TOKEN));
+ drop(child.stdin.take());
+ assert!(wait(&mut child, TIMEOUT).success(), "owned stop failed");
+ }
+
+ fn state(&self, name: &str) -> Value {
+ eventually(|| {
+ fs::read(self.root.join(format!("{name}.json")))
+ .ok()
+ .and_then(|b| serde_json::from_slice(&b).ok())
+ })
+ }
+
+ fn log(&self) -> String {
+ fs::read_to_string(self.root.join("lifecycle.log")).unwrap_or_default()
+ }
+
+ fn await_log(&self, needle: &str) {
+ eventually(|| self.log().contains(needle).then_some(()));
+ }
+}
+
+impl Drop for Fixture {
+ fn drop(&mut self) {
+ // Ask the normal owner first, including after an assertion panic. Stop
+ // has a bounded wait and all inputs refer only to this fixture root.
+ if let Ok(mut stop) = command(&self.root, "stop").spawn() {
+ if let Some(mut stdin) = stop.stdin.take() {
+ let bytes = serde_json::to_vec(&self.control(TOKEN)).unwrap_or_default();
+ let _ = stdin.write_all(&(bytes.len() as u32).to_be_bytes());
+ let _ = stdin.write_all(&bytes);
+ }
+ let deadline = Instant::now() + Duration::from_secs(8);
+ while stop.try_wait().ok().flatten().is_none() && Instant::now() < deadline {
+ thread::sleep(Duration::from_millis(20));
+ }
+ let _ = stop.kill();
+ let _ = stop.wait();
+ }
+ // Receipt copies are captured before tests deliberately corrupt state.
+ // A still-matching guard or command birth proves the original session
+ // still exists. Recheck each enumerated PID's birth before its signal.
+ for anchor in self.owned.borrow().iter() {
+ if identity(anchor.pid).as_ref() != Some(anchor) {
+ continue;
+ }
+ let mut members: Vec<_> = census()
+ .into_iter()
+ .filter(|p| p.session == anchor.session)
+ .collect();
+ members.sort_by_key(|p| p.pid == anchor.session);
+ for member in members {
+ if identity(member.pid).as_ref() == Some(&member) {
+ // SAFETY: matched kernel birth and session from a live
+ // captured launch identity; no user-owned state is used.
+ unsafe {
+ libc::kill(member.pid, libc::SIGKILL);
+ }
+ }
+ }
+ }
+ let _ = fs::remove_dir_all(&self.root);
+ }
+}
+
+#[derive(Clone, PartialEq, Eq)]
+struct Identity {
+ pid: i32,
+ session: i32,
+ birth: u64,
+}
+
+#[cfg(target_os = "linux")]
+fn identity(pid: i32) -> Option {
+ let text = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
+ let fields: Vec<_> = text.rsplit_once(") ")?.1.split_whitespace().collect();
+ Some(Identity {
+ pid,
+ session: fields.get(3)?.parse().ok()?,
+ birth: fields.get(19)?.parse().ok()?,
+ })
+}
+
+#[cfg(target_os = "macos")]
+fn identity(pid: i32) -> Option {
+ let mut info = std::mem::MaybeUninit::::zeroed();
+ // SAFETY: kernel writes at most the supplied live buffer size.
+ let size = unsafe {
+ libc::proc_pidinfo(
+ pid,
+ libc::PROC_PIDTBSDINFO,
+ 0,
+ info.as_mut_ptr().cast(),
+ std::mem::size_of::() as i32,
+ )
+ };
+ if size as usize != std::mem::size_of::() {
+ return None;
+ }
+ let info = unsafe { info.assume_init() };
+ let session = unsafe { libc::getsid(pid) };
+ (session > 1).then_some(Identity {
+ pid,
+ session,
+ birth: info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec,
+ })
+}
+
+#[cfg(target_os = "linux")]
+fn census() -> Vec {
+ fs::read_dir("/proc")
+ .into_iter()
+ .flatten()
+ .flatten()
+ .filter_map(|e| e.file_name().to_str()?.parse().ok())
+ .filter_map(identity)
+ .collect()
+}
+
+#[cfg(target_os = "macos")]
+fn census() -> Vec {
+ let mut pids = vec![0i32; 65536];
+ // SAFETY: initialized vector provides the supplied writable byte count.
+ let count =
+ unsafe { libc::proc_listpids(1, 0, pids.as_mut_ptr().cast(), (pids.len() * 4) as i32) };
+ if count <= 0 {
+ return Vec::new();
+ }
+ pids.into_iter()
+ .take(count as usize / 4)
+ .filter(|p| *p > 1)
+ .filter_map(identity)
+ .collect()
+}
+
+fn command(root: &Path, action: &str) -> Command {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_hack-lifecycle-supervisor"));
+ command
+ .arg(action)
+ .env_clear()
+ .env("HOME", root)
+ .env("PATH", "/usr/bin:/bin")
+ .env("HACK_DAEMON_AUTOSTART_DISABLE", "1")
+ .env("SUPERVISOR_PARENT_ONLY", "synthetic-parent-only")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null());
+ command
+}
+
+fn send(writer: &mut impl Write, value: &Value) {
+ let bytes = serde_json::to_vec(value).unwrap();
+ writer
+ .write_all(&(bytes.len() as u32).to_be_bytes())
+ .unwrap();
+ writer.write_all(&bytes).unwrap();
+ writer.flush().unwrap();
+}
+
+fn exact(reader: &mut (impl Read + AsRawFd), bytes: &mut [u8]) {
+ let end = Instant::now() + TIMEOUT;
+ let mut offset = 0;
+ while offset < bytes.len() {
+ assert!(Instant::now() < end, "framed response timed out");
+ let mut poll = libc::pollfd {
+ fd: reader.as_raw_fd(),
+ events: libc::POLLIN,
+ revents: 0,
+ };
+ // SAFETY: one initialized pollfd is writable and alive for this call.
+ let n = unsafe { libc::poll(&mut poll, 1, 100) };
+ if n == 0 {
+ continue;
+ }
+ assert!(n > 0, "poll failed");
+ let n = reader.read(&mut bytes[offset..]).unwrap();
+ assert!(n > 0, "response closed before a complete frame");
+ offset += n;
+ }
+}
+
+fn receive(reader: &mut (impl Read + AsRawFd)) -> Value {
+ let mut header = [0; 4];
+ exact(reader, &mut header);
+ let length = u32::from_be_bytes(header) as usize;
+ assert!((1..=1024 * 1024).contains(&length));
+ let mut bytes = vec![0; length];
+ exact(reader, &mut bytes);
+ serde_json::from_slice(&bytes).unwrap()
+}
+
+fn wait(child: &mut Child, timeout: Duration) -> std::process::ExitStatus {
+ let end = Instant::now() + timeout;
+ loop {
+ if let Some(status) = child.try_wait().unwrap() {
+ return status;
+ }
+ if Instant::now() >= end {
+ let _ = child.kill();
+ let _ = child.wait();
+ panic!("owned subprocess exceeded deadline");
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+}
+
+fn eventually(mut predicate: impl FnMut() -> Option) -> T {
+ let end = Instant::now() + TIMEOUT;
+ loop {
+ if let Some(value) = predicate() {
+ return value;
+ }
+ assert!(
+ Instant::now() < end,
+ "expected fixture observation did not arrive"
+ );
+ thread::sleep(Duration::from_millis(20));
+ }
+}
+
+fn pid(state: &Value, field: &str) -> i32 {
+ state[field].as_i64().unwrap() as i32
+}
+
+struct ForeignChild(Child);
+impl ForeignChild {
+ fn spawn() -> Self {
+ use std::os::unix::process::CommandExt;
+ let mut command = Command::new(std::env::current_exe().unwrap());
+ command
+ .args(["--exact", "fixture_process", "--nocapture"])
+ .env_clear()
+ .env("SUPERVISOR_FIXTURE", "hold")
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+ // SAFETY: changes only the new child, with no allocated state.
+ unsafe {
+ command.pre_exec(|| {
+ if libc::setsid() < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ });
+ }
+ Self(command.spawn().unwrap())
+ }
+}
+impl Drop for ForeignChild {
+ fn drop(&mut self) {
+ let _ = self.0.kill();
+ let _ = self.0.wait();
+ }
+}
+
+fn refused(fixture: &Fixture, action: &str, request: Value) {
+ let mut child = command(&fixture.root, action).spawn().unwrap();
+ send(child.stdin.as_mut().unwrap(), &request);
+ drop(child.stdin.take());
+ assert!(
+ !wait(&mut child, TIMEOUT).success(),
+ "unsafe operation was accepted"
+ );
+}
+
+#[test]
+fn starts_and_records_identity() {
+ let f = Fixture::new();
+ let (mut caller, receipt) = f.start(vec![f.process("worker", "hold")]);
+ assert_eq!(receipt["version"], 1);
+ f.await_log("fixture-ready");
+ let state = f.state("worker");
+ assert_eq!(state["ownerToken"], TOKEN);
+ assert_eq!(state["guardPid"], state["processGroupId"]);
+ assert!(state["guardStartTime"].as_u64().unwrap() > 0);
+ assert!(state["bootId"].as_str().is_some_and(|v| !v.is_empty()));
+ assert!(pid(&state, "commandPid") > 1);
+ assert_eq!(
+ fs::metadata(f.root.join("worker.json")).unwrap().mode() & 0o777,
+ 0o600
+ );
+ assert_eq!(
+ fs::metadata(f.root.join("lifecycle.log")).unwrap().mode() & 0o777,
+ 0o600
+ );
+ f.stop();
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn stop_after_caller_closed() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "hold")]);
+ drop(caller.stdin.take());
+ drop(caller.stdout.take());
+ f.await_log("fixture-ready");
+ f.stop();
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn supervisor_kill_then_recover_stop() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "hold")]);
+ f.await_log("fixture-ready");
+ caller.kill().unwrap();
+ caller.wait().unwrap();
+ refused(&f, "run", f.launch(vec![f.process("worker", "hold")]));
+ f.stop();
+}
+
+#[test]
+fn term_ignoring_command_is_killed_after_grace() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "ignore-term")]);
+ f.await_log("fixture-ready");
+ let start = Instant::now();
+ f.stop();
+ assert!(
+ start.elapsed() >= Duration::from_secs(5),
+ "TERM grace was shortened"
+ );
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn foreign_owner_cannot_stop() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "hold")]);
+ f.await_log("fixture-ready");
+ refused(&f, "stop", f.control("different-owner"));
+ let state = f.state("worker");
+ // SAFETY: signal zero observes this fixture's process without delivering a signal.
+ assert_eq!(unsafe { libc::kill(pid(&state, "commandPid"), 0) }, 0);
+ f.stop();
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn failed_start_runs_no_later_command() {
+ let f = Fixture::new();
+ let broken =
+ json!({"name":"broken","argv":["/nonexistent/synthetic-program"],"cwd":f.root,"env":{}});
+ refused(
+ &f,
+ "run",
+ f.launch(vec![broken, f.process("later", "hold")]),
+ );
+ assert!(
+ !f.root.join("later.json").exists(),
+ "later command started after failure"
+ );
+ assert!(!f.log().contains("fixture-ready"));
+}
+
+#[test]
+fn output_bursts_and_long_lines_include_partial_tail() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "burst")]);
+ assert!(wait(&mut caller, TIMEOUT).success());
+ let text = f.log();
+ assert!(text.contains("\tstderr\tfixture-error"));
+ assert!(text.contains("partial-tail"));
+ assert_eq!(text.matches("burst-record-").count(), 2000);
+ assert_eq!(text.bytes().filter(|v| *v == b'X').count(), 300_000);
+ for line in text.lines() {
+ let fields: Vec<_> = line.splitn(4, '\t').collect();
+ assert_eq!(fields.len(), 4);
+ assert_eq!(fields[1], "worker");
+ assert!(fields[0].ends_with('Z'));
+ }
+}
+
+#[test]
+fn zombies_do_not_hold_liveness() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "zombie")]);
+ assert!(
+ wait(&mut caller, TIMEOUT).success(),
+ "zombie-only session remained alive"
+ );
+}
+
+#[test]
+fn sentinel_environment_never_enters_argv_or_state() {
+ let f = Fixture::new();
+ let mut process = f.process("worker", "sentinel");
+ let sentinel = format!(
+ "synthetic-credential-{}-{}",
+ std::process::id(),
+ SEQUENCE.fetch_add(1, Ordering::Relaxed)
+ );
+ process["env"]["SYNTHETIC_PRIVATE_VALUE"] = json!(sentinel);
+ let (mut caller, _) = f.start(vec![process]);
+ f.await_log("sentinel-received");
+ for entry in fs::read_dir(&f.root).unwrap().flatten() {
+ if entry.file_type().unwrap().is_file() {
+ let bytes = fs::read(entry.path()).unwrap();
+ assert!(
+ !bytes
+ .windows(sentinel.len())
+ .any(|v| v == sentinel.as_bytes()),
+ "private value persisted"
+ );
+ }
+ }
+ let state = f.state("worker");
+ #[cfg(target_os = "linux")]
+ for process_id in [
+ caller.id() as i32,
+ pid(&state, "guardPid"),
+ pid(&state, "commandPid"),
+ ] {
+ let bytes = fs::read(format!("/proc/{process_id}/cmdline")).unwrap();
+ assert!(
+ !bytes
+ .windows(sentinel.len())
+ .any(|v| v == sentinel.as_bytes()),
+ "private value in argv"
+ );
+ }
+ #[cfg(target_os = "linux")]
+ {
+ let bytes = fs::read(format!("/proc/{}/environ", pid(&state, "guardPid"))).unwrap();
+ assert!(bytes.is_empty(), "guard inherited caller environment");
+ }
+ #[cfg(target_os = "macos")]
+ {
+ let output = Command::new("/bin/ps")
+ .args([
+ "-o",
+ "command=",
+ "-p",
+ &format!(
+ "{},{},{}",
+ caller.id(),
+ pid(&state, "guardPid"),
+ pid(&state, "commandPid")
+ ),
+ ])
+ .output()
+ .unwrap();
+ assert!(
+ !output
+ .stdout
+ .windows(sentinel.len())
+ .any(|v| v == sentinel.as_bytes()),
+ "private value in argv"
+ );
+ }
+ f.stop();
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn child_descriptor_allowlist() {
+ use std::os::fd::{FromRawFd, OwnedFd};
+ use std::os::unix::fs::OpenOptionsExt;
+ let f = Fixture::new();
+ let file = fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(f.root.join("unexpected-inheritance"))
+ .unwrap();
+ // Deliberately emulate a caller's inheritable descriptor. The launcher's
+ // startup descriptor sweep and each exec boundary must exclude it.
+ let raw = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_DUPFD, 200) };
+ assert!(raw >= 200);
+ let _inheritable = unsafe { OwnedFd::from_raw_fd(raw) };
+ let (mut caller, _) = f.start(vec![f.process("worker", "descriptors")]);
+ assert!(wait(&mut caller, TIMEOUT).success());
+ assert!(f.log().contains("descriptor-allowlist-ok"));
+ assert!(!f.log().contains("descriptor-leak"));
+}
+
+#[test]
+fn concurrent_stop_requests_settle() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "hold")]);
+ f.await_log("fixture-ready");
+ let mut first = command(&f.root, "stop").spawn().unwrap();
+ let mut second = command(&f.root, "stop").spawn().unwrap();
+ send(first.stdin.as_mut().unwrap(), &f.control(TOKEN));
+ send(second.stdin.as_mut().unwrap(), &f.control(TOKEN));
+ drop(first.stdin.take());
+ drop(second.stdin.take());
+ let first_ok = wait(&mut first, TIMEOUT).success();
+ let second_ok = wait(&mut second, TIMEOUT).success();
+ assert!(first_ok || second_ok, "neither stop request settled");
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn changed_process_group_remains_in_owned_session() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "change-group")]);
+ f.await_log("changed-group-ready");
+ f.stop();
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn inherited_ignored_signal_is_reset() {
+ use std::os::unix::process::CommandExt;
+ let f = Fixture::new();
+ let mut command = command(&f.root, "run");
+ // SAFETY: signal changes only the freshly forked test child; it is an
+ // async-signal-safe operation and captures no allocated state.
+ unsafe {
+ command.pre_exec(|| {
+ libc::signal(libc::SIGTERM, libc::SIG_IGN);
+ Ok(())
+ });
+ }
+ let mut caller = command.spawn().unwrap();
+ send(
+ caller.stdin.as_mut().unwrap(),
+ &f.launch(vec![f.process("worker", "signal-default")]),
+ );
+ let receipt = receive(caller.stdout.as_mut().unwrap());
+ f.remember(&receipt);
+ f.await_log("signal-default-ok");
+ let start = Instant::now();
+ f.stop();
+ assert!(
+ start.elapsed() < Duration::from_secs(4),
+ "command retained ignored TERM disposition"
+ );
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn supervisor_killed_during_output_burst_is_recoverable() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "continuous-output")]);
+ f.await_log("continuous-output");
+ caller.kill().unwrap();
+ caller.wait().unwrap();
+ f.stop();
+}
+
+#[test]
+fn dead_guard_with_live_session_refuses_duplicate_and_stop() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "hold")]);
+ f.await_log("fixture-ready");
+ let state = f.state("worker");
+ // SAFETY: these PIDs came from this fixture's ready receipt only.
+ unsafe {
+ libc::kill(pid(&state, "guardPid"), libc::SIGKILL);
+ }
+ assert!(wait(&mut caller, TIMEOUT).success());
+ refused(&f, "run", f.launch(vec![f.process("worker", "hold")]));
+ let (mut stop, _) = f.request("stop", &f.control(TOKEN));
+ assert!(!wait(&mut stop, TIMEOUT).success());
+ // The guard cannot prove ownership after its own death. The test itself
+ // retains direct launch provenance and must clean up its authored child.
+ unsafe {
+ libc::kill(pid(&state, "commandPid"), libc::SIGKILL);
+ }
+}
+
+#[test]
+fn stale_receipt_cannot_signal_another_process() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "hold")]);
+ f.await_log("fixture-ready");
+ let mut state = f.state("worker");
+ let original = state.clone();
+ // A valid-looking receipt with an incorrect birth time must never become
+ // authority just because its PID currently exists.
+ state["guardStartTime"] = json!(state["guardStartTime"].as_u64().unwrap() + 1);
+ fs::write(
+ f.root.join("worker.json"),
+ serde_json::to_vec(&state).unwrap(),
+ )
+ .unwrap();
+ let (mut stop, _) = f.request("stop", &f.control(TOKEN));
+ wait(&mut stop, TIMEOUT);
+ assert_eq!(unsafe { libc::kill(pid(&original, "commandPid"), 0) }, 0);
+ fs::write(
+ f.root.join("worker.json"),
+ serde_json::to_vec(&original).unwrap(),
+ )
+ .unwrap();
+ f.stop();
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn receipt_naming_a_foreign_pid_cannot_stop_it() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "hold")]);
+ f.await_log("fixture-ready");
+ let mut foreign = ForeignChild::spawn();
+ let original = f.state("worker");
+ let mut state = original.clone();
+ state["guardPid"] = json!(foreign.0.id());
+ state["processGroupId"] = json!(foreign.0.id());
+ // Linux births have clock-tick precision: two distinct live PIDs can have
+ // equal births. Make this stale identity differ deterministically.
+ let foreign_identity = eventually(|| identity(foreign.0.id() as i32));
+ state["guardStartTime"] = json!(foreign_identity.birth + 1);
+ fs::write(
+ f.root.join("worker.json"),
+ serde_json::to_vec(&state).unwrap(),
+ )
+ .unwrap();
+ let (mut stop, _) = f.request("stop", &f.control(TOKEN));
+ wait(&mut stop, TIMEOUT);
+ let foreign_running = foreign.0.try_wait().unwrap().is_none();
+ drop(foreign);
+ fs::write(
+ f.root.join("worker.json"),
+ serde_json::to_vec(&original).unwrap(),
+ )
+ .unwrap();
+ f.stop();
+ assert!(wait(&mut caller, TIMEOUT).success());
+ assert!(foreign_running, "stale receipt signalled another process");
+}
+
+#[test]
+fn stale_reused_session_leader_does_not_block_fresh_launch() {
+ let f = Fixture::new();
+ let (mut first, _) = f.start(vec![f.process("worker", "hold")]);
+ f.await_log("fixture-ready");
+ f.stop();
+ assert!(wait(&mut first, TIMEOUT).success());
+ let mut foreign = ForeignChild::spawn();
+ let foreign_identity = eventually(|| identity(foreign.0.id() as i32));
+ let mut state = f.state("worker");
+ state["guardPid"] = json!(foreign.0.id());
+ state["processGroupId"] = json!(foreign.0.id());
+ state["guardStartTime"] = json!(foreign_identity.birth + 1);
+ fs::write(
+ f.root.join("worker.json"),
+ serde_json::to_vec(&state).unwrap(),
+ )
+ .unwrap();
+ let (mut next, _) = f.start(vec![f.process("worker", "hold")]);
+ f.stop();
+ assert!(wait(&mut next, TIMEOUT).success());
+ assert!(
+ foreign.0.try_wait().unwrap().is_none(),
+ "foreign reused session was signalled"
+ );
+}
+
+#[test]
+fn incomplete_frame_has_one_whole_handoff_deadline() {
+ let f = Fixture::new();
+ let mut caller = command(&f.root, "run").spawn().unwrap();
+ let start = Instant::now();
+ caller.stdin.as_mut().unwrap().write_all(&[0]).unwrap();
+ thread::sleep(Duration::from_secs(6));
+ // Complete the header late, leaving the announced body absent. A reset
+ // deadline per read would let this request survive sixteen seconds.
+ caller
+ .stdin
+ .as_mut()
+ .unwrap()
+ .write_all(&[0, 0, 16])
+ .unwrap();
+ assert!(!wait(&mut caller, Duration::from_secs(6)).success());
+ assert!(start.elapsed() < Duration::from_secs(12));
+ assert!(!f.root.join("lifecycle.log").exists());
+}
+
+#[test]
+fn failure_teardown_stops_separate_command_group() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "hold")]);
+ f.await_log("fixture-ready");
+ let state = f.state("worker");
+ let command_pid = pid(&state, "commandPid");
+ let guard_pid = pid(&state, "guardPid");
+ assert_ne!(command_pid, guard_pid);
+ drop(f);
+ assert!(wait(&mut caller, TIMEOUT).success());
+ assert!(identity(command_pid).is_none(), "fixture command leaked");
+ assert!(identity(guard_pid).is_none(), "fixture guard leaked");
+}
+
+#[test]
+fn unresponsive_guard_stop_remains_unknown() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "hold")]);
+ f.await_log("fixture-ready");
+ let state = f.state("worker");
+ let guard = pid(&state, "guardPid");
+ // SAFETY: the guard belongs to this fixture and its receipt is immutable.
+ assert_eq!(unsafe { libc::kill(guard, libc::SIGSTOP) }, 0);
+ let (mut stop, receipt) = f.request("stop", &f.control(TOKEN));
+ assert_eq!(receipt["outcome"], "unknown");
+ assert!(!wait(&mut stop, TIMEOUT).success());
+ assert_eq!(unsafe { libc::kill(pid(&state, "commandPid"), 0) }, 0);
+ refused(&f, "run", f.launch(vec![f.process("worker", "hold")]));
+ assert_eq!(unsafe { libc::kill(guard, libc::SIGCONT) }, 0);
+ f.stop();
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn new_generation_ignores_retained_terminal_receipts() {
+ let f = Fixture::new();
+ let (mut first, _) = f.start(vec![f.process("old-name", "hold")]);
+ f.await_log("fixture-ready");
+ f.stop();
+ assert!(wait(&mut first, TIMEOUT).success());
+ let mut launch = f.launch(vec![f.process("renamed", "hold")]);
+ launch["ownerToken"] = json!("synthetic-next-generation");
+ let (mut next, _) = f.request("run", &launch);
+ let (mut stop, receipt) = f.request("stop", &f.control("synthetic-next-generation"));
+ assert!(wait(&mut stop, TIMEOUT).success());
+ assert!(wait(&mut next, TIMEOUT).success());
+ assert_eq!(receipt["processes"].as_array().unwrap().len(), 1);
+ assert!(
+ f.root.join("old-name.json").exists(),
+ "terminal receipt was discarded"
+ );
+}
+
+#[test]
+fn command_joining_guard_group_still_receives_kill() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "join-guard-group")]);
+ f.await_log("joined-guard-group");
+ let start = Instant::now();
+ f.stop();
+ assert!(start.elapsed() >= Duration::from_secs(5));
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn process_group_leader_caller_refuses_without_launch() {
+ use std::os::unix::process::CommandExt;
+ let f = Fixture::new();
+ let mut command = command(&f.root, "run");
+ // SAFETY: changes only the newly forked child, using no allocated state.
+ unsafe {
+ command.pre_exec(|| {
+ if libc::setpgid(0, 0) != 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ });
+ }
+ let mut caller = command.spawn().unwrap();
+ send(
+ caller.stdin.as_mut().unwrap(),
+ &f.launch(vec![f.process("worker", "hold")]),
+ );
+ drop(caller.stdin.take());
+ assert!(!wait(&mut caller, TIMEOUT).success());
+ assert!(!f.root.join("worker.json").exists());
+ assert!(!f.root.join("lifecycle.log").exists());
+}
+
+#[test]
+fn stop_serializes_with_multiple_process_launches() {
+ let f = Fixture::new();
+ let processes: Vec<_> = (0..16)
+ .map(|i| f.process(&format!("worker-{i}"), "hold"))
+ .collect();
+ let mut caller = command(&f.root, "run").spawn().unwrap();
+ send(caller.stdin.as_mut().unwrap(), &f.launch(processes));
+ f.state("worker-0");
+ let mut stop = command(&f.root, "stop").spawn().unwrap();
+ send(stop.stdin.as_mut().unwrap(), &f.control(TOKEN));
+ let ready = receive(caller.stdout.as_mut().unwrap());
+ f.remember(&ready);
+ let receipt = receive(stop.stdout.as_mut().unwrap());
+ assert_eq!(receipt["outcome"], "stopped");
+ assert!(wait(&mut stop, TIMEOUT).success());
+ assert!(wait(&mut caller, TIMEOUT).success());
+ for state in ready["processes"].as_array().unwrap() {
+ assert!(
+ identity(pid(state, "commandPid")).is_none(),
+ "a launch escaped concurrent stop"
+ );
+ }
+}
+
+#[test]
+fn unicode_codepoint_crossing_chunk_boundary_is_preserved() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "unicode")]);
+ assert!(wait(&mut caller, TIMEOUT).success());
+ let log = f.log();
+ assert_eq!(log.matches('🦀').count(), 1);
+ assert!(
+ !log.contains('\u{fffd}'),
+ "UTF-8 chunk boundary corrupted a codepoint"
+ );
+}
+
+#[test]
+fn case_insensitive_process_name_collision_refuses_all_launches() {
+ let f = Fixture::new();
+ refused(
+ &f,
+ "run",
+ f.launch(vec![
+ f.process("Worker", "hold"),
+ f.process("worker", "hold"),
+ ]),
+ );
+ assert!(!f.root.join("Worker.json").exists());
+ assert!(!f.root.join("worker.json").exists());
+ assert!(!f.root.join("lifecycle.log").exists());
+}
+
+#[test]
+fn status_observes_without_creating_files() {
+ let f = Fixture::new();
+ let (mut status, receipt) = f.request("status", &f.control(TOKEN));
+ assert!(wait(&mut status, TIMEOUT).success());
+ assert_eq!(receipt["outcome"], "observed");
+ assert!(receipt["processes"].as_array().unwrap().is_empty());
+ assert_eq!(fs::read_dir(&f.root).unwrap().count(), 0);
+}
+
+#[cfg(target_os = "linux")]
+#[test]
+fn adopted_orphan_is_reaped_while_direct_command_lives() {
+ let f = Fixture::new();
+ let (mut caller, _) = f.start(vec![f.process("worker", "orphan-exits")]);
+ f.await_log("orphan-ready");
+ let orphan = eventually(|| {
+ f.log().lines().find_map(|line| {
+ line.split("orphan-ready ")
+ .nth(1)
+ .and_then(|value| value.parse::().ok())
+ })
+ });
+ eventually(|| identity(orphan).is_none().then_some(()));
+ let state = f.state("worker");
+ assert!(identity(pid(&state, "commandPid")).is_some());
+ f.stop();
+ assert!(wait(&mut caller, TIMEOUT).success());
+}
+
+#[test]
+fn fixture_process() {
+ let Ok(mode) = std::env::var("SUPERVISOR_FIXTURE") else {
+ return;
+ };
+ assert!(
+ std::env::var_os("SUPERVISOR_PARENT_ONLY").is_none(),
+ "command inherited caller environment"
+ );
+ match mode.as_str() {
+ "burst" => {
+ let mut out = std::io::stdout().lock();
+ for i in 0..2000 {
+ writeln!(out, "burst-record-{i}").unwrap();
+ }
+ out.write_all(&vec![b'X'; 300_000]).unwrap();
+ out.write_all(b"\npartial-tail").unwrap();
+ out.flush().unwrap();
+ eprintln!("fixture-error");
+ std::process::exit(0);
+ }
+ "descriptors" => {
+ assert_eq!(unsafe { libc::isatty(0) }, 0);
+ let mut byte = [0u8];
+ assert_eq!(
+ std::io::stdin().read(&mut byte).unwrap(),
+ 0,
+ "command inherited private protocol input"
+ );
+ let mut leaked = false;
+ for fd in 3..1024 {
+ // SAFETY: F_GETFD observes descriptor state without mutation.
+ if unsafe { libc::fcntl(fd, libc::F_GETFD) } >= 0 {
+ leaked = true;
+ }
+ }
+ println!(
+ "{}",
+ if leaked {
+ "descriptor-leak"
+ } else {
+ "descriptor-allowlist-ok"
+ }
+ );
+ std::process::exit(if leaked { 1 } else { 0 });
+ }
+ "zombie" => {
+ // SAFETY: fixture immediately exits in the fork child; it invokes
+ // no allocator or Rust destructors after fork in that branch.
+ let child = unsafe { libc::fork() };
+ assert!(child >= 0);
+ if child == 0 {
+ unsafe {
+ libc::_exit(0);
+ }
+ }
+ thread::sleep(Duration::from_millis(100));
+ std::process::exit(0);
+ }
+ #[cfg(target_os = "linux")]
+ "orphan-exits" => {
+ let (mut input, output) = std::os::unix::net::UnixStream::pair().unwrap();
+ // SAFETY: both fork descendants use only async-signal-safe libc
+ // calls and immediately exit; no Rust allocator state is reused.
+ let intermediary = unsafe { libc::fork() };
+ assert!(intermediary >= 0);
+ if intermediary == 0 {
+ unsafe {
+ let orphan = libc::fork();
+ if orphan == 0 {
+ libc::usleep(300_000);
+ libc::_exit(0);
+ }
+ let bytes = orphan.to_be_bytes();
+ libc::write(output.as_raw_fd(), bytes.as_ptr().cast(), bytes.len());
+ libc::_exit(if orphan < 0 { 98 } else { 0 });
+ }
+ }
+ let mut status = 0;
+ assert_eq!(
+ unsafe { libc::waitpid(intermediary, &mut status, 0) },
+ intermediary
+ );
+ let mut bytes = [0; 4];
+ input.read_exact(&mut bytes).unwrap();
+ let orphan = i32::from_be_bytes(bytes);
+ assert!(orphan > 1);
+ println!("orphan-ready {orphan}");
+ }
+ "change-group" => {
+ // SAFETY: fork child uses only async-signal-safe calls and never
+ // returns to Rust, so the libtest thread state is not reused.
+ let child = unsafe { libc::fork() };
+ assert!(child >= 0);
+ if child == 0 {
+ unsafe {
+ if libc::setpgid(0, 0) != 0 {
+ libc::_exit(98);
+ }
+ libc::write(1, c"changed-group-ready\n".as_ptr().cast(), 20);
+ loop {
+ libc::pause();
+ }
+ }
+ }
+ }
+ "join-guard-group" => {
+ // SAFETY: fixture joins its existing session leader's group; only
+ // this authored test process's group/disposition are changed.
+ unsafe {
+ let session = libc::getsid(0);
+ assert!(session > 1);
+ assert_eq!(libc::setpgid(0, session), 0);
+ libc::signal(libc::SIGTERM, libc::SIG_IGN);
+ }
+ println!("joined-guard-group");
+ }
+ "unicode" => {
+ let mut out = std::io::stdout().lock();
+ out.write_all(&vec![b'a'; 65535]).unwrap();
+ out.write_all("🦀\n".as_bytes()).unwrap();
+ out.flush().unwrap();
+ std::process::exit(0);
+ }
+ "signal-default" => {
+ let mut disposition: libc::sigaction = unsafe { std::mem::zeroed() };
+ assert_eq!(
+ unsafe { libc::sigaction(libc::SIGTERM, std::ptr::null(), &mut disposition) },
+ 0
+ );
+ assert_eq!(disposition.sa_sigaction, libc::SIG_DFL);
+ println!("signal-default-ok");
+ }
+ "continuous-output" => loop {
+ println!("continuous-output {}", "x".repeat(4096));
+ thread::sleep(Duration::from_millis(1));
+ },
+ "sentinel" => {
+ assert!(std::env::var_os("SYNTHETIC_PRIVATE_VALUE").is_some());
+ println!("sentinel-received");
+ }
+ "ignore-term" => {
+ // SAFETY: SIG_IGN is a valid process-local disposition.
+ unsafe {
+ libc::signal(libc::SIGTERM, libc::SIG_IGN);
+ }
+ }
+ "hold" => (),
+ _ => std::process::exit(99),
+ }
+ println!("fixture-ready");
+ std::io::stdout().flush().unwrap();
+ loop {
+ thread::sleep(Duration::from_secs(1));
+ }
+}
+
+use std::os::unix::fs::DirBuilderExt;
diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts
index 61f47515d..aed6fa011 100644
--- a/scripts/lib/tla-runtime-models.ts
+++ b/scripts/lib/tla-runtime-models.ts
@@ -141,6 +141,14 @@ const restartPolicyStaleUses = [
{ engine: "exited", bound: 3 },
] as const;
const contracts: readonly ModelContract[] = [
+ {
+ name: "lifecycle-supervisor",
+ module: "LifecycleSupervisor",
+ states: 129,
+ invariant: "NoDuplicate",
+ action: "Retry",
+ fields: ["duplicate = TRUE", "live = TRUE", "durable = TRUE"],
+ },
{
name: "shutdown-rebase",
module: "ShutdownRebase",
diff --git a/tests/models/tla/lifecycle-supervisor/COVERAGE.md b/tests/models/tla/lifecycle-supervisor/COVERAGE.md
new file mode 100644
index 000000000..5416ba319
--- /dev/null
+++ b/tests/models/tla/lifecycle-supervisor/COVERAGE.md
@@ -0,0 +1,38 @@
+# Lifecycle supervisor ownership model
+
+`LifecycleSupervisor.tla` models one configured command name and at most two
+start attempts. The model precedes implementation. The first artifact is a
+standalone Rust binary exercised by a fake caller; the CLI remains unchanged.
+
+| Action/property | Source transition | Regression boundary |
+| --- | --- | --- |
+| `Reserve`, `Publish`, `Spawn`; `DurableBeforeLaunch` | Per-command flock, guard session creation, synced identity before argv spawn | `starts_and_records_identity`; refused publication/spawn cannot run a later command |
+| `SupervisorDeath`; `GuardRetainsLock` | Resident guard retains flock and stdout/stderr readers after project supervisor loss | `supervisor_kill_then_recover_stop`; output continues after supervisor SIGKILL |
+| `GuardDeath`, `Retry`; `NoDuplicate` | Retained session census refuses a retry even if separately killed guard released its flock | Guard-loss retry and identity refusal regression |
+| `Stop`, `Kill`; `NoForeignSignal` | Exact owner/boot/start identity before TERM and KILL; guard remains outside command groups | Wrong-owner/stale-identity refusal; TERM-ignore escalation |
+| `ProcessExit`, `Reap` | Session census ignores zombies; reap direct/adopted children; release lock after no live members | Zombie liveness; child changes process group within session |
+| `StopUnknown` | Bounded confirmation can report uncertainty while live members remain | Honest unknown recovery outcome, never successful absence |
+
+Positive TLC exploration: 129 distinct states, queue exhausted, depth 11. Negative
+control sets `SkipLiveFence = TRUE`; `Retry` then violates `NoDuplicate` with live,
+durable and duplicate all true in the same state. Both guard-loss and project-loss
+traces can witness this violation; the registry requires the named action and
+same-state fields without depending on worker scheduling.
+
+Checker: checksum-pinned TLA+ 1.7.4 artifact (TLC reports version 2.19), Java 17,
+two workers and a bounded heap. The maintained runner pins the positive count and
+validates the negative invariant/action/witness. Apalache is not qualified here.
+
+Assumptions: flock serialization, atomic/synchronized publication, reliable native
+identity and session census, and rechecking identity before effects. Signal delivery
+does not imply process exit: `KillPending` can retain live processes, and
+`StopUnknown` is distinct from `Exited`. No fairness or eventual exit is assumed.
+An identity change blocks signaling. A separately killed guard leaves an orphaned
+session; it never grants new launch authority simply because its lock was released.
+
+Omissions: actual filesystem durability, command contents, multi-command rollback,
+pipe buffers, descriptor creation races, scheduler latency, resource use, power loss,
+PID reuse between observation and signaling, hostile same-user mutation, and
+commands escaping their session with `setsid`. Those remain native implementation
+or explicitly unsupported boundaries. This finite model does not qualify a shipped
+artifact, macOS behavior, CLI integration or the default runner.
diff --git a/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla b/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla
new file mode 100644
index 000000000..0fc117f65
--- /dev/null
+++ b/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla
@@ -0,0 +1,85 @@
+------------------------ MODULE LifecycleSupervisor ------------------------
+EXTENDS Naturals
+CONSTANT SkipLiveFence
+VARIABLES phase, durable, guard, locked, live, supervisor, attempts,
+ identity, unsafeSignal, duplicate
+vars == <>
+
+Init == /\ phase = "Absent"
+ /\ durable = FALSE /\ guard = FALSE /\ locked = FALSE
+ /\ live = FALSE /\ supervisor = FALSE /\ attempts = 0
+ /\ identity = "match" /\ unsafeSignal = FALSE /\ duplicate = FALSE
+
+Reserve == /\ phase \in {"Absent", "Exited"} /\ ~live /\ attempts < 2
+ /\ phase' = "Starting" /\ guard' = TRUE /\ locked' = TRUE
+ /\ supervisor' = TRUE /\ durable' = FALSE
+ /\ attempts' = attempts + 1
+ /\ UNCHANGED <>
+Publish == /\ phase = "Starting" /\ ~durable
+ /\ durable' = TRUE
+ /\ UNCHANGED <>
+Spawn == /\ phase = "Starting" /\ durable /\ guard /\ locked
+ /\ phase' = "Running" /\ live' = TRUE
+ /\ UNCHANGED <>
+StartFailure == /\ phase = "Starting"
+ /\ phase' = "Exited" /\ guard' = FALSE /\ locked' = FALSE
+ /\ supervisor' = FALSE
+ /\ UNCHANGED <>
+SupervisorDeath == /\ supervisor /\ phase = "Running"
+ /\ supervisor' = FALSE /\ phase' = "Recoverable"
+ /\ UNCHANGED <>
+GuardDeath == /\ guard /\ live
+ /\ guard' = FALSE /\ locked' = FALSE
+ /\ phase' = "Orphaned" /\ supervisor' = FALSE
+ /\ UNCHANGED <>
+Retry == /\ ~supervisor /\ live /\ attempts < 2
+ /\ SkipLiveFence
+ /\ duplicate' = TRUE /\ attempts' = attempts + 1
+ /\ UNCHANGED <>
+ReplaceIdentity == /\ identity = "match" /\ durable
+ /\ identity' \in {"foreign", "unknown"}
+ /\ UNCHANGED <>
+Stop == /\ phase \in {"Running", "Recoverable"}
+ /\ guard /\ durable /\ identity = "match"
+ /\ phase' = "Stopping"
+ /\ unsafeSignal' = unsafeSignal \/ identity # "match"
+ /\ UNCHANGED <>
+Kill == /\ phase = "Stopping" /\ guard /\ identity = "match"
+ /\ phase' = "KillPending"
+ /\ unsafeSignal' = unsafeSignal \/ identity # "match"
+ /\ UNCHANGED <>
+StopUnknown == /\ phase \in {"Stopping", "KillPending"} /\ live
+ /\ phase' = "StopUnknown"
+ /\ UNCHANGED <>
+ProcessExit == /\ live /\ live' = FALSE
+ /\ UNCHANGED <>
+Reap == /\ ~live /\ phase \notin {"Absent", "Starting", "Exited"}
+ /\ phase' = "Exited" /\ guard' = FALSE /\ locked' = FALSE
+ /\ supervisor' = FALSE
+ /\ UNCHANGED <>
+Next == Reserve \/ Publish \/ Spawn \/ StartFailure \/ SupervisorDeath
+ \/ GuardDeath \/ Retry \/ ReplaceIdentity \/ Stop \/ Kill
+ \/ StopUnknown \/ ProcessExit \/ Reap
+Spec == Init /\ [][Next]_vars
+TypeOK == /\ phase \in {"Absent", "Starting", "Running", "Recoverable",
+ "Orphaned", "Stopping", "KillPending", "StopUnknown", "Exited"}
+ /\ attempts \in 0..2 /\ identity \in {"match", "foreign", "unknown"}
+ /\ \A b \in {durable, guard, locked, live, supervisor,
+ unsafeSignal, duplicate} : b \in BOOLEAN
+DurableBeforeLaunch == live => durable
+GuardRetainsLock == guard => locked
+NoForeignSignal == ~unsafeSignal
+NoDuplicate == ~duplicate
+=============================================================================
diff --git a/tests/models/tla/lifecycle-supervisor/negative.cfg b/tests/models/tla/lifecycle-supervisor/negative.cfg
new file mode 100644
index 000000000..a53d6e384
--- /dev/null
+++ b/tests/models/tla/lifecycle-supervisor/negative.cfg
@@ -0,0 +1,4 @@
+SPECIFICATION Spec
+CONSTANT SkipLiveFence = TRUE
+INVARIANTS TypeOK DurableBeforeLaunch GuardRetainsLock NoForeignSignal NoDuplicate
+CHECK_DEADLOCK FALSE
diff --git a/tests/models/tla/lifecycle-supervisor/positive.cfg b/tests/models/tla/lifecycle-supervisor/positive.cfg
new file mode 100644
index 000000000..58bdd8c68
--- /dev/null
+++ b/tests/models/tla/lifecycle-supervisor/positive.cfg
@@ -0,0 +1,4 @@
+SPECIFICATION Spec
+CONSTANT SkipLiveFence = FALSE
+INVARIANTS TypeOK DurableBeforeLaunch GuardRetainsLock NoForeignSignal NoDuplicate
+CHECK_DEADLOCK FALSE
From 6a57cdb009d9996a5b1362fdcad8826f10cc9d07 Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 20:16:43 -0500
Subject: [PATCH 02/55] fix(lifecycle): pass macOS supervisor lint without
redundant cast
---
packages/lifecycle-supervisor/src/os.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs
index 98bc95f9d..7ec5c168e 100644
--- a/packages/lifecycle-supervisor/src/os.rs
+++ b/packages/lifecycle-supervisor/src/os.rs
@@ -161,7 +161,7 @@ pub fn inspect(pid: i32) -> Result> {
.and_then(|v| v.checked_add(info.pbi_start_tvusec))
.ok_or_else(refused)?,
uid: info.pbi_uid,
- zombie: info.pbi_status == libc::SZOMB as u32,
+ zombie: info.pbi_status == libc::SZOMB,
}))
}
From 12e8976d46048834b6d27bab73ba2ec48c4ae3ee Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 20:23:33 -0500
Subject: [PATCH 03/55] feat(lifecycle): opt into native supervisor for host
processes
---
.github/workflows/lifecycle-supervisor.yml | 20 +
docs/diagrams/lifecycle-controller.mmd | 31 ++
docs/diagrams/lifecycle-controller.svg | 66 +++
docs/lifecycle-supervisor.md | 76 ++-
docs/lifecycle.md | 20 +-
docs/reference/v5-model-coverage.md | 2 +
docs/versions/v5/getting-started.md | 7 +
packages/lifecycle-supervisor/README.md | 12 +-
.../lifecycle-supervisor/src/incremental.rs | 314 +++++++++++
packages/lifecycle-supervisor/src/main.rs | 1 +
packages/lifecycle-supervisor/src/os.rs | 60 +-
packages/lifecycle-supervisor/src/protocol.rs | 25 +
packages/lifecycle-supervisor/src/storage.rs | 18 +-
.../lifecycle-supervisor/src/supervisor.rs | 220 ++++++--
.../lifecycle-supervisor/tests/incremental.rs | 511 ++++++++++++++++++
scripts/lib/tla-runtime-models.ts | 28 +
.../native-authored-host-processes.ts | 6 +
src/commands/host-ps.ts | 73 ++-
src/commands/project.ts | 25 +-
src/lib/lifecycle-runner.ts | 35 ++
src/lib/lifecycle-runtime.ts | 260 ++++++---
src/lib/lifecycle-supervisor.ts | 392 ++++++++++++++
src/lib/project-lifecycle-controller.ts | 68 ++-
src/lib/project-lifecycle-hygiene.ts | 229 +++++++-
src/lib/project-lifecycle-supervisor.ts | 420 ++++++++++++++
src/lib/project.ts | 21 +
src/lib/runtime-projects.ts | 88 ++-
src/templates.ts | 7 +
tests/e2e/scenarios/lifecycle-host-process.ts | 251 ++++++++-
.../scenarios/lifecycle-session-recovery.ts | 291 +++++++++-
tests/lifecycle-runner-config.test.ts | 169 ++++++
tests/lifecycle-state-publication.test.ts | 255 +++++++++
tests/lifecycle-supervisor-controller.test.ts | 313 +++++++++++
tests/lifecycle-supervisor-observers.test.ts | 333 ++++++++++++
.../tla/lifecycle-controller/COVERAGE.md | 49 ++
.../LifecycleController.tla | 79 +++
.../tla/lifecycle-controller/negative.cfg | 4 +
.../tla/lifecycle-controller/pending.cfg | 4 +
.../tla/lifecycle-controller/positive.cfg | 4 +
39 files changed, 4595 insertions(+), 192 deletions(-)
create mode 100644 docs/diagrams/lifecycle-controller.mmd
create mode 100644 docs/diagrams/lifecycle-controller.svg
create mode 100644 packages/lifecycle-supervisor/src/incremental.rs
create mode 100644 packages/lifecycle-supervisor/tests/incremental.rs
create mode 100644 src/lib/lifecycle-runner.ts
create mode 100644 src/lib/lifecycle-supervisor.ts
create mode 100644 src/lib/project-lifecycle-supervisor.ts
create mode 100644 tests/lifecycle-runner-config.test.ts
create mode 100644 tests/lifecycle-state-publication.test.ts
create mode 100644 tests/lifecycle-supervisor-controller.test.ts
create mode 100644 tests/lifecycle-supervisor-observers.test.ts
create mode 100644 tests/models/tla/lifecycle-controller/COVERAGE.md
create mode 100644 tests/models/tla/lifecycle-controller/LifecycleController.tla
create mode 100644 tests/models/tla/lifecycle-controller/negative.cfg
create mode 100644 tests/models/tla/lifecycle-controller/pending.cfg
create mode 100644 tests/models/tla/lifecycle-controller/positive.cfg
diff --git a/.github/workflows/lifecycle-supervisor.yml b/.github/workflows/lifecycle-supervisor.yml
index 54bd02643..d11a61299 100644
--- a/.github/workflows/lifecycle-supervisor.yml
+++ b/.github/workflows/lifecycle-supervisor.yml
@@ -4,11 +4,21 @@ on:
pull_request:
paths:
- packages/lifecycle-supervisor/**
+ - src/lib/lifecycle-supervisor.ts
+ - src/lib/project-lifecycle-supervisor.ts
+ - src/lib/project-lifecycle-controller.ts
+ - src/lib/lifecycle-runtime.ts
+ - tests/lifecycle-supervisor-controller.test.ts
- .github/workflows/lifecycle-supervisor.yml
push:
branches: [next, main]
paths:
- packages/lifecycle-supervisor/**
+ - src/lib/lifecycle-supervisor.ts
+ - src/lib/project-lifecycle-supervisor.ts
+ - src/lib/project-lifecycle-controller.ts
+ - src/lib/lifecycle-runtime.ts
+ - tests/lifecycle-supervisor-controller.test.ts
- .github/workflows/lifecycle-supervisor.yml
workflow_dispatch:
@@ -44,3 +54,13 @@ jobs:
run: cargo test --locked --manifest-path packages/lifecycle-supervisor/Cargo.toml -- --test-threads=4
- name: Build standalone release executable
run: cargo build --locked --release --manifest-path packages/lifecycle-supervisor/Cargo.toml
+ - uses: oven-sh/setup-bun@v1
+ with:
+ bun-version: "1.4.4"
+ - name: Install locked CLI test dependencies
+ run: bun install --frozen-lockfile
+ - name: Test current-source CLI controller against native supervisor
+ env:
+ HACK_TEST_REQUIRE_SUPERVISOR: '1'
+ HACK_TEST_SUPERVISOR_BINARY: ${{ env.CARGO_TARGET_DIR }}/debug/hack-lifecycle-supervisor
+ run: bun test tests/lifecycle-supervisor-controller.test.ts tests/lifecycle-runner-config.test.ts tests/lifecycle-state-publication.test.ts tests/lifecycle-supervisor-observers.test.ts
diff --git a/docs/diagrams/lifecycle-controller.mmd b/docs/diagrams/lifecycle-controller.mmd
new file mode 100644
index 000000000..c072b5006
--- /dev/null
+++ b/docs/diagrams/lifecycle-controller.mmd
@@ -0,0 +1,31 @@
+---
+title: Lifecycle controller · launch receipts and adoption
+---
+stateDiagram-v2
+ direction TB
+ [*] --> Idle
+ Idle --> Starting : Begin
+ Starting --> AwaitReply : Launch [pending published]
+ AwaitReply --> Ready : Accept
+ AwaitReply --> InspectLate : LoseReply
+ InspectLate --> LatePublished : PublishLate [native live]
+ LatePublished --> Failed : FailUnknown
+ Ready --> Adopted : Adopt [native live]
+ Failed --> Adopted : Adopt [native live]
+ Adopted --> AwaitReply : Add [not stopped]
+ Ready --> Aborted : Abort [epoch matches]
+ Failed --> Aborted : Abort [epoch matches]
+ Ready --> Stopped : Stop [absence confirmed]
+ Adopted --> Stopped : Stop [absence confirmed]
+ Failed --> Stopped : Stop [absence confirmed]
+ note right of Starting
+ INV ownership precedes launch
+ end note
+ note right of Adopted
+ INV old cleanup cannot stop
+ Token stable, epoch changes
+ end note
+ note right of Failed
+ Unknown is not absence
+ Verified late effect retained
+ end note
diff --git a/docs/diagrams/lifecycle-controller.svg b/docs/diagrams/lifecycle-controller.svg
new file mode 100644
index 000000000..ad958fdee
--- /dev/null
+++ b/docs/diagrams/lifecycle-controller.svg
@@ -0,0 +1,66 @@
+Lifecycle controller · launch receipts and adoption Begin Launch [pending published] Accept LoseReply PublishLate [native live] FailUnknown Adopt [native live] Adopt [native live] Add [not stopped] Abort [epoch matches] Abort [epoch matches] Stop [absence confirmed] Stop [absence confirmed] Stop [absence confirmed] Idle AwaitReply Ready InspectLate LatePublished Aborted Stopped Starting Failed Adopted INV ownership precedes launch Unknown is not absence Verified late effect retained INV old cleanup cannot stop Token stable, epoch changes
\ No newline at end of file
diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md
index f9158d940..24a426ce9 100644
--- a/docs/lifecycle-supervisor.md
+++ b/docs/lifecycle-supervisor.md
@@ -1,9 +1,11 @@
-# Lifecycle supervisor: standalone contract
+# Lifecycle supervisor contract
-This first slice builds a standalone Rust supervisor with a fake caller. It does
-not change `hack up`, configuration, or the default runner. CLI integration follows
-lifecycle retirement changes; packaging is a separate change. The current default
-continues to use the lifecycle mux session described in [lifecycle](./lifecycle.md).
+The built-in Rust supervisor is an experimental opt-in for host lifecycle
+processes. `lifecycle.runner: "supervisor"` selects it; omission keeps the mux
+runner described in [lifecycle](./lifecycle.md). Its standalone process contracts
+and CLI integration are tested separately. Distribution packaging is a separate
+change: opt-in requires a matching supervisor executable alongside Hack or an
+explicit test executable path.
## Artifact choice
@@ -19,7 +21,7 @@ The supported build targets are `aarch64-apple-darwin`, `x86_64-apple-darwin`, a
## Protocol and private state
-The binary accepts `run`, `stop`, and `status`. Input and output are one four-byte
+The binary accepts `run`, `serve`, `add`, `stop`, and `status`. Input and output are one four-byte
big-endian length followed by that many JSON bytes. Frames are bounded to 1 MiB;
each frame read or write has one 10-second deadline. A complete frame, rather than descriptor close,
commits delivery. Environment values travel only in the input frame and process
@@ -33,6 +35,11 @@ absolute `cwd`, and `env`. Names are safe file identifiers and must also be uniq
without regard to ASCII case, including on case-sensitive filesystems. Commands execute argv
directly. A shell-authored command can explicitly use `sh -c`; the supervisor adds
no shell wrapper. The full environment is explicit, with no ambient inheritance.
+An optional `displayName` retains the user's service label while `name` stays a
+safe native file key. CLI callers use stable hashed keys so labels containing
+spaces or separators cannot change the native directory layout. Optional
+`logPath` selects the existing private lifecycle log; its parent and file must
+pass the same owner, mode and no-alias checks as the native root.
`stop` and `status` accept `{version: 1, root, ownerToken}`. Read-only `status`
returns persisted receipts; a receipt is not a health check. It creates no files,
@@ -50,8 +57,9 @@ Replies contain `version`, `supervisorPid`, `processes`, and `outcome` (`ready`,
the supervisor calls `setsid` itself. A caller using `detached: true` or
`setpgid(0, 0)` is refused before authored code starts.
-The root contains per-command state and lock files, an instance lock and
-`lifecycle.log`. Directories are mode 0700; files are mode 0600. Symlinks,
+The root contains per-command state and lock files, an instance lock and,
+for incremental launches, `control.sock`. The default log is `lifecycle.log`.
+Directories are mode 0700; files and the control socket are mode 0600. Symlinks,
hard-linked files, foreign owners and broad permissions are refused. State contains
name, ownership token, guard PID, process group, OS start identity, boot identity,
UID, command PID when known, phase, and the direct command's exit code or signal
@@ -138,7 +146,7 @@ Neither feature is part of this unit; interactive workspaces keep tmux by defaul
## Delivery gates
-The CLI opt-in is planned as `lifecycle.runner = "mux" | "supervisor"`, default
+The CLI opt-in is `lifecycle.runner = "mux" | "supervisor"`, default
`mux`, plus a test environment override. Both runners must pass existing lifecycle
unit and e2e scenarios before that integration is ready. Packaging must include the
binary wherever the Compose CLI installs: stable archives, prerelease bundles,
@@ -167,3 +175,53 @@ Default migration removes the Python/FIFO wrapper in
POSIX `sh -c` intentionally. Interactive tmux environment delivery stays separate.
Zellij removal, its breaking-change notice, and the workspace-only `MuxBackend`
contract belong to the third unit.
+
+## Opt-in CLI integration
+
+
+
+The [controller model coverage](../tests/models/tla/lifecycle-controller/COVERAGE.md)
+maps the finite ownership model to implementation tests, separately from the
+physical-process model above.
+
+The integration slice adds `lifecycle.runner: "supervisor"`; omission remains
+`"mux"`. `HACK_LIFECYCLE_RUNNER=mux|supervisor` overrides the project setting for
+verification. Invalid values fail before hooks or runtime effects. The executable
+must be installed beside Hack, or selected by an absolute
+`HACK_LIFECYCLE_SUPERVISOR_BINARY` test path. An unavailable executable is a refusal,
+not a fallback to another runner. Packaging remains a separate delivery gate.
+
+Persistent before hooks start immediately, in their configured order between
+blocking hooks. The native `serve` operation starts the first command and listens
+on a private Unix socket. Finite `add` clients forward later framed launch requests
+to that same project owner. Environment stays in the frame and process memory.
+The socket uses a relative address under an opened private directory, avoiding
+macOS pathname-length limits. Every handoff has an explicit deadline.
+
+The common lifecycle state records the native owner token and command identities;
+logs remain at the existing per-instance log path, so `hack logs -f ` watches
+them. Processes do not appear in tmux. Interactive `hack session` behavior is
+unchanged. Stop uses the persisted backend even if configuration changes later.
+Both runners use a five-second TERM grace, KILL escalation, and a bounded
+confirmation window; zombies do not count as running. Unknown outcomes keep
+ownership and shared helper holders for another `hack down` attempt.
+
+A healthy matching definition can be adopted. A definition whose recorded command
+has exited is replaced as a whole, including live siblings, matching the mux
+controller's healthy-window requirement. Adoption records a new cleanup generation
+without changing the native token, so an earlier start's rollback cannot stop
+adopted work. CLI publication and native effects share an instance lock; the common
+state file has a separate project-wide writer lock and atomic private publication.
+
+A failed incremental handoff is reconciled with native status. If the requested
+command became live after the reply was lost, its verified receipt is published
+and the failure remains explicit. It is never launched a second time. A new owner
+is retried only after native status proves the earlier commands absent. A missing
+native root with no possible state is absent; unreadable, malformed or unverifiable
+state remains unknown. Native status reports live and unknown names using kernel
+identity, not a persisted `running` string. Doctor and host status use those fields;
+missing tools and unknown identities cannot authorize repair or state removal.
+
+Sending TERM to the project owner invokes its owned rollback path; this is not a
+finite protocol request and has no response frame. Use the finite `stop` operation
+when a confirmation receipt is needed. Logs currently have no automatic rotation.
diff --git a/docs/lifecycle.md b/docs/lifecycle.md
index dadee3527..b3a5551b8 100644
--- a/docs/lifecycle.md
+++ b/docs/lifecycle.md
@@ -4,9 +4,8 @@ Many projects need host-side setup around `hack up` (auth, local proxies, tunnel
Lifecycle is configured in `.hack/hack.config.json` under `lifecycle`.
-The [standalone Rust supervisor contract](./lifecycle-supervisor.md) describes
-the separately tested supervisor work. It does not change the current mux runner
-or expose a new configuration setting in this slice.
+The [Rust supervisor contract](./lifecycle-supervisor.md) describes the experimental
+`lifecycle.runner: "supervisor"` opt-in. Omission keeps the current mux runner.
With the Compose backend, the first Ctrl-C during foreground `hack up` asks
Compose to stop the project containers. Hack waits for Compose to finish stopping
@@ -626,3 +625,18 @@ Notes:
`--no-interactive` convention used across the CLI (documented defaults or `E_INTERACTIVE_REQUIRED`).
It does not change hook `stdin` behavior — a hook that itself blocks on interactive auth still
blocks under `--no-interactive`.
+
+## Experimental built-in runner
+
+Set `lifecycle.runner` to `"supervisor"` to use the built-in Rust host-process
+owner; the default remains `"mux"`. The supervisor executable must be available
+beside Hack. The [supervisor contract](./lifecycle-supervisor.md) records delivery
+and platform qualification. This opt-in does not provide a PTY: stdin is null,
+stdout/stderr are pipes, and commands requiring a terminal must use a
+noninteractive mode or an interactive workspace.
+
+Supervisor lifecycle processes do not appear as tmux sessions. Watch their output
+with `hack logs -f `. Stop and restart use the recorded owner even after the
+runner setting changes. Unknown shutdown retains the ownership record and any
+shared helper holders, and fails so `hack down` can be retried. Shared helper
+commands themselves keep their existing ownership contract in this unit.
diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md
index a43eaae62..af8596f01 100644
--- a/docs/reference/v5-model-coverage.md
+++ b/docs/reference/v5-model-coverage.md
@@ -37,6 +37,7 @@ must be attached to the eventual exact integration revision.
| Property and critical states | Model coverage | Implementation/regression boundary | Remaining qualification |
| --- | --- | --- | --- |
| Standalone lifecycle supervisor: durable pre-launch identity, retained guard lock, project-supervisor loss, separately lost guard, verified stop and unknown stop | [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/COVERAGE.md) | `packages/lifecycle-supervisor` and its fake-caller contract tests; no CLI integration or default change in this slice | One command name, at most two starts, matching/foreign/unknown identities, arbitrary process exit and supervisor/guard loss; 129 states. Negative control removes the live-session retry fence and violates `NoDuplicate`. Native PID identity, filesystem synchronization, descriptors, output and macOS effects require implementation tests. |
+| Opt-in lifecycle controller: pending ownership before launch, stable native owner across adoption, cleanup epochs, publication after a lost reply and serialized add/stop | [lifecycle-controller](../../tests/models/tla/lifecycle-controller/COVERAGE.md) | `project-lifecycle-supervisor.ts`, `lifecycle-supervisor.ts`, common lifecycle state publication and controller/incremental regressions | One instance, one creator plus one adopter, one initial launch plus one incremental add; 21 states. Negative controls remove the cleanup epoch fence (`NoAdoptedStop`) and pending publication fence (`DurableBeforeLaunch`). Atomic lock regions and trustworthy native readback are assumptions; process identity, sockets, cancellation latency and OS effects remain implementation tests. |
| Native status across startup lease stages; failed start with running services (HACK-2110) | [native-status-start](../../tests/models/tla/native-status-start/COVERAGE.md) | Observer-only presentation, stable journal fence, distinct authored Observe/Status replies; real-flock held-reader start test and expiry diagnostic; plain-down exact-proof recovery tests. Failure capture occurs at startup/effect verification and owned cleanup, never status; first failure wins, incomplete-rebind capture and owner-crash reconstruction are excluded | Seven acquisition stages, one reader, one start; 110 states. Negative control fails after services start. Second-mutator refusal and complete recovery proof are assumptions checked separately in code, not independently proved by this model. No new control-loop tick or lease; cleanup settle is bounded at ten seconds per exited listener. Lead Mac gate: ps every 0.5 s across ten starts, cross-project polling and failed-start plain down. |
| Guest control ready before exposed bridge sockets bind; identity changes or the startup deadline expires | No new TLA+ model; deterministic clock schedules both readiness orders against real Unix socket nodes | `provider/bridge.rs` waits only in fresh `finish_boot`, before adoption, and keeps running audits immediate. `bridge_startup_tests.rs` covers two slots, late creation and mode tightening, permanent absence/unsafe modes, type/UID/link refusals, provider loss before/after observation and late success rejection. No wait mutates sockets or grants cleanup authority; the existing verified-provider stop handles failure. `lifecycle/boot_sequence_tests.rs` drives the real waiter through the production fresh-boot sequence and rejects audit/adoption on timeout, unsafe sockets or provider loss. `native-runtime-client.test.ts` checks the fixed runtime recover command and identical-root hint reach the frontend | Two sockets, finite scheduled events, injected provider verification and monotonic clock. Kernel process identity, provider scheduling, disk durability and successful recovery remain host boundaries. Require ten consecutive real-VM Mac Ctrl-C scenarios with pool stop/start; portable tests are not that evidence. |
| CI cancellation must not lose merged-change coverage; only exact-head full evidence may qualify a prerelease | No maintained GitHub scheduler model. Finite fixture interleavings cover predecessor success/failure/cancellation, two competing full/scoped runs and two rerun attempts | `ci.yml` serializes next pushes in one non-cancelling group (GitHub cancels only the replaced pending run), cancels stale PR runs, and requires a successful before run for selection. `selectPrereleaseCiRun` reads current attempts; `requireBranchChecks` binds suite and job check IDs. `ci-trusted-policy`, `prerelease-plan` and `release-prepare-gate` regressions reject cancelled predecessors, green subsets, stale checks, skipped hosts and changed attempts | API snapshots and GitHub group/event semantics are assumptions; races after the last read remain possible. Publication rechecks the frozen protected head and full evidence but does not transactionally lock GitHub. Hosted dispatch/cancellation checks qualify wiring; no scheduler liveness or formal refinement claim. |
@@ -82,6 +83,7 @@ liveness/reachability controls have their own documented assumptions.
| Model | Positive distinct states | Configurations |
| --- | ---: | ---: |
| [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla) | 129 | 2 |
+| [lifecycle-controller](../../tests/models/tla/lifecycle-controller/LifecycleController.tla) | 21 | 2 |
| [native-status-start](../../tests/models/tla/native-status-start/StatusStart.tla) | 110 | 2 |
| [native-publisher-stop](../../tests/models/tla/native-publisher-stop/PublisherStop.tla) | 168 | 7 |
| [shutdown-rebase](../../tests/models/tla/shutdown-rebase/ShutdownRebase.tla) | 43 | 3 |
diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md
index 52efb8442..deb2e647b 100644
--- a/docs/versions/v5/getting-started.md
+++ b/docs/versions/v5/getting-started.md
@@ -106,3 +106,10 @@ This stops the project. It does not delete its named volumes or stop shared
routing services. See [Run and inspect](guides/run-project.md) for service selection,
log history and restarts. When this Compose run works, try native only on a
[disposable project](native/start.md).
+
+Lifecycle host processes can opt into `lifecycle.runner: "supervisor"` when the
+Rust supervisor executable is installed alongside the CLI. The default stays
+`"mux"`. This mode has no terminal: use noninteractive commands and
+`hack logs -f ` to watch output. See the
+[lifecycle runner implementation contract](https://github.com/hack-dance/hack/blob/next/docs/lifecycle-supervisor.md) for prerequisites and
+recovery behavior.
diff --git a/packages/lifecycle-supervisor/README.md b/packages/lifecycle-supervisor/README.md
index 3fc78f6d8..20f8d4124 100644
--- a/packages/lifecycle-supervisor/README.md
+++ b/packages/lifecycle-supervisor/README.md
@@ -5,8 +5,10 @@ owner for Linux and macOS. It has no dependency on runtime-core, a terminal
multiplexer, Python, or a shell wrapper. Authored shell commands can still
explicitly select a shell in their argv.
-The first slice is exercised by a fake caller. It does not select a new CLI runner
-or install the executable. See [the protocol, state model and delivery gates](../../docs/lifecycle-supervisor.md).
+The CLI can select this owner with `lifecycle.runner: "supervisor"`; the default
+remains `"mux"`. Fake-caller contracts and the real CLI integration have separate
+regressions. Distribution packaging remains a separate change. See
+[the protocol, state model and delivery gates](../../docs/lifecycle-supervisor.md).
Build and verify from the repository root with the pinned toolchain:
@@ -23,9 +25,11 @@ processes and cleans them up. CI runs the same crate on Linux x86_64, macOS arm6
and macOS x86_64. A cross-target `cargo check` checks compilation only; it does not
qualify platform signal, process identity, output or recovery behavior.
-`run` must be spawned as a fresh child without the caller making it a process-group
+`run` and `serve` must be spawned as fresh children without the caller making them process-group
leader. The supervisor calls `setsid` itself and detaches its standard descriptors
-after the framed ready receipt. `stop` and `status` are finite framed requests.
+after the framed ready receipt. `serve` accepts later `add` requests through a
+private local socket, preserving persistent-hook ordering. `add`, `stop`, and
+`status` are finite framed requests.
Frames, not pipe closure, delimit delivery. The per-command guard publishes kernel
identity before executing authored argv and retains ownership after the project
supervisor dies. A killed guard with surviving session members is an unknown
diff --git a/packages/lifecycle-supervisor/src/incremental.rs b/packages/lifecycle-supervisor/src/incremental.rs
new file mode 100644
index 000000000..c8abf596b
--- /dev/null
+++ b/packages/lifecycle-supervisor/src/incremental.rs
@@ -0,0 +1,314 @@
+//! Incremental launch transport. Only fresh internal processes change cwd, using
+//! the opened private root as the Unix socket namespace. Frames and deadlines,
+//! rather than descriptor closure, delimit every request and reply.
+use crate::{
+ Result, os,
+ protocol::{self, Launch},
+ refused,
+ storage::Root,
+ supervisor::{self, Receipt},
+};
+use std::{
+ fs,
+ os::fd::{AsRawFd, FromRawFd, OwnedFd},
+ os::unix::{
+ fs::{FileTypeExt, MetadataExt, PermissionsExt},
+ net::{UnixListener, UnixStream},
+ },
+ process::Child,
+ sync::atomic::Ordering,
+ thread,
+ time::Duration,
+};
+
+const SOCKET: &str = "control.sock";
+
+struct SocketOwner {
+ device: u64,
+ inode: u64,
+}
+
+impl Drop for SocketOwner {
+ fn drop(&mut self) {
+ if let Ok(metadata) = checked_socket()
+ && metadata.dev() == self.device
+ && metadata.ino() == self.inode
+ {
+ let _ = fs::remove_file(SOCKET);
+ }
+ }
+}
+
+fn checked_socket() -> Result {
+ let metadata = fs::symlink_metadata(SOCKET)?;
+ // SAFETY: geteuid only observes this process's effective owner.
+ if !metadata.file_type().is_socket()
+ || metadata.uid() != unsafe { libc::geteuid() }
+ || metadata.mode() & 0o777 != 0o600
+ {
+ return Err(refused());
+ }
+ Ok(metadata)
+}
+
+fn bind() -> Result<(UnixListener, SocketOwner)> {
+ match checked_socket() {
+ Ok(_) => fs::remove_file(SOCKET)?,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
+ Err(error) => return Err(error),
+ }
+ let listener = UnixListener::bind(SOCKET)?;
+ fs::set_permissions(SOCKET, fs::Permissions::from_mode(0o600))?;
+ let metadata = checked_socket()?;
+ listener.set_nonblocking(true)?;
+ Ok((
+ listener,
+ SocketOwner {
+ device: metadata.dev(),
+ inode: metadata.ino(),
+ },
+ ))
+}
+
+pub(crate) fn add(request: Launch) -> Result<()> {
+ supervisor::validate_launch(&request)?;
+ if request.processes.len() != 1 {
+ return Err(refused());
+ }
+ let root = Root::open(&request.root)?;
+ root.enter()?;
+ let metadata = checked_socket()?;
+ let mut stream = connect()?;
+ let current = checked_socket()?;
+ if current.dev() != metadata.dev() || current.ino() != metadata.ino() {
+ return Err(refused());
+ }
+ protocol::write(&mut stream, &request)?;
+ let receipt: serde_json::Value = protocol::read(&mut stream)?;
+ if receipt["version"] != 1 || !receipt["processes"].is_array() {
+ return Err(refused());
+ }
+ protocol::write(&mut std::io::stdout(), &receipt)?;
+ if receipt["outcome"] != "ready" {
+ return Err(refused());
+ }
+ Ok(())
+}
+
+fn connect() -> Result {
+ // Darwin lacks SOCK_CLOEXEC. Set both flags immediately after socket and
+ // use an explicit frame/deadline; no liveness relies on descriptor closure.
+ // SAFETY: socket returns a fresh owned descriptor with no retained pointer.
+ let raw = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
+ if raw < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ // SAFETY: the new descriptor has exactly one Rust owner.
+ let fd = unsafe { OwnedFd::from_raw_fd(raw) };
+ // SAFETY: integer descriptor flags are set on the live owned socket.
+ if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ os::nonblocking(&fd)?;
+ // SAFETY: zeroed sockaddr_un is initialized below and passed at exact size.
+ let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() };
+ address.sun_family = libc::AF_UNIX as libc::sa_family_t;
+ for (target, byte) in address.sun_path.iter_mut().zip(SOCKET.bytes()) {
+ *target = byte as libc::c_char;
+ }
+ #[cfg(target_os = "macos")]
+ {
+ address.sun_len = std::mem::size_of_val(&address) as u8;
+ }
+ // SAFETY: address is initialized, NUL-terminated and live for the supplied size.
+ if unsafe {
+ libc::connect(
+ fd.as_raw_fd(),
+ (&address as *const libc::sockaddr_un).cast(),
+ std::mem::size_of_val(&address) as libc::socklen_t,
+ )
+ } != 0
+ {
+ let error = std::io::Error::last_os_error();
+ if !matches!(
+ error.raw_os_error(),
+ Some(libc::EINPROGRESS) | Some(libc::EAGAIN)
+ ) {
+ return Err(error);
+ }
+ let mut poll = libc::pollfd {
+ fd: fd.as_raw_fd(),
+ events: libc::POLLOUT,
+ revents: 0,
+ };
+ // SAFETY: one initialized descriptor record is borrowed for the bounded wait.
+ if unsafe { libc::poll(&mut poll, 1, protocol::DEADLINE.as_millis() as i32) } <= 0 {
+ return Err(refused());
+ }
+ let mut result: libc::c_int = 0;
+ let mut length = std::mem::size_of_val(&result) as libc::socklen_t;
+ // SAFETY: getsockopt writes one live bounded integer and length.
+ if unsafe {
+ libc::getsockopt(
+ fd.as_raw_fd(),
+ libc::SOL_SOCKET,
+ libc::SO_ERROR,
+ (&mut result as *mut libc::c_int).cast(),
+ &mut length,
+ )
+ } < 0
+ {
+ return Err(std::io::Error::last_os_error());
+ }
+ if result != 0 {
+ return Err(std::io::Error::from_raw_os_error(result));
+ }
+ }
+ Ok(UnixStream::from(fd))
+}
+
+pub(crate) fn serve(request: Launch) -> Result<()> {
+ supervisor::validate_launch(&request)?;
+ let root = Root::open(&request.root)?;
+ let _instance_lock = root.lock(".instance.lock")?;
+ let launch_lock = root.lock_wait(".stop.lock")?;
+ for state in root.states()? {
+ if os::recorded_live(&state, false)? {
+ return Err(refused());
+ }
+ }
+ os::session()?;
+ os::signals()?;
+ root.enter()?;
+ let (listener, _socket_owner) = bind()?;
+ let mut children = Vec::new();
+ let outcome = (|| {
+ for process in request.processes {
+ supervisor::start_guard(
+ &request.root,
+ &request.owner_token,
+ request.log_path.as_deref(),
+ process,
+ &mut children,
+ )?;
+ }
+ protocol::write(
+ &mut std::io::stdout(),
+ &Receipt::new(
+ std::process::id(),
+ "ready",
+ root.states()?
+ .into_iter()
+ .filter(|s| s.owner_token == request.owner_token)
+ .collect(),
+ ),
+ )?;
+ drop(launch_lock);
+ supervisor::detach_stdio()?;
+ loop {
+ if os::STOP.load(Ordering::Relaxed) {
+ return Err(refused());
+ }
+ match listener.accept() {
+ Ok((mut stream, _)) => {
+ stream.set_nonblocking(true)?;
+ let mut response_started = false;
+ let added = handle_add(
+ &root,
+ &request.owner_token,
+ request.log_path.as_deref(),
+ &mut stream,
+ &mut children,
+ &mut response_started,
+ );
+ if added.is_err() && !response_started {
+ let _ = protocol::write(
+ &mut stream,
+ &Receipt::new(std::process::id(), "refused", Vec::new()),
+ );
+ }
+ }
+ Err(error)
+ if matches!(
+ error.kind(),
+ std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
+ ) => {}
+ Err(error) => return Err(error),
+ }
+ let mut live = false;
+ for child in &mut children {
+ if child.try_wait()?.is_none() {
+ live = true;
+ }
+ }
+ if !live {
+ return Ok(());
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+ })();
+ if outcome.is_err() {
+ supervisor::rollback(&mut children);
+ }
+ outcome
+}
+
+fn handle_add(
+ root: &Root,
+ owner: &str,
+ log_path: Option<&std::path::Path>,
+ stream: &mut UnixStream,
+ children: &mut Vec,
+ response_started: &mut bool,
+) -> Result<()> {
+ let request: Launch = protocol::read(stream)?;
+ supervisor::validate_launch(&request)?;
+ if request.root != root.path
+ || request.owner_token != owner
+ || request.processes.len() != 1
+ || request.log_path.as_deref() != log_path
+ {
+ return Err(refused());
+ }
+ let _stop_lock = root.lock_wait(".stop.lock")?;
+ let process = request.processes.into_iter().next().ok_or_else(refused)?;
+ let states = root.states()?;
+ let mut live = 0;
+ for state in &states {
+ let present = os::recorded_live(state, false)?;
+ if present {
+ if state.owner_token != owner
+ || matches!(state.phase.as_str(), "stopping" | "kill-pending")
+ || matches!(os::observe_state(state), os::Observation::Unknown)
+ {
+ return Err(refused());
+ }
+ live += 1;
+ }
+ if state.name.eq_ignore_ascii_case(&process.name) && (present || state.name != process.name)
+ {
+ return Err(refused());
+ }
+ }
+ // A stop that completed while add waited closes this owner's admission.
+ // A later hook may start a fresh owner once its instance lock is released.
+ if live == 0 || live >= 128 {
+ return Err(refused());
+ }
+ let before = children.len();
+ let started = supervisor::start_guard(&root.path, owner, log_path, process, children);
+ if started.is_err() {
+ supervisor::rollback(&mut children[before..]);
+ return Err(refused());
+ }
+ let receipt = Receipt::new(
+ std::process::id(),
+ "ready",
+ root.states()?
+ .into_iter()
+ .filter(|state| state.owner_token == owner)
+ .collect(),
+ );
+ *response_started = true;
+ protocol::write(stream, &receipt)
+}
diff --git a/packages/lifecycle-supervisor/src/main.rs b/packages/lifecycle-supervisor/src/main.rs
index 41a8850f9..d8481ef63 100644
--- a/packages/lifecycle-supervisor/src/main.rs
+++ b/packages/lifecycle-supervisor/src/main.rs
@@ -22,6 +22,7 @@
//! leader. Killing that guard separately is an unknown-ownership outcome and
//! fails closed when a live group remains.
+mod incremental;
mod os;
mod protocol;
mod storage;
diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs
index 7ec5c168e..77b4fa30d 100644
--- a/packages/lifecycle-supervisor/src/os.rs
+++ b/packages/lifecycle-supervisor/src/os.rs
@@ -101,10 +101,16 @@ pub fn census() -> Result> {
let mut result = Vec::new();
for entry in fs::read_dir("/proc")? {
let entry = entry?;
- if let Some(pid) = entry.file_name().to_str().and_then(|v| v.parse().ok())
- && let Some(identity) = inspect(pid)?
- {
- result.push(identity);
+ if let Some(pid) = entry.file_name().to_str().and_then(|v| v.parse().ok()) {
+ match inspect(pid) {
+ Ok(Some(identity)) => result.push(identity),
+ Ok(None) => (),
+ // Authored commands retain this user's UID. Linux hidepid and
+ // unrelated protected processes must not abort their census.
+ Err(error)
+ if matches!(error.raw_os_error(), Some(libc::EPERM) | Some(libc::EACCES)) => {}
+ Err(error) => return Err(error),
+ }
}
}
Ok(result)
@@ -271,6 +277,41 @@ pub fn recorded_live(state: &crate::storage::State, excluding_guard: bool) -> Re
live_group(state.guard_pid, excluding_guard.then_some(state.guard_pid))
}
+pub enum Observation {
+ Absent,
+ Live,
+ Unknown,
+}
+
+/// Health observation is stricter than a persisted running phase. A lost guard
+/// with remaining session members is unknown, never healthy or absent.
+pub fn observe_state(state: &crate::storage::State) -> Observation {
+ let observed = (|| -> Result {
+ if state.boot_id != boot()? {
+ return Ok(Observation::Absent);
+ }
+ let leader = inspect(state.guard_pid)?;
+ if let Some(identity) = &leader
+ && (identity.start != state.guard_start_time
+ || identity.uid != state.uid
+ || identity.session != state.guard_pid)
+ {
+ return Ok(Observation::Absent);
+ }
+ if !live_group(state.guard_pid, Some(state.guard_pid))? {
+ return Ok(Observation::Absent);
+ }
+ Ok(
+ if leader.is_some_and(|p| !p.zombie && p.group == state.guard_pid) {
+ Observation::Live
+ } else {
+ Observation::Unknown
+ },
+ )
+ })();
+ observed.unwrap_or(Observation::Unknown)
+}
+
/// The guard is never a member of an authored process group. It retains the
/// command lock while KILL is confirmed, including uninterruptible processes.
pub fn session_signal(session: i32, signal: i32) -> Result<()> {
@@ -283,13 +324,18 @@ pub fn session_signal(session: i32, signal: i32) -> Result<()> {
.filter(|p| p.group != session)
.map(|p| p.group)
.collect();
+ let mut first_error = None;
for member in members.iter().filter(|p| p.group == session) {
- signal_member(member, signal)?;
+ if let Err(error) = signal_member(member, signal) {
+ first_error.get_or_insert(error);
+ }
}
for group in groups {
- group_signal(group, signal)?;
+ if let Err(error) = group_signal(group, signal) {
+ first_error.get_or_insert(error);
+ }
}
- Ok(())
+ first_error.map_or(Ok(()), Err)
}
fn signal_member(expected: &Identity, signal: i32) -> Result<()> {
diff --git a/packages/lifecycle-supervisor/src/protocol.rs b/packages/lifecycle-supervisor/src/protocol.rs
index 0a795a3e2..9f3cdedba 100644
--- a/packages/lifecycle-supervisor/src/protocol.rs
+++ b/packages/lifecycle-supervisor/src/protocol.rs
@@ -10,6 +10,11 @@ use std::{
pub const LIMIT: usize = 1024 * 1024;
pub const DEADLINE: Duration = Duration::from_secs(10);
+static STDOUT_STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
+
+pub fn stdout_started() -> bool {
+ STDOUT_STARTED.load(std::sync::atomic::Ordering::Relaxed)
+}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -18,6 +23,8 @@ pub struct Launch {
pub root: PathBuf,
pub owner_token: String,
pub processes: Vec,
+ #[serde(default)]
+ pub log_path: Option,
}
#[derive(Deserialize, Serialize)]
@@ -27,6 +34,12 @@ pub struct Process {
pub argv: Vec,
pub cwd: PathBuf,
pub env: BTreeMap,
+ #[serde(
+ rename = "displayName",
+ default,
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub display_name: Option,
}
#[derive(Deserialize, Serialize)]
@@ -44,6 +57,8 @@ pub struct GuardLaunch {
pub root: PathBuf,
pub owner_token: String,
pub process: Process,
+ #[serde(default)]
+ pub log_path: Option,
}
pub fn valid_name(name: &str) -> bool {
@@ -71,6 +86,13 @@ pub fn validate_control(version: u32, root: &std::path::Path, token: &str) -> Re
impl Process {
pub fn validate(&self) -> Result<()> {
+ if self
+ .display_name
+ .as_ref()
+ .is_some_and(|name| name.trim().is_empty() || name.len() > 4096 || name.contains('\0'))
+ {
+ return Err(refused());
+ }
if !valid_name(&self.name)
|| !self.cwd.is_absolute()
|| self.argv.is_empty()
@@ -166,6 +188,9 @@ pub fn write(output: &mut (impl Write + AsRawFd), value: &impl Serialize) -> Res
return Err(refused());
}
let fd = output.as_raw_fd();
+ if fd == libc::STDOUT_FILENO {
+ STDOUT_STARTED.store(true, std::sync::atomic::Ordering::Relaxed);
+ }
// SAFETY: fcntl borrows this live, exclusively used protocol descriptor.
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags < 0 {
diff --git a/packages/lifecycle-supervisor/src/storage.rs b/packages/lifecycle-supervisor/src/storage.rs
index 593560a0c..c850bf6d3 100644
--- a/packages/lifecycle-supervisor/src/storage.rs
+++ b/packages/lifecycle-supervisor/src/storage.rs
@@ -15,6 +15,8 @@ use std::{
pub struct State {
pub version: u32,
pub name: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub display_name: Option,
pub owner_token: String,
pub guard_pid: i32,
pub process_group_id: i32,
@@ -37,6 +39,15 @@ pub struct Root {
}
impl Root {
+ /// Fresh internal binaries may use a descriptor-relative cwd to keep Unix
+ /// socket addresses below Darwin's 104-byte limit. Caller cwd is untouched.
+ pub fn enter(&self) -> Result<()> {
+ // SAFETY: the owned descriptor refers to the admitted private directory.
+ if unsafe { libc::fchdir(self.directory.as_raw_fd()) } != 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ }
pub fn open(path: &Path) -> Result {
let directory = OpenOptions::new()
.read(true)
@@ -120,6 +131,12 @@ impl Root {
}
pub fn save(&self, state: &State) -> Result<()> {
+ let bytes = serde_json::to_vec(state).map_err(|_| refused())?;
+ // Match the bounded read contract before publishing or spawning. Human
+ // labels can expand when JSON escapes control characters.
+ if bytes.len() > 8192 {
+ return Err(refused());
+ }
let final_name = format!("{}.json", state.name);
// Refuse unsafe pre-existing targets before atomically replacing an owned
// regular receipt. Never truncate through a link.
@@ -151,7 +168,6 @@ impl Root {
Err(error) => return Err(error),
}
};
- let bytes = serde_json::to_vec(state).map_err(|_| refused())?;
temporary.write_all(&bytes)?;
temporary.sync_all()?;
let temporary_name = std::ffi::CString::new(temporary_name).map_err(|_| refused())?;
diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs
index d52babf29..6972f635f 100644
--- a/packages/lifecycle-supervisor/src/supervisor.rs
+++ b/packages/lifecycle-supervisor/src/supervisor.rs
@@ -24,14 +24,51 @@ const LINE_LIMIT: usize = 64 * 1024;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
-struct Receipt {
+pub(crate) struct Receipt {
version: u32,
supervisor_pid: u32,
outcome: &'static str,
processes: Vec,
+ live_process_names: Vec,
+ unknown_process_names: Vec,
+}
+
+impl Receipt {
+ pub(crate) fn new(supervisor_pid: u32, outcome: &'static str, processes: Vec) -> Self {
+ let mut live_process_names = Vec::new();
+ let mut unknown_process_names = Vec::new();
+ for state in &processes {
+ match os::observe_state(state) {
+ os::Observation::Live => live_process_names
+ .push(state.display_name.as_ref().unwrap_or(&state.name).clone()),
+ os::Observation::Unknown => unknown_process_names
+ .push(state.display_name.as_ref().unwrap_or(&state.name).clone()),
+ os::Observation::Absent => (),
+ }
+ }
+ Self {
+ version: 1,
+ supervisor_pid,
+ outcome,
+ processes,
+ live_process_names,
+ unknown_process_names,
+ }
+ }
}
pub fn main() -> Result<()> {
+ let result = dispatch();
+ if result.is_err() && !protocol::stdout_started() {
+ let _ = protocol::write(
+ &mut std::io::stdout(),
+ &Receipt::new(0, "refused", Vec::new()),
+ );
+ }
+ result
+}
+
+fn dispatch() -> Result<()> {
os::close_inherited()?;
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 1 {
@@ -39,6 +76,8 @@ pub fn main() -> Result<()> {
}
match args[0].to_str() {
Some("run") => run(protocol::read(&mut std::io::stdin())?),
+ Some("serve") => crate::incremental::serve(protocol::read(&mut std::io::stdin())?),
+ Some("add") => crate::incremental::add(protocol::read(&mut std::io::stdin())?),
Some("guard") => guard(protocol::read(&mut std::io::stdin())?),
Some("stop") | Some("status") => {
let request: Control = protocol::read(&mut std::io::stdin())?;
@@ -67,22 +106,20 @@ pub fn main() -> Result<()> {
};
protocol::write(
&mut std::io::stdout(),
- &Receipt {
- version: 1,
- supervisor_pid: 0,
- outcome: if result.is_err() {
+ &Receipt::new(
+ 0,
+ if result.is_err() {
"unknown"
} else if args[0] == "stop" {
"stopped"
} else {
"observed"
},
- processes: root
- .states()?
+ root.states()?
.into_iter()
.filter(|state| state.owner_token == request.owner_token)
.collect(),
- },
+ ),
)?;
result
}
@@ -90,8 +127,15 @@ pub fn main() -> Result<()> {
}
}
-fn run(request: Launch) -> Result<()> {
+pub(crate) fn validate_launch(request: &Launch) -> Result<()> {
protocol::validate_control(request.version, &request.root, &request.owner_token)?;
+ if request
+ .log_path
+ .as_ref()
+ .is_some_and(|path| !path.is_absolute() || path.file_name().is_none())
+ {
+ return Err(refused());
+ }
if request.processes.is_empty() || request.processes.len() > 128 {
return Err(refused());
}
@@ -102,6 +146,11 @@ fn run(request: Launch) -> Result<()> {
return Err(refused());
}
}
+ Ok(())
+}
+
+fn run(request: Launch) -> Result<()> {
+ validate_launch(&request)?;
let root = Root::open(&request.root)?;
let _instance_lock = root.lock(".instance.lock")?;
let launch_lock = root.lock_wait(".stop.lock")?;
@@ -121,45 +170,18 @@ fn run(request: Launch) -> Result<()> {
if os::STOP.load(Ordering::Relaxed) {
return Err(refused());
}
- let mut command = Command::new(std::env::current_exe()?);
- command
- .arg("guard")
- .env_clear()
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::null());
- // SAFETY: setsid is async-signal-safe, and this closure allocates no
- // application state. It runs in the freshly forked child only.
- unsafe {
- command.pre_exec(os::session);
- }
- let child = command.spawn()?;
- children.push(child);
- let child = children.last_mut().ok_or_else(refused)?;
- let plan = GuardLaunch {
- version: 1,
- root: request.root.clone(),
- owner_token: request.owner_token.clone(),
+ let state = start_guard(
+ &request.root,
+ &request.owner_token,
+ request.log_path.as_deref(),
process,
- };
- protocol::write(&mut child.stdin.take().ok_or_else(refused)?, &plan)?;
- let state: State = protocol::read(&mut child.stdout.take().ok_or_else(refused)?)?;
- if state.guard_pid != child.id() as i32 || state.owner_token != request.owner_token {
- return Err(refused());
- }
- if state.phase == "failed-start" {
- return Err(refused());
- }
+ &mut children,
+ )?;
states.push(state);
}
protocol::write(
&mut std::io::stdout(),
- &Receipt {
- version: 1,
- supervisor_pid: std::process::id(),
- outcome: "ready",
- processes: states,
- },
+ &Receipt::new(std::process::id(), "ready", states),
)?;
drop(launch_lock);
detach_stdio()?;
@@ -193,7 +215,48 @@ fn run(request: Launch) -> Result<()> {
outcome
}
-fn rollback(children: &mut [Child]) {
+pub(crate) fn start_guard(
+ root: &std::path::Path,
+ owner_token: &str,
+ log_path: Option<&std::path::Path>,
+ process: protocol::Process,
+ children: &mut Vec,
+) -> Result {
+ if os::STOP.load(Ordering::Relaxed) {
+ return Err(refused());
+ }
+ let mut command = Command::new(std::env::current_exe()?);
+ command
+ .arg("guard")
+ .env_clear()
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null());
+ // SAFETY: setsid only uses async-signal-safe operations in the fresh child.
+ unsafe {
+ command.pre_exec(os::session);
+ }
+ children.push(command.spawn()?);
+ let child = children.last_mut().ok_or_else(refused)?;
+ let plan = GuardLaunch {
+ version: 1,
+ root: root.into(),
+ owner_token: owner_token.into(),
+ process,
+ log_path: log_path.map(std::path::Path::to_path_buf),
+ };
+ protocol::write(&mut child.stdin.take().ok_or_else(refused)?, &plan)?;
+ let state: State = protocol::read(&mut child.stdout.take().ok_or_else(refused)?)?;
+ if state.guard_pid != child.id() as i32
+ || state.owner_token != owner_token
+ || state.phase == "failed-start"
+ {
+ return Err(refused());
+ }
+ Ok(state)
+}
+
+pub(crate) fn rollback(children: &mut [Child]) {
for child in children.iter_mut() {
if child.try_wait().ok().flatten().is_some() {
continue;
@@ -239,6 +302,7 @@ fn guard(request: GuardLaunch) -> Result<()> {
let mut state = State {
version: 1,
name: request.process.name.clone(),
+ display_name: request.process.display_name.clone(),
owner_token: request.owner_token,
guard_pid: pid,
process_group_id: pid,
@@ -250,10 +314,25 @@ fn guard(request: GuardLaunch) -> Result<()> {
exit_code: None,
signal: None,
};
- let mut log = root.file(
- "lifecycle.log",
- libc::O_WRONLY | libc::O_APPEND | libc::O_CREAT,
- )?;
+ let mut log = if let Some(path) = request.log_path {
+ if !path.is_absolute() {
+ return Err(refused());
+ }
+ let parent = Root::open(path.parent().ok_or_else(refused)?)?;
+ let name = path
+ .file_name()
+ .and_then(|v| v.to_str())
+ .ok_or_else(refused)?;
+ if name == "." || name == ".." || name.contains('/') {
+ return Err(refused());
+ }
+ parent.file(name, libc::O_WRONLY | libc::O_APPEND | libc::O_CREAT)?
+ } else {
+ root.file(
+ "lifecycle.log",
+ libc::O_WRONLY | libc::O_APPEND | libc::O_CREAT,
+ )?
+ };
root.save(&state)?;
let outcome = guard_command(&request.process, &root, &mut state, &mut log);
if outcome.is_err() && state.command_pid.is_some() {
@@ -279,6 +358,7 @@ fn guard_command(
state: &mut State,
log: &mut File,
) -> Result<()> {
+ let log_name = sanitize_service(state.display_name.as_deref().unwrap_or(&state.name));
let mut command = Command::new(&process.argv[0]);
command
.args(&process.argv[1..])
@@ -318,8 +398,8 @@ fn guard_command(
let mut drained_after_exit = None;
loop {
// Alternate bounded reads so stdout bursts cannot starve stderr/stop.
- out.drain(&mut stdout, log, &state.name, "stdout")?;
- err.drain(&mut stderr, log, &state.name, "stderr")?;
+ out.drain(&mut stdout, log, &log_name, "stdout")?;
+ err.drain(&mut stderr, log, &log_name, "stderr")?;
if !direct_exited && let Some(status) = child.try_wait()? {
direct_exited = true;
state.exit_code = status.code();
@@ -348,10 +428,10 @@ fn guard_command(
// inherit a pipe on Darwin. Drain a bounded final interval instead.
if since.elapsed() >= Duration::from_millis(100) {
if !out.pending.is_empty() {
- out.flush(log, &state.name, "stdout", true)?;
+ out.flush(log, &log_name, "stdout", true)?;
}
if !err.pending.is_empty() {
- err.flush(log, &state.name, "stderr", true)?;
+ err.flush(log, &log_name, "stderr", true)?;
}
state.phase = if killed { "killed" } else { "exited" }.into();
root.save(state)?;
@@ -377,19 +457,30 @@ fn stop(states: &[State]) -> Result<()> {
if !os::recorded_live(state, false)? {
continue;
}
- let identity = os::inspect(state.guard_pid)?.ok_or_else(refused)?;
- os::same(state, &identity)?;
+ let verified = os::inspect(state.guard_pid)?
+ .ok_or_else(refused)
+ .and_then(|identity| os::same(state, &identity));
+ if let Err(error) = verified {
+ if os::recorded_live(state, false)? {
+ return Err(error);
+ }
+ continue;
+ }
active.push(state.clone());
}
+ let mut first_error = None;
for state in &active {
if let Err(error) = os::signal_guard(state) {
// Natural exit can race between admission and pidfd delivery.
// Only a new kernel absence proof turns that race into success.
if os::recorded_live(state, false)? {
- return Err(error);
+ first_error.get_or_insert(error);
}
}
}
+ if let Some(error) = first_error {
+ return Err(error);
+ }
let deadline = Instant::now() + GRACE + Duration::from_secs(2);
while Instant::now() < deadline {
let mut live = false;
@@ -406,7 +497,7 @@ fn stop(states: &[State]) -> Result<()> {
Err(refused())
}
-fn detach_stdio() -> Result<()> {
+pub(crate) fn detach_stdio() -> Result<()> {
let null = std::fs::OpenOptions::new()
.read(true)
.write(true)
@@ -505,9 +596,21 @@ fn incomplete_utf8_tail(bytes: &[u8]) -> usize {
0
}
+fn sanitize_service(name: &str) -> String {
+ let compact = name.split_whitespace().collect::>().join(" ");
+ let mut units = 0;
+ compact
+ .chars()
+ .take_while(|ch| {
+ units += ch.len_utf16();
+ units <= 96
+ })
+ .collect()
+}
+
#[cfg(test)]
mod tests {
- use super::incomplete_utf8_tail;
+ use super::{incomplete_utf8_tail, sanitize_service};
#[test]
fn chunk_boundary_retains_only_incomplete_codepoints() {
@@ -519,5 +622,8 @@ mod tests {
}
}
assert_eq!(incomplete_utf8_tail(b"abc\xff"), 0);
+ assert_eq!(sanitize_service(" API\t/\nworker "), "API / worker");
+ assert_eq!(sanitize_service(&"x".repeat(120)).len(), 96);
+ assert_eq!(sanitize_service(&format!("{}🦀", "x".repeat(95))).len(), 95);
}
}
diff --git a/packages/lifecycle-supervisor/tests/incremental.rs b/packages/lifecycle-supervisor/tests/incremental.rs
new file mode 100644
index 000000000..a3baf5508
--- /dev/null
+++ b/packages/lifecycle-supervisor/tests/incremental.rs
@@ -0,0 +1,511 @@
+//! Real incremental callers use framed stdin, private fixtures and no daemon.
+use serde_json::{Value, json};
+use std::{
+ fs,
+ io::{Read, Write},
+ os::{
+ fd::AsRawFd,
+ unix::fs::{DirBuilderExt, MetadataExt},
+ },
+ path::PathBuf,
+ process::{Child, Command, Stdio},
+ sync::atomic::{AtomicU64, Ordering},
+ thread,
+ time::{Duration, Instant, SystemTime, UNIX_EPOCH},
+};
+
+const TOKEN: &str = "synthetic-incremental-owner";
+static SERIAL: AtomicU64 = AtomicU64::new(0);
+
+struct Fixture {
+ base: PathBuf,
+ root: PathBuf,
+ owners: Vec,
+}
+
+impl Fixture {
+ fn new() -> Self {
+ let nonce = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ let base = std::env::temp_dir().join(format!(
+ "incremental-{}-{nonce}-{}",
+ std::process::id(),
+ SERIAL.fetch_add(1, Ordering::Relaxed)
+ ));
+ fs::DirBuilder::new().mode(0o700).create(&base).unwrap();
+ let root = base.join("private-instance-with-a-deliberately-long-directory-name-so-unix-socket-addresses-exceed-the-darwin-limit-without-relative-addressing");
+ fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
+ Self {
+ base,
+ root,
+ owners: Vec::new(),
+ }
+ }
+ fn command(&self, operation: &str) -> Command {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_hack-lifecycle-supervisor"));
+ command
+ .arg(operation)
+ .env_clear()
+ .env("HOME", &self.base)
+ .env("HACK_DAEMON_AUTOSTART_DISABLE", "1")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::null());
+ command
+ }
+ fn process(&self, name: &str, mode: &str) -> Value {
+ json!({"name":name,"argv":[std::env::current_exe().unwrap(),"--exact","incremental_fixture","--nocapture"],"cwd":self.root,"env":{"HOME":self.base,"HACK_DAEMON_AUTOSTART_DISABLE":"1","INCREMENTAL_MODE":mode,"INCREMENTAL_VALUE":"synthetic-stdin-only-value"}})
+ }
+ fn launch(&self, process: Value) -> Value {
+ json!({"version":1,"root":self.root,"ownerToken":TOKEN,"processes":[process]})
+ }
+ fn call(&self, operation: &str, request: &Value) -> (Child, Value) {
+ let mut child = self.command(operation).spawn().unwrap();
+ send(child.stdin.as_mut().unwrap(), request);
+ // Keep stdin open: neither side may wait on descriptor closure.
+ let receipt = receive(child.stdout.as_mut().unwrap());
+ (child, receipt)
+ }
+ fn serve(&mut self, process: Value) -> Value {
+ let (child, receipt) = self.call("serve", &self.launch(process));
+ self.owners.push(child);
+ assert_eq!(receipt["outcome"], "ready");
+ receipt
+ }
+ fn add(&self, process: Value) -> Value {
+ let (mut child, receipt) = self.call("add", &self.launch(process));
+ assert!(wait(&mut child).success());
+ assert_eq!(receipt["outcome"], "ready");
+ receipt
+ }
+ fn control(&self, operation: &str) -> Value {
+ let (mut child, receipt) = self.call(
+ operation,
+ &json!({"version":1,"root":self.root,"ownerToken":TOKEN}),
+ );
+ assert!(wait(&mut child).success());
+ receipt
+ }
+ fn state(&self, name: &str) -> Value {
+ serde_json::from_slice(&fs::read(self.root.join(format!("{name}.json"))).unwrap()).unwrap()
+ }
+}
+
+impl Drop for Fixture {
+ fn drop(&mut self) {
+ if let Ok(mut child) = self.command("stop").spawn() {
+ if let Some(mut input) = child.stdin.take() {
+ send(
+ &mut input,
+ &json!({"version":1,"root":self.root,"ownerToken":TOKEN}),
+ );
+ }
+ let _ = wait(&mut child);
+ }
+ for child in &mut self.owners {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
+ let _ = fs::remove_dir_all(&self.base);
+ }
+}
+
+fn send(output: &mut impl Write, value: &Value) {
+ let bytes = serde_json::to_vec(value).unwrap();
+ output
+ .write_all(&(bytes.len() as u32).to_be_bytes())
+ .unwrap();
+ output.write_all(&bytes).unwrap();
+ output.flush().unwrap();
+}
+fn exact(input: &mut (impl Read + AsRawFd), bytes: &mut [u8]) {
+ let deadline = Instant::now() + Duration::from_secs(20);
+ let mut offset = 0;
+ while offset < bytes.len() {
+ assert!(
+ Instant::now() < deadline,
+ "framed response deadline expired"
+ );
+ let mut fd = libc::pollfd {
+ fd: input.as_raw_fd(),
+ events: libc::POLLIN,
+ revents: 0,
+ };
+ // SAFETY: one live initialized poll record.
+ assert!(unsafe { libc::poll(&mut fd, 1, 1000) } >= 0);
+ if fd.revents == 0 {
+ continue;
+ }
+ let count = input.read(&mut bytes[offset..]).unwrap();
+ assert!(count > 0, "incomplete frame");
+ offset += count;
+ }
+}
+fn receive(input: &mut (impl Read + AsRawFd)) -> Value {
+ let mut header = [0; 4];
+ exact(input, &mut header);
+ let length = u32::from_be_bytes(header) as usize;
+ assert!(length <= 1024 * 1024);
+ let mut bytes = vec![0; length];
+ exact(input, &mut bytes);
+ serde_json::from_slice(&bytes).unwrap()
+}
+fn wait(child: &mut Child) -> std::process::ExitStatus {
+ let deadline = Instant::now() + Duration::from_secs(20);
+ loop {
+ if let Some(status) = child.try_wait().unwrap() {
+ return status;
+ }
+ if Instant::now() >= deadline {
+ let _ = child.kill();
+ let _ = child.wait();
+ panic!("owned child did not settle");
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+}
+fn eventually(mut condition: impl FnMut() -> bool) {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ while !condition() {
+ assert!(Instant::now() < deadline);
+ thread::sleep(Duration::from_millis(20));
+ }
+}
+
+#[test]
+fn incremental_fixture() {
+ let Ok(mode) = std::env::var("INCREMENTAL_MODE") else {
+ return;
+ };
+ assert_eq!(
+ std::env::var("INCREMENTAL_VALUE").unwrap(),
+ "synthetic-stdin-only-value"
+ );
+ if mode == "fd" {
+ // The command only inherits its three intended standard descriptors.
+ for fd in 3..256 {
+ // SAFETY: observation only; the command fixture owns no other fd.
+ assert_eq!(
+ unsafe { libc::fcntl(fd, libc::F_GETFD) },
+ -1,
+ "unexpected inherited descriptor {fd}"
+ );
+ }
+ }
+ println!("incremental-ready-{mode}");
+ if mode == "short" {
+ return;
+ }
+ loop {
+ thread::sleep(Duration::from_millis(50));
+ }
+}
+
+#[test]
+fn long_root_and_incremental_hook_order_keep_one_owner() {
+ let mut fixture = Fixture::new();
+ assert!(fixture.root.as_os_str().len() > 104);
+ let first = fixture.serve(fixture.process("before-one", "hold"));
+ let second = fixture.add(fixture.process("before-two", "fd"));
+ let third = fixture.add(fixture.process("service", "hold"));
+ assert_eq!(first["supervisorPid"], second["supervisorPid"]);
+ assert_eq!(first["supervisorPid"], third["supervisorPid"]);
+ let status = fixture.control("status");
+ assert_eq!(status["liveProcessNames"].as_array().unwrap().len(), 3);
+ assert_eq!(status["unknownProcessNames"], json!([]));
+ assert_eq!(
+ fs::metadata(fixture.root.join("control.sock"))
+ .unwrap()
+ .mode()
+ & 0o777,
+ 0o600
+ );
+ fixture.control("stop");
+ for owner in &mut fixture.owners {
+ assert!(wait(owner).success());
+ }
+ assert!(!fixture.root.join("control.sock").exists());
+}
+
+#[test]
+fn wrong_token_and_duplicate_add_refuse_without_harming_owner() {
+ let mut fixture = Fixture::new();
+ fixture.serve(fixture.process("helper", "hold"));
+ for mut request in [
+ fixture.launch(fixture.process("helper", "hold")),
+ fixture.launch(fixture.process("HELPER", "hold")),
+ fixture.launch(fixture.process("other", "hold")),
+ ] {
+ if request["processes"][0]["name"] == "other" {
+ request["ownerToken"] = json!("wrong-owner");
+ }
+ let (mut child, response) = fixture.call("add", &request);
+ assert_eq!(response["outcome"], "refused");
+ assert!(!wait(&mut child).success());
+ }
+ assert_eq!(
+ fixture.control("status")["liveProcessNames"],
+ json!(["helper"])
+ );
+}
+
+#[test]
+fn owner_death_keeps_native_status_and_recovery_stop() {
+ let mut fixture = Fixture::new();
+ fixture.serve(fixture.process("helper", "hold"));
+ fixture.owners[0].kill().unwrap();
+ fixture.owners[0].wait().unwrap();
+ let (mut duplicate, response) =
+ fixture.call("serve", &fixture.launch(fixture.process("helper", "hold")));
+ assert_eq!(response["outcome"], "refused");
+ assert!(!wait(&mut duplicate).success());
+ assert_eq!(
+ fixture.control("status")["liveProcessNames"],
+ json!(["helper"])
+ );
+ fixture.control("stop");
+ let state = fixture.state("helper");
+ assert!(matches!(
+ state["phase"].as_str(),
+ Some("exited") | Some("killed")
+ ));
+}
+
+#[test]
+fn completed_stop_cannot_be_followed_by_add_to_old_owner() {
+ let mut fixture = Fixture::new();
+ fixture.serve(fixture.process("helper", "hold"));
+ fixture.control("stop");
+ let (mut add, receipt) = fixture.call("add", &fixture.launch(fixture.process("later", "hold")));
+ assert_eq!(receipt["outcome"], "refused");
+ assert!(!wait(&mut add).success());
+ assert!(!fixture.root.join("later.json").exists());
+}
+
+#[test]
+fn external_private_log_is_shared_and_add_cannot_redirect_it() {
+ let mut fixture = Fixture::new();
+ let logs = fixture.base.join("logs");
+ fs::DirBuilder::new().mode(0o700).create(&logs).unwrap();
+ let log = logs.join("project.log");
+ let mut request = fixture.launch(fixture.process("helper", "hold"));
+ request["logPath"] = json!(log);
+ let (owner, reply) = fixture.call("serve", &request);
+ assert_eq!(reply["outcome"], "ready");
+ fixture.owners.push(owner);
+ let mut add = fixture.launch(fixture.process("service", "hold"));
+ add["logPath"] = json!(log);
+ let (mut caller, reply) = fixture.call("add", &add);
+ assert_eq!(reply["outcome"], "ready");
+ assert!(wait(&mut caller).success());
+ eventually(|| {
+ fs::read_to_string(&log).is_ok_and(|value| {
+ value.contains("helper\tstdout") && value.contains("service\tstdout")
+ })
+ });
+ assert_eq!(fs::metadata(&log).unwrap().mode() & 0o777, 0o600);
+ assert!(!fixture.root.join("lifecycle.log").exists());
+ add["processes"][0]["name"] = json!("redirect");
+ add["logPath"] = json!(logs.join("other.log"));
+ let (mut caller, reply) = fixture.call("add", &add);
+ assert_eq!(reply["outcome"], "refused");
+ assert!(!wait(&mut caller).success());
+ assert!(!logs.join("other.log").exists());
+}
+
+#[test]
+fn short_hook_owner_retires_then_later_hook_can_serve_again() {
+ let mut fixture = Fixture::new();
+ fixture.serve(fixture.process("first", "short"));
+ assert!(wait(&mut fixture.owners[0]).success());
+ fixture.serve(fixture.process("later", "hold"));
+ assert_eq!(
+ fixture.control("status")["liveProcessNames"],
+ json!(["later"])
+ );
+}
+
+#[test]
+fn framing_refusal_is_finite_without_stdin_eof() {
+ let fixture = Fixture::new();
+ let request = json!({"version":42,"root":fixture.root,"ownerToken":TOKEN,"processes":[]});
+ let (mut child, receipt) = fixture.call("serve", &request);
+ assert_eq!(receipt["outcome"], "refused");
+ assert_eq!(receipt["processes"], json!([]));
+ assert!(!wait(&mut child).success());
+}
+
+#[test]
+fn concurrent_add_and_stop_leave_no_later_command_running() {
+ let mut fixture = Fixture::new();
+ fixture.serve(fixture.process("helper", "hold"));
+ let mut add = fixture.command("add").spawn().unwrap();
+ send(
+ add.stdin.as_mut().unwrap(),
+ &fixture.launch(fixture.process("later", "hold")),
+ );
+ let mut stop = fixture.command("stop").spawn().unwrap();
+ send(
+ stop.stdin.as_mut().unwrap(),
+ &json!({"version":1,"root":fixture.root,"ownerToken":TOKEN}),
+ );
+ let add_reply = receive(add.stdout.as_mut().unwrap());
+ let stop_reply = receive(stop.stdout.as_mut().unwrap());
+ let add_status = wait(&mut add);
+ assert_eq!(add_status.success(), add_reply["outcome"] == "ready");
+ assert_eq!(stop_reply["outcome"], "stopped");
+ assert!(wait(&mut stop).success());
+ for owner in &mut fixture.owners {
+ assert!(wait(owner).success());
+ }
+ assert_eq!(fixture.control("status")["liveProcessNames"], json!([]));
+}
+
+#[test]
+#[cfg(target_os = "linux")]
+fn incremental_environment_is_absent_from_argv_and_persisted_files() {
+ let mut fixture = Fixture::new();
+ fixture.serve(fixture.process("first", "hold"));
+ let receipt = fixture.add(fixture.process("second", "hold"));
+ let sentinel = b"synthetic-stdin-only-value";
+ for state in receipt["processes"].as_array().unwrap() {
+ for key in ["guardPid", "commandPid"] {
+ let args = fs::read(format!("/proc/{}/cmdline", state[key].as_i64().unwrap())).unwrap();
+ assert!(!args.windows(sentinel.len()).any(|v| v == sentinel));
+ }
+ }
+ for entry in fs::read_dir(&fixture.root).unwrap() {
+ let entry = entry.unwrap();
+ let metadata = entry.metadata().unwrap();
+ assert_eq!(metadata.mode() & 0o077, 0);
+ if metadata.is_file() {
+ let bytes = fs::read(entry.path()).unwrap();
+ assert!(!bytes.windows(sentinel.len()).any(|v| v == sentinel));
+ }
+ }
+}
+
+#[test]
+#[cfg(target_os = "linux")]
+fn status_names_unknown_when_guard_lost_with_live_command() {
+ let mut fixture = Fixture::new();
+ fixture.serve(fixture.process("helper", "hold"));
+ let state = fixture.state("helper");
+ let guard = state["guardPid"].as_i64().unwrap() as i32;
+ let command = state["commandPid"].as_i64().unwrap() as i32;
+ let original = fs::read_to_string(format!("/proc/{command}/stat")).unwrap();
+ let birth = original
+ .rsplit_once(") ")
+ .unwrap()
+ .1
+ .split_whitespace()
+ .nth(19)
+ .unwrap()
+ .to_owned();
+ // SAFETY: the guard identity was returned by this fixture's own launch.
+ unsafe {
+ libc::kill(guard, libc::SIGKILL);
+ }
+ eventually(|| fs::read_to_string(format!("/proc/{guard}/stat")).is_err());
+ let status = fixture.control("status");
+ assert_eq!(status["liveProcessNames"], json!([]));
+ assert_eq!(status["unknownProcessNames"], json!(["helper"]));
+ // The test deliberately removed the recovery guard. Stop this synthetic
+ // direct command only after rechecking its captured native birth.
+ let now = fs::read_to_string(format!("/proc/{command}/stat")).unwrap();
+ assert_eq!(
+ now.rsplit_once(") ").unwrap().1.split_whitespace().nth(19),
+ Some(birth.as_str())
+ );
+ // SAFETY: exact live birth matches this fixture's captured command identity.
+ unsafe {
+ libc::kill(command, libc::SIGKILL);
+ }
+}
+
+#[test]
+fn display_names_are_visible_labels_never_state_paths() {
+ let mut fixture = Fixture::new();
+ let display = "API worker / ready\ttask\nnext";
+ let mut process = fixture.process("service-0123456789abcdef", "hold");
+ process["displayName"] = json!(display);
+ let first = fixture.serve(process);
+ assert_eq!(first["processes"][0]["name"], "service-0123456789abcdef");
+ assert_eq!(first["processes"][0]["displayName"], display);
+ let mut second = fixture.process("service-fedcba9876543210", "hold");
+ second["displayName"] = json!("other / service");
+ fixture.add(second);
+ let status = fixture.control("status");
+ assert!(
+ status["liveProcessNames"]
+ .as_array()
+ .unwrap()
+ .contains(&json!(display))
+ );
+ assert!(
+ status["liveProcessNames"]
+ .as_array()
+ .unwrap()
+ .contains(&json!("other / service"))
+ );
+ eventually(|| {
+ fs::read_to_string(fixture.root.join("lifecycle.log"))
+ .is_ok_and(|log| log.contains("\tAPI worker / ready task next\tstdout\t"))
+ });
+ assert!(fixture.root.join("service-0123456789abcdef.json").exists());
+ assert!(fixture.root.join("service-fedcba9876543210.json").exists());
+ assert_eq!(
+ fs::read_dir(&fixture.root)
+ .unwrap()
+ .filter_map(Result::ok)
+ .filter(|entry| entry.file_type().unwrap().is_dir())
+ .count(),
+ 0
+ );
+}
+
+#[test]
+fn display_name_limit_refuses_without_launch() {
+ let fixture = Fixture::new();
+ let mut process = fixture.process("safe-key", "hold");
+ process["displayName"] = json!("x".repeat(4097));
+ let (mut owner, receipt) = fixture.call("serve", &fixture.launch(process));
+ assert_eq!(receipt["outcome"], "refused");
+ assert!(!wait(&mut owner).success());
+ assert!(!fixture.root.join("safe-key.json").exists());
+}
+
+#[test]
+fn lost_add_client_reply_retains_native_ownership_and_live_observation() {
+ let mut fixture = Fixture::new();
+ fixture.serve(fixture.process("helper", "hold"));
+ let mut add = fixture.command("add").spawn().unwrap();
+ drop(add.stdout.take());
+ send(
+ add.stdin.as_mut().unwrap(),
+ &fixture.launch(fixture.process("late", "hold")),
+ );
+ assert!(!wait(&mut add).success());
+ let status = fixture.control("status");
+ assert!(
+ status["liveProcessNames"]
+ .as_array()
+ .unwrap()
+ .contains(&json!("late"))
+ );
+ assert_eq!(fixture.state("late")["ownerToken"], TOKEN);
+ fixture.control("stop");
+}
+
+#[test]
+fn escaped_display_name_cannot_publish_unreadable_state() {
+ let fixture = Fixture::new();
+ let mut process = fixture.process("safe-key", "hold");
+ process["displayName"] = json!(format!("x{}", "\u{1}".repeat(4095)));
+ let (mut owner, receipt) = fixture.call("serve", &fixture.launch(process));
+ assert_eq!(receipt["outcome"], "refused");
+ assert!(!wait(&mut owner).success());
+ assert!(!fixture.root.join("safe-key.json").exists());
+}
diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts
index aed6fa011..db395e862 100644
--- a/scripts/lib/tla-runtime-models.ts
+++ b/scripts/lib/tla-runtime-models.ts
@@ -141,6 +141,34 @@ const restartPolicyStaleUses = [
{ engine: "exited", bound: 3 },
] as const;
const contracts: readonly ModelContract[] = [
+ {
+ name: "lifecycle-controller",
+ module: "LifecycleController",
+ states: 21,
+ invariant: "NoAdoptedStop",
+ action: "Abort",
+ fields: [
+ "unsafeStop = TRUE",
+ "epoch = 2",
+ "creatorEpoch = 1",
+ "adopted = TRUE",
+ 'stage = "Aborted"',
+ ],
+ additionalControls: [
+ {
+ name: "pending",
+ negative: true,
+ invariant: "DurableBeforeLaunch",
+ action: "Launch",
+ fields: [
+ "unsafeLaunch = TRUE",
+ "pending = FALSE",
+ "live = TRUE",
+ 'stage = "AwaitReply"',
+ ],
+ },
+ ],
+ },
{
name: "lifecycle-supervisor",
module: "LifecycleSupervisor",
diff --git a/src/backends/native-authored-host-processes.ts b/src/backends/native-authored-host-processes.ts
index 83c31a2d8..c22567b71 100644
--- a/src/backends/native-authored-host-processes.ts
+++ b/src/backends/native-authored-host-processes.ts
@@ -375,6 +375,9 @@ async function stopKnown(opts: {
};
await current();
if (opts.savedEntry) {
+ if (opts.savedEntry.backend === "supervisor") {
+ return refuse();
+ }
const backend = getMuxBackends().get(opts.savedEntry.backend) ?? refuse();
const inspected = await inspectLifecycleSession({
backend,
@@ -743,6 +746,9 @@ export async function createNativeAuthoredHostProcesses(input: {
return refuse();
}
if (savedEntry) {
+ if (savedEntry.backend === "supervisor") {
+ return refuse();
+ }
const backend = getMuxBackends().get(savedEntry.backend) ?? refuse();
const inspection = await inspectLifecycleSession({
backend,
diff --git a/src/commands/host-ps.ts b/src/commands/host-ps.ts
index c81124734..ee1d1f348 100644
--- a/src/commands/host-ps.ts
+++ b/src/commands/host-ps.ts
@@ -11,6 +11,7 @@ import {
type LifecycleStateEntry,
readLifecycleState,
} from "../lib/lifecycle-runtime.ts";
+import { inspectLifecycleSupervisor } from "../lib/lifecycle-supervisor.ts";
import { resolvePersistedLifecycleProcessGroupIds } from "../lib/project-lifecycle-processes.ts";
import { inspectLifecycleSession } from "../lib/project-lifecycle-sessions.ts";
import {
@@ -112,7 +113,12 @@ async function readLifecycleObservations({
for (const [projectDir, projectRoot] of directories) {
for (const entry of await readLifecycleState({ projectDir })) {
lifecycle.push(
- await observeLifecycleEntry({ entry, projectRoot, snapshot })
+ await observeLifecycleEntry({
+ entry,
+ projectDir,
+ projectRoot,
+ snapshot,
+ })
);
}
}
@@ -120,13 +126,22 @@ async function readLifecycleObservations({
}
async function observeLifecycleEntry({
entry,
+ projectDir,
projectRoot,
snapshot,
}: {
readonly entry: LifecycleStateEntry;
+ readonly projectDir: string;
readonly projectRoot: string;
readonly snapshot: readonly ObservedProcess[] | null;
}): Promise> {
+ if (entry.backend === "supervisor") {
+ return await observeSupervisorLifecycleEntry({
+ entry,
+ projectDir,
+ projectRoot,
+ });
+ }
const backends = getMuxBackends();
const backend = backends.get(entry.backend);
const inspection = backend
@@ -177,6 +192,62 @@ async function observeLifecycleEntry({
};
}
+/** Read native session ownership without interpreting it as a mux session. */
+export async function observeSupervisorLifecycleEntry(opts: {
+ readonly entry: LifecycleStateEntry;
+ readonly projectDir: string;
+ readonly projectRoot: string;
+ readonly inspect?: typeof inspectLifecycleSupervisor;
+}): Promise> {
+ const base = {
+ project: opts.entry.projectName,
+ projectRoot: opts.projectRoot,
+ branch: opts.entry.branch,
+ session: opts.entry.sessionName,
+ backend: "supervisor",
+ lifetime: "persistent",
+ definitionSource: "persisted",
+ // The generic host snapshot does not include session IDs, so it cannot
+ // qualify every group or descendant after a command changes groups.
+ processGroupIds: [],
+ observedPids: [],
+ liveCpuTimeMs: null,
+ liveRssBytes: null,
+ elapsedMs: null,
+ };
+ if (!opts.entry.ownershipToken) {
+ return { ...base, ownership: "unknown", attention: "ownership_unverified" };
+ }
+ try {
+ const receipt = await (opts.inspect ?? inspectLifecycleSupervisor)({
+ projectDir: opts.projectDir,
+ composeProject: opts.entry.composeProject,
+ ownershipToken: opts.entry.ownershipToken,
+ });
+ if (
+ receipt.outcome !== "observed" ||
+ receipt.unknownProcessNames.length > 0
+ ) {
+ return {
+ ...base,
+ ownership: "unknown",
+ attention: "ownership_unverified",
+ };
+ }
+ const live = new Set(receipt.liveProcessNames);
+ return {
+ ...base,
+ ownership: live.size > 0 ? "owned-healthy" : "absent",
+ sessionIds: receipt.processes
+ .filter((process) => live.has(process.name))
+ .map((process) => process.processGroupId),
+ attention: live.size > 0 ? null : "session_absent_review_state",
+ };
+ } catch {
+ return { ...base, ownership: "unknown", attention: "ownership_unverified" };
+ }
+}
+
function lifecycleAttention(classification: string | undefined): string | null {
if (classification === "absent") {
return "session_absent_review_state";
diff --git a/src/commands/project.ts b/src/commands/project.ts
index cd2a76425..4b4a43f77 100644
--- a/src/commands/project.ts
+++ b/src/commands/project.ts
@@ -1,4 +1,5 @@
import { reportLooseEnvLayerModes } from "../lib/env-layer-modes.ts";
+import { resolveLifecycleRunner } from "../lib/lifecycle-runner.ts";
import {
createLifecycleProcessController,
installLifecycleSignalCleanup,
@@ -2044,11 +2045,15 @@ function sharedHelperControls(
* runs. Exported for tests.
*/
export async function assertSharedHelperSessionMuxSupported(opts: {
+ readonly runner?: "mux" | "supervisor";
readonly sharedHelpersConfigured: boolean;
readonly needsSession: boolean;
readonly mux: () => Promise;
}): Promise {
- if (!(opts.sharedHelpersConfigured && opts.needsSession)) {
+ if (
+ opts.runner === "supervisor" ||
+ !(opts.sharedHelpersConfigured && opts.needsSession)
+ ) {
return;
}
const mux = await opts.mux();
@@ -2078,6 +2083,9 @@ async function runLifecycleUpBeforeAndProcesses(opts: {
readonly effectiveEnvName: string | null;
readonly composeProject: string;
}): Promise {
+ if (opts.cfg.lifecycleRunnerError) {
+ throw new Error(opts.cfg.lifecycleRunnerError);
+ }
if (opts.cfg.sharedHelpersError) {
// Fail closed before any hook runs: starting without the configured
// helpers would launch services that depend on them.
@@ -2089,6 +2097,7 @@ async function runLifecycleUpBeforeAndProcesses(opts: {
// the hooks run supersedes this operation (see startProjectSharedHelpers).
const sharedHelperConfigs = opts.cfg.lifecycle?.sharedHelpers ?? [];
await assertSharedHelperSessionMuxSupported({
+ runner: resolveLifecycleRunner({ runner: opts.cfg.lifecycle?.runner }),
sharedHelpersConfigured: sharedHelperConfigs.length > 0,
needsSession:
(opts.cfg.lifecycle?.processes ?? []).length > 0 ||
@@ -2172,6 +2181,7 @@ async function runLifecycleUpBeforeAndProcesses(opts: {
}
const starter = createLifecycleProcessController({
+ runner: resolveLifecycleRunner({ runner: opts.cfg.lifecycle?.runner }),
mux: () => resolveMux({ project: opts.project }),
project: opts.project,
projectName: opts.projectName,
@@ -2723,6 +2733,7 @@ export async function startLifecycleProcesses(opts: {
}
const starter = createLifecycleProcessController({
+ runner: resolveLifecycleRunner({ runner: opts.cfg.lifecycle?.runner }),
mux: opts.mux ?? (() => resolveMux({ project: opts.project })),
project: opts.project,
projectName: opts.projectName,
@@ -7806,10 +7817,18 @@ function refuseInvalidSharedHelpers(opts: {
readonly cfg: Awaited>;
readonly json: boolean;
}): number | null {
- if (!opts.cfg.sharedHelpersError) {
+ let runnerError = opts.cfg.lifecycleRunnerError;
+ try {
+ resolveLifecycleRunner({ runner: opts.cfg.lifecycle?.runner });
+ } catch {
+ runnerError = "Invalid lifecycle runner; use mux or supervisor.";
+ }
+ if (!(opts.cfg.sharedHelpersError || runnerError)) {
return null;
}
- const message = `Invalid lifecycle.sharedHelpers; nothing was changed: ${opts.cfg.sharedHelpersError}`;
+ const message =
+ runnerError ??
+ `Invalid lifecycle.sharedHelpers; nothing was changed: ${opts.cfg.sharedHelpersError}`;
if (opts.json) {
return emitLifecycleResult({
result: errorResult({ code: "E_LIFECYCLE_FAILED", message }),
diff --git a/src/lib/lifecycle-runner.ts b/src/lib/lifecycle-runner.ts
new file mode 100644
index 000000000..96ac782bd
--- /dev/null
+++ b/src/lib/lifecycle-runner.ts
@@ -0,0 +1,35 @@
+export type LifecycleRunner = "mux" | "supervisor";
+
+export const LIFECYCLE_RUNNER_CONFIG_ERROR =
+ 'Invalid lifecycle.runner; expected "mux" or "supervisor". Values omitted.';
+
+/** Validate without coercion: a misspelled runner must never launch another one. */
+export function parseLifecycleRunner(value: unknown): LifecycleRunner | null {
+ return value === "mux" || value === "supervisor" ? value : null;
+}
+
+/** Explicit test/runtime overrides precede project config; omission keeps mux. */
+export function resolveLifecycleRunner(opts: {
+ readonly runner?: unknown;
+ readonly env?: Readonly>;
+}): LifecycleRunner {
+ const env = opts.env ?? process.env;
+ const override = env.HACK_LIFECYCLE_RUNNER;
+ if (override !== undefined) {
+ const runner = parseLifecycleRunner(override);
+ if (!runner) {
+ throw new Error(
+ 'Invalid HACK_LIFECYCLE_RUNNER; expected "mux" or "supervisor". Values omitted.'
+ );
+ }
+ return runner;
+ }
+ if (opts.runner === undefined) {
+ return "mux";
+ }
+ const runner = parseLifecycleRunner(opts.runner);
+ if (!runner) {
+ throw new Error(LIFECYCLE_RUNNER_CONFIG_ERROR);
+ }
+ return runner;
+}
diff --git a/src/lib/lifecycle-runtime.ts b/src/lib/lifecycle-runtime.ts
index debbea0f2..2ab06a580 100644
--- a/src/lib/lifecycle-runtime.ts
+++ b/src/lib/lifecycle-runtime.ts
@@ -1,15 +1,18 @@
+import { lstat } from "node:fs/promises";
import { resolve } from "node:path";
import { readLinesFromStream } from "../ui/lines.ts";
-import { pathExists, readTextFile, writeTextFile } from "./fs.ts";
import { getString, isRecord } from "./guards.ts";
import {
appendPrivateFile,
ensurePrivateDirectory,
openPrivateAppend,
PrivatePathRefusedError,
+ readReplaceablePrivateFile,
+ replacePrivateFileAtomic,
} from "./private-file.ts";
+import { withProjectsRegistryLock } from "./projects-registry-lock.ts";
-export type LifecycleBackend = "tmux" | "zellij";
+export type LifecycleBackend = "tmux" | "zellij" | "supervisor";
export type LifecycleStateProcess = {
readonly name: string;
@@ -18,6 +21,10 @@ export type LifecycleStateProcess = {
readonly panePid?: number;
readonly paneBirth?: string;
readonly processGroupId?: number;
+ readonly guardPid?: number;
+ readonly guardStartTime?: number;
+ readonly commandPid?: number;
+ readonly bootId?: string;
readonly processGroupBirth?: string;
};
@@ -31,6 +38,8 @@ export type LifecycleStateEntry = {
readonly definitionHash?: string;
readonly processes: readonly LifecycleStateProcess[];
readonly updatedAt: string;
+ readonly supervisorPid?: number;
+ readonly cleanupEpoch?: string;
};
type LifecycleStateFile = {
@@ -152,33 +161,57 @@ export async function ensurePrivateLifecycleLog(opts: {
export async function readLifecycleState(opts: {
readonly projectDir: string;
}): Promise {
- const path = resolveLifecycleStatePath({ projectDir: opts.projectDir });
- if (!(await pathExists(path))) {
+ const root = resolveLifecycleRootDir(opts);
+ const path = resolveLifecycleStatePath(opts);
+ const rootInfo = await lstat(root).catch((error: unknown) => {
+ if (isRecord(error) && error.code === "ENOENT") {
+ return null;
+ }
+ throw error;
+ });
+ if (!rootInfo) {
return [];
}
- const text = await readTextFile(path);
- if (!text) {
+ if (!rootInfo.isDirectory() || rootInfo.uid !== process.getuid?.()) {
+ throw lifecycleStateRefused();
+ }
+ await verifyLifecycleStateTarget(path);
+ const file = await readReplaceablePrivateFile({
+ path,
+ directories: [root],
+ maxBytes: 1024 * 1024,
+ refusal: lifecycleStateRefused,
+ });
+ if (file === null) {
return [];
}
+ await verifyLifecycleStateTarget(path);
+ const text = file.text;
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
- return [];
+ throw lifecycleStateRefused();
}
if (!isRecord(parsed)) {
- return [];
+ throw lifecycleStateRefused();
}
const entriesRaw = parsed.entries;
if (!Array.isArray(entriesRaw)) {
- return [];
+ throw lifecycleStateRefused();
}
const entries: LifecycleStateEntry[] = [];
for (const entry of entriesRaw) {
const parsedEntry = parseLifecycleStateEntry(entry);
- if (parsedEntry) {
- entries.push(parsedEntry);
+ if (
+ !parsedEntry ||
+ entries.some(
+ (current) => current.composeProject === parsedEntry.composeProject
+ )
+ ) {
+ throw lifecycleStateRefused();
}
+ entries.push(parsedEntry);
}
return entries;
}
@@ -187,18 +220,20 @@ export async function upsertLifecycleStateEntry(opts: {
readonly projectDir: string;
readonly entry: LifecycleStateEntry;
}): Promise {
- const state = await readLifecycleState({ projectDir: opts.projectDir });
- const withoutCurrent = state.filter(
- (entry) => entry.composeProject !== opts.entry.composeProject
- );
- const next: LifecycleStateFile = {
- entries: [...withoutCurrent, opts.entry].sort((a, b) =>
- a.composeProject.localeCompare(b.composeProject)
- ),
- };
- await writeLifecycleStateFile({
- projectDir: opts.projectDir,
- state: next,
+ return await mutateLifecycleState(opts.projectDir, async () => {
+ const state = await readLifecycleState({ projectDir: opts.projectDir });
+ const withoutCurrent = state.filter(
+ (entry) => entry.composeProject !== opts.entry.composeProject
+ );
+ const next: LifecycleStateFile = {
+ entries: [...withoutCurrent, opts.entry].sort((a, b) =>
+ a.composeProject.localeCompare(b.composeProject)
+ ),
+ };
+ await writeLifecycleStateFile({
+ projectDir: opts.projectDir,
+ state: next,
+ });
});
}
@@ -206,16 +241,18 @@ export async function removeLifecycleStateEntry(opts: {
readonly projectDir: string;
readonly composeProject: string;
}): Promise {
- const state = await readLifecycleState({ projectDir: opts.projectDir });
- const nextEntries = state.filter(
- (entry) => entry.composeProject !== opts.composeProject
- );
- if (nextEntries.length === state.length) {
- return;
- }
- await writeLifecycleStateFile({
- projectDir: opts.projectDir,
- state: { entries: nextEntries },
+ return await mutateLifecycleState(opts.projectDir, async () => {
+ const state = await readLifecycleState({ projectDir: opts.projectDir });
+ const nextEntries = state.filter(
+ (entry) => entry.composeProject !== opts.composeProject
+ );
+ if (nextEntries.length === state.length) {
+ return;
+ }
+ await writeLifecycleStateFile({
+ projectDir: opts.projectDir,
+ state: { entries: nextEntries },
+ });
});
}
@@ -224,50 +261,55 @@ export async function removeLifecycleStateEntryIfOwned(opts: {
readonly composeProject: string;
readonly ownershipToken: string;
}): Promise {
- const state = await readLifecycleState({ projectDir: opts.projectDir });
- const current = state.find(
- (entry) => entry.composeProject === opts.composeProject
- );
- if (current?.ownershipToken !== opts.ownershipToken) {
- return false;
- }
- await writeLifecycleStateFile({
- projectDir: opts.projectDir,
- state: {
- entries: state.filter(
- (entry) => entry.composeProject !== opts.composeProject
- ),
- },
+ return await mutateLifecycleState(opts.projectDir, async () => {
+ const state = await readLifecycleState({ projectDir: opts.projectDir });
+ const current = state.find(
+ (entry) => entry.composeProject === opts.composeProject
+ );
+ if (current?.ownershipToken !== opts.ownershipToken) {
+ return false;
+ }
+ await writeLifecycleStateFile({
+ projectDir: opts.projectDir,
+ state: {
+ entries: state.filter(
+ (entry) => entry.composeProject !== opts.composeProject
+ ),
+ },
+ });
+ return true;
});
- return true;
}
export async function removeLifecycleStateEntryIfMatching(opts: {
readonly projectDir: string;
readonly expectedEntry: LifecycleStateEntry;
}): Promise {
- const state = await readLifecycleState({ projectDir: opts.projectDir });
- const current = state.find(
- (entry) => entry.composeProject === opts.expectedEntry.composeProject
- );
- if (
- !current ||
- current.sessionName !== opts.expectedEntry.sessionName ||
- current.backend !== opts.expectedEntry.backend ||
- current.updatedAt !== opts.expectedEntry.updatedAt ||
- current.ownershipToken !== opts.expectedEntry.ownershipToken
- ) {
- return false;
- }
- await writeLifecycleStateFile({
- projectDir: opts.projectDir,
- state: {
- entries: state.filter(
- (entry) => entry.composeProject !== opts.expectedEntry.composeProject
- ),
- },
+ return await mutateLifecycleState(opts.projectDir, async () => {
+ const state = await readLifecycleState({ projectDir: opts.projectDir });
+ const current = state.find(
+ (entry) => entry.composeProject === opts.expectedEntry.composeProject
+ );
+ if (
+ !current ||
+ current.sessionName !== opts.expectedEntry.sessionName ||
+ current.backend !== opts.expectedEntry.backend ||
+ current.updatedAt !== opts.expectedEntry.updatedAt ||
+ current.cleanupEpoch !== opts.expectedEntry.cleanupEpoch ||
+ current.ownershipToken !== opts.expectedEntry.ownershipToken
+ ) {
+ return false;
+ }
+ await writeLifecycleStateFile({
+ projectDir: opts.projectDir,
+ state: {
+ entries: state.filter(
+ (entry) => entry.composeProject !== opts.expectedEntry.composeProject
+ ),
+ },
+ });
+ return true;
});
- return true;
}
function parseLifecycleStateEntry(value: unknown): LifecycleStateEntry | null {
@@ -286,7 +328,13 @@ function parseLifecycleStateEntry(value: unknown): LifecycleStateEntry | null {
) {
return null;
}
- if (!(backendRaw === "tmux" || backendRaw === "zellij")) {
+ if (
+ !(
+ backendRaw === "tmux" ||
+ backendRaw === "zellij" ||
+ backendRaw === "supervisor"
+ )
+ ) {
return null;
}
const branchRaw = value.branch;
@@ -301,10 +349,15 @@ function parseLifecycleStateEntry(value: unknown): LifecycleStateEntry | null {
const processes: LifecycleStateProcess[] = [];
for (const item of processesRaw) {
const parsed = parseLifecycleStateProcess(item);
- if (parsed) {
- processes.push(parsed);
+ if (!parsed) {
+ return null;
}
+ processes.push(parsed);
}
+ const supervisorPid = parseLifecycleOptionalPositiveInteger(
+ value.supervisorPid
+ );
+ const cleanupEpoch = getString(value, "cleanupEpoch")?.trim();
return {
composeProject,
projectName,
@@ -315,6 +368,8 @@ function parseLifecycleStateEntry(value: unknown): LifecycleStateEntry | null {
...(definitionHash ? { definitionHash } : {}),
processes,
updatedAt,
+ ...(supervisorPid ? { supervisorPid } : {}),
+ ...(cleanupEpoch ? { cleanupEpoch } : {}),
};
}
@@ -335,6 +390,12 @@ function parseLifecycleStateProcess(
const processGroupId = parseLifecycleOptionalPositiveInteger(
value.processGroupId
);
+ const guardPid = parseLifecycleOptionalPositiveInteger(value.guardPid);
+ const guardStartTime = parseLifecycleOptionalPositiveInteger(
+ value.guardStartTime
+ );
+ const commandPid = parseLifecycleOptionalPositiveInteger(value.commandPid);
+ const bootId = getString(value, "bootId");
const processGroupBirth = getString(value, "processGroupBirth")?.trim();
return {
name,
@@ -343,6 +404,10 @@ function parseLifecycleStateProcess(
...(panePid ? { panePid } : {}),
...(paneBirth ? { paneBirth } : {}),
...(processGroupId ? { processGroupId } : {}),
+ ...(guardPid ? { guardPid } : {}),
+ ...(guardStartTime ? { guardStartTime } : {}),
+ ...(commandPid ? { commandPid } : {}),
+ ...(bootId ? { bootId } : {}),
...(processGroupBirth ? { processGroupBirth } : {}),
};
}
@@ -366,8 +431,20 @@ async function writeLifecycleStateFile(opts: {
}): Promise {
const root = resolveLifecycleRootDir({ projectDir: opts.projectDir });
const path = resolveLifecycleStatePath({ projectDir: opts.projectDir });
+ const text = `${JSON.stringify(opts.state, null, 2)}\n`;
+ if (Buffer.byteLength(text) > 1024 * 1024) {
+ throw lifecycleStateRefused();
+ }
await ensurePrivateDirectory(root);
- await writeTextFile(path, `${JSON.stringify(opts.state, null, 2)}\n`);
+ await verifyLifecycleStateTarget(path);
+ await replacePrivateFileAtomic({
+ path,
+ directories: [root],
+ staging: "sibling",
+ text,
+ refusal: lifecycleStateRefused,
+ beforeRename: () => verifyLifecycleStateTarget(path),
+ });
}
function sanitizeLifecycleToken(value: string): string {
@@ -473,3 +550,40 @@ export async function drainLifecycleOutput(opts: {
});
}
}
+
+function lifecycleStateRefused(): Error {
+ return new Error(
+ "Lifecycle ownership state is invalid or unsafe; kept existing state. Values omitted."
+ );
+}
+
+async function verifyLifecycleStateTarget(path: string): Promise {
+ const info = await lstat(path).catch((error: unknown) => {
+ if (isRecord(error) && error.code === "ENOENT") {
+ return null;
+ }
+ throw error;
+ });
+ // A concurrent atomic replacement can unlink the observed inode before
+ // the asynchronous stat result is delivered (nlink=0). That is not an
+ // alias; the replaceable reader separately binds its actual opened file.
+ if (
+ info &&
+ (!info.isFile() || info.uid !== process.getuid?.() || info.nlink > 1)
+ ) {
+ throw lifecycleStateRefused();
+ }
+}
+
+async function mutateLifecycleState(
+ projectDir: string,
+ run: () => Promise
+): Promise {
+ const root = resolveLifecycleRootDir({ projectDir });
+ await ensurePrivateDirectory(root);
+ return await withProjectsRegistryLock({
+ lockPath: resolve(root, "state.lock"),
+ label: "Lifecycle state",
+ run,
+ });
+}
diff --git a/src/lib/lifecycle-supervisor.ts b/src/lib/lifecycle-supervisor.ts
new file mode 100644
index 000000000..48d7b44b1
--- /dev/null
+++ b/src/lib/lifecycle-supervisor.ts
@@ -0,0 +1,392 @@
+import { createHash } from "node:crypto";
+import { lstat } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { isRecord } from "./guards.ts";
+import { resolveLifecycleLogPath } from "./lifecycle-runtime.ts";
+import { ensurePrivateDirectory } from "./private-file.ts";
+import { withProjectsRegistryLock } from "./projects-registry-lock.ts";
+
+export type LifecycleSupervisorProcess = {
+ readonly name: string;
+ readonly argv: readonly string[];
+ readonly cwd: string;
+ readonly env: Readonly>;
+};
+
+export type LifecycleSupervisorState = {
+ readonly version: 1;
+ readonly name: string;
+ readonly ownerToken: string;
+ readonly guardPid: number;
+ readonly processGroupId: number;
+ readonly guardStartTime: number;
+ readonly bootId: string;
+ readonly uid: number;
+ readonly commandPid: number | null;
+ readonly phase: string;
+ readonly exitCode: number | null;
+ readonly signal: number | null;
+};
+
+export type LifecycleSupervisorReceipt = {
+ readonly version: 1;
+ readonly supervisorPid: number;
+ readonly outcome: "ready" | "observed" | "stopped" | "unknown" | "refused";
+ readonly processes: readonly LifecycleSupervisorState[];
+ readonly liveProcessNames: readonly string[];
+ readonly unknownProcessNames: readonly string[];
+};
+
+type Instance = {
+ readonly projectDir: string;
+ readonly composeProject: string;
+ readonly ownershipToken: string;
+};
+
+const LIMIT = 1024 * 1024;
+const REQUEST_TIMEOUT_MS = 35_000;
+
+/** The common log stays where existing lifecycle readers expect it. */
+export function resolveLifecycleSupervisorRoot(opts: {
+ readonly projectDir: string;
+ readonly composeProject: string;
+}): string {
+ return `${resolveLifecycleLogPath(opts)}.supervisor`;
+}
+
+/** Resolve only the current installation or an explicit test executable. */
+export function resolveLifecycleSupervisorBinary(): string {
+ const selected = process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY;
+ if (selected !== undefined) {
+ if (
+ !(selected.trim() && selected.startsWith("/")) ||
+ selected.includes("\0")
+ ) {
+ throw new Error(
+ "HACK_LIFECYCLE_SUPERVISOR_BINARY must name an absolute executable path."
+ );
+ }
+ return selected;
+ }
+ return Bun.main.startsWith("/$bunfs/")
+ ? resolve(dirname(process.execPath), "hack-lifecycle-supervisor")
+ : resolve(import.meta.dir, "../../dist/hack-lifecycle-supervisor");
+}
+
+/** Native observations, never a persisted `running` phase, establish liveness. */
+export async function inspectLifecycleSupervisor(
+ opts: Instance
+): Promise {
+ if (await lifecycleSupervisorRootAbsent(opts)) {
+ return {
+ version: 1,
+ supervisorPid: 0,
+ outcome: "observed",
+ processes: [],
+ liveProcessNames: [],
+ unknownProcessNames: [],
+ };
+ }
+ return await request({ ...opts, action: "status" });
+}
+
+/** A failed or unknown stop keeps the caller's ownership and dependency records. */
+export async function stopLifecycleSupervisor(
+ opts: Instance
+): Promise {
+ if (await lifecycleSupervisorRootAbsent(opts)) {
+ return {
+ version: 1,
+ supervisorPid: 0,
+ outcome: "stopped",
+ processes: [],
+ liveProcessNames: [],
+ unknownProcessNames: [],
+ };
+ }
+ return await request({ ...opts, action: "stop" });
+}
+
+async function lifecycleSupervisorRootAbsent(opts: Instance): Promise {
+ try {
+ await lstat(resolveLifecycleSupervisorRoot(opts));
+ return false;
+ } catch (error) {
+ if (isRecord(error) && error.code === "ENOENT") {
+ return true;
+ }
+ throw failure();
+ }
+}
+
+/** First launch creates the project owner; later launches preserve hook ordering. */
+export async function launchLifecycleSupervisorProcess(
+ opts: Instance & {
+ readonly action: "serve" | "add";
+ readonly process: LifecycleSupervisorProcess;
+ }
+): Promise {
+ return await request({ ...opts, processes: [opts.process] });
+}
+
+async function request(
+ opts: Instance & {
+ readonly action: "serve" | "add" | "status" | "stop";
+ readonly processes?: readonly LifecycleSupervisorProcess[];
+ }
+): Promise {
+ const payload = new TextEncoder().encode(
+ JSON.stringify({
+ version: 1,
+ root: resolveLifecycleSupervisorRoot(opts),
+ ownerToken: opts.ownershipToken,
+ ...(opts.processes
+ ? {
+ processes: opts.processes.map((process) => ({
+ ...process,
+ name: `service-${createHash("sha256").update(process.name).digest("hex")}`,
+ displayName: process.name,
+ })),
+ logPath: resolveLifecycleLogPath(opts),
+ }
+ : {}),
+ })
+ );
+ if (payload.length > LIMIT) {
+ payload.fill(0);
+ throw failure();
+ }
+ const frame = new Uint8Array(payload.length + 4);
+ new DataView(frame.buffer).setUint32(0, payload.length);
+ frame.set(payload, 4);
+ payload.fill(0);
+ let child: Bun.Subprocess<"pipe", "pipe", "ignore">;
+ try {
+ // The Rust owner calls setsid. `detached: true` would make it a group
+ // leader before that call and is deliberately absent here.
+ child = Bun.spawn([resolveLifecycleSupervisorBinary(), opts.action], {
+ env: {},
+ stdin: "pipe",
+ stdout: "pipe",
+ stderr: "ignore",
+ });
+ } catch {
+ frame.fill(0);
+ throw new Error(
+ "Lifecycle supervisor is unavailable; build or install it alongside Hack."
+ );
+ }
+ child.unref();
+ let timeout: ReturnType | undefined;
+ const deadline = new Promise((_, reject) => {
+ timeout = setTimeout(() => reject(failure()), REQUEST_TIMEOUT_MS);
+ });
+ const reader = child.stdout.getReader();
+ try {
+ const result = await Promise.race([
+ (async () => {
+ await child.stdin.write(frame);
+ await child.stdin.end();
+ const receipt = await readReceipt(reader);
+ if (
+ new Set(receipt.processes.map((state) => state.name)).size !==
+ receipt.processes.length ||
+ (opts.processes &&
+ (receipt.supervisorPid <= 1 ||
+ opts.processes.some(
+ (process) =>
+ !receipt.processes.some(
+ (state) => state.name === process.name
+ )
+ ))) ||
+ (opts.action === "stop" &&
+ (receipt.liveProcessNames.length > 0 ||
+ receipt.unknownProcessNames.length > 0))
+ ) {
+ throw failure();
+ }
+ if (
+ receipt.processes.some(
+ (state) => state.ownerToken !== opts.ownershipToken
+ ) ||
+ receipt.liveProcessNames.some(
+ (name) => !receipt.processes.some((state) => state.name === name)
+ ) ||
+ receipt.unknownProcessNames.some(
+ (name) => !receipt.processes.some((state) => state.name === name)
+ )
+ ) {
+ throw failure();
+ }
+ if (opts.action !== "serve") {
+ const exitCode = await child.exited;
+ if (exitCode !== 0) {
+ throw failure();
+ }
+ }
+ const expected = {
+ status: "observed",
+ stop: "stopped",
+ serve: "ready",
+ add: "ready",
+ }[opts.action];
+ if (receipt.outcome !== expected) {
+ throw failure();
+ }
+ return receipt;
+ })(),
+ deadline,
+ ]);
+ return result;
+ } catch {
+ try {
+ child.kill("SIGTERM");
+ } catch {
+ /* The finite client may already have exited. */
+ }
+ throw failure();
+ } finally {
+ if (timeout !== undefined) {
+ clearTimeout(timeout);
+ }
+ frame.fill(0);
+ await reader.cancel().catch(() => undefined);
+ reader.releaseLock();
+ }
+}
+
+/** Frame completion is explicit; a caller never waits for a detached owner's EOF. */
+async function readReceipt(
+ reader: ReadableStreamDefaultReader
+): Promise {
+ let bytes = new Uint8Array(0);
+ let length: number | undefined;
+ for (;;) {
+ const next = await reader.read();
+ if (next.done || bytes.length + next.value.length > LIMIT + 4) {
+ throw failure();
+ }
+ const combined = new Uint8Array(bytes.length + next.value.length);
+ combined.set(bytes);
+ combined.set(next.value, bytes.length);
+ bytes = combined;
+ if (length === undefined && bytes.length >= 4) {
+ length = new DataView(bytes.buffer).getUint32(0);
+ if (length === 0 || length > LIMIT) {
+ throw failure();
+ }
+ }
+ if (length !== undefined && bytes.length >= length + 4) {
+ const value: unknown = JSON.parse(
+ new TextDecoder("utf-8", { fatal: true }).decode(
+ bytes.subarray(4, length + 4)
+ )
+ );
+ return parseReceipt(value);
+ }
+ }
+}
+
+function integer(value: unknown, minimum = 0): value is number {
+ return (
+ typeof value === "number" && Number.isSafeInteger(value) && value >= minimum
+ );
+}
+function optionalInteger(value: unknown): number | null {
+ if (value === null || value === undefined) {
+ return null;
+ }
+ if (!integer(value)) {
+ throw failure();
+ }
+ return value;
+}
+function strings(value: unknown): readonly string[] {
+ if (
+ !(Array.isArray(value) && value.every((item) => typeof item === "string"))
+ ) {
+ throw failure();
+ }
+ return value;
+}
+function state(value: unknown): LifecycleSupervisorState {
+ if (
+ !isRecord(value) ||
+ value.version !== 1 ||
+ typeof value.name !== "string" ||
+ typeof value.ownerToken !== "string" ||
+ !integer(value.guardPid, 2) ||
+ value.processGroupId !== value.guardPid ||
+ !integer(value.guardStartTime, 1) ||
+ typeof value.bootId !== "string" ||
+ !integer(value.uid) ||
+ typeof value.phase !== "string"
+ ) {
+ throw failure();
+ }
+ return {
+ version: 1,
+ name:
+ typeof value.displayName === "string" ? value.displayName : value.name,
+ ownerToken: value.ownerToken,
+ guardPid: value.guardPid,
+ processGroupId: value.guardPid,
+ guardStartTime: value.guardStartTime,
+ bootId: value.bootId,
+ uid: value.uid,
+ commandPid: optionalInteger(value.commandPid),
+ phase: value.phase,
+ exitCode: optionalInteger(value.exitCode),
+ signal: optionalInteger(value.signal),
+ };
+}
+function parseReceipt(value: unknown): LifecycleSupervisorReceipt {
+ if (
+ !isRecord(value) ||
+ value.version !== 1 ||
+ !integer(value.supervisorPid) ||
+ !Array.isArray(value.processes)
+ ) {
+ throw failure();
+ }
+ const outcome = value.outcome;
+ if (
+ outcome !== "ready" &&
+ outcome !== "observed" &&
+ outcome !== "stopped" &&
+ outcome !== "unknown" &&
+ outcome !== "refused"
+ ) {
+ throw failure();
+ }
+ return {
+ version: 1,
+ supervisorPid: value.supervisorPid,
+ outcome,
+ processes: value.processes.map(state),
+ liveProcessNames: strings(value.liveProcessNames),
+ unknownProcessNames: strings(value.unknownProcessNames),
+ };
+}
+function failure(): Error {
+ return new Error(
+ "Lifecycle supervisor could not confirm the operation; ownership was retained. Values omitted."
+ );
+}
+
+/** Serialize CLI publication and native effects for one instance. Native locks
+ * remain independent and protect the binary's own state after CLI loss. */
+export async function withLifecycleSupervisorControllerLock(opts: {
+ readonly projectDir: string;
+ readonly composeProject: string;
+ readonly run: () => Promise;
+}): Promise {
+ const logPath = resolveLifecycleLogPath(opts);
+ await ensurePrivateDirectory(dirname(logPath));
+ return await withProjectsRegistryLock({
+ lockPath: `${logPath}.controller.lock`,
+ label: "lifecycle supervisor controller",
+ timeoutMs: 40_000,
+ run: opts.run,
+ });
+}
diff --git a/src/lib/project-lifecycle-controller.ts b/src/lib/project-lifecycle-controller.ts
index 226a862a7..6893c6360 100644
--- a/src/lib/project-lifecycle-controller.ts
+++ b/src/lib/project-lifecycle-controller.ts
@@ -24,6 +24,10 @@ import {
resolveLifecycleLogPath,
upsertLifecycleStateEntry,
} from "./lifecycle-runtime.ts";
+import {
+ stopLifecycleSupervisor,
+ withLifecycleSupervisorControllerLock,
+} from "./lifecycle-supervisor.ts";
import { readProcessIdentities } from "./process-tree.ts";
import {
type ProjectLifecycleCommand,
@@ -49,6 +53,7 @@ import {
inspectListeningTcpPorts,
resolveLifecycleSingletonDecision,
} from "./project-lifecycle-singleton.ts";
+import { createSupervisorLifecycleController } from "./project-lifecycle-supervisor.ts";
import type { SharedHelperSessionOwnership } from "./project-shared-helpers.ts";
import { exec } from "./shell.ts";
export type LifecycleControllerProject = {
@@ -143,6 +148,16 @@ async function reconcileChangedLifecycleBackend(opts: {
if (!opts.entry || opts.entry.backend === opts.resolvedBackend) {
return opts.entry;
}
+ if (opts.entry.backend === "supervisor") {
+ await stopLifecycleProcessController({
+ project: { projectRoot: opts.projectRoot, projectDir: opts.projectDir },
+ projectName: opts.entry.projectName,
+ branch: opts.entry.branch,
+ composeProject: opts.composeProject,
+ definitionHash: opts.definitionHash,
+ });
+ return null;
+ }
const previousBackend = opts.mux.backends.get(opts.entry.backend);
if (!previousBackend?.available) {
throw new Error(
@@ -225,6 +240,7 @@ function describeSessionCreateFailure(opts: {
/** The shared Hack mux owner. Callers supply selection; this owner retains session/token checks. */
export function createLifecycleProcessController(opts: {
+ readonly runner?: "mux" | "supervisor";
readonly mux: () => Promise;
readonly project: LifecycleControllerProject;
readonly projectName: string;
@@ -278,6 +294,16 @@ export function createLifecycleProcessController(opts: {
getOperationCleanup: () => LifecycleOperationCleanup | null;
getSignalCleanup: () => { readonly dispose: () => void } | null;
} {
+ if (opts.runner === "supervisor") {
+ return createSupervisorLifecycleController({
+ ...opts,
+ stopExisting: () =>
+ stopLifecycleProcessController({
+ ...opts,
+ supervisorControllerLocked: true,
+ }),
+ });
+ }
const sessionName = resolveLifecycleSessionName({
projectName: opts.projectName,
branch: opts.branch,
@@ -902,7 +928,7 @@ export class LifecycleSessionDisplacedError extends Error {
}
}
-async function resolveLifecycleSingletonDecisionForProcess(opts: {
+export async function resolveLifecycleSingletonDecisionForProcess(opts: {
readonly process: ProjectLifecycleProcess;
readonly projectDir: string;
readonly composeProject: string;
@@ -1099,6 +1125,8 @@ async function startLifecycleProcess(opts: {
}
export async function stopLifecycleProcessController(opts: {
+ /** Internal: the caller already serializes this supervisor transition. */
+ readonly supervisorControllerLocked?: boolean;
readonly project: LifecycleControllerProject;
readonly definitionHash: string;
readonly projectName: string;
@@ -1124,6 +1152,10 @@ export async function stopLifecycleProcessController(opts: {
(entry) => entry.composeProject === opts.composeProject
) ?? null;
+ if (lifecycleEntry?.backend === "supervisor") {
+ await stopSupervisorEntry(opts, lifecycleEntry);
+ return;
+ }
const backend = lifecycleEntry
? (opts.mux ? (await opts.mux()).backends : getMuxBackends()).get(
lifecycleEntry.backend
@@ -1206,6 +1238,40 @@ export async function stopLifecycleProcessController(opts: {
}
}
+async function stopSupervisorEntry(
+ opts: Parameters[0],
+ lifecycleEntry: LifecycleStateEntry
+): Promise {
+ if (!opts.supervisorControllerLocked) {
+ return await withLifecycleSupervisorControllerLock({
+ projectDir: opts.project.projectDir,
+ composeProject: opts.composeProject,
+ run: () =>
+ stopLifecycleProcessController({
+ ...opts,
+ supervisorControllerLocked: true,
+ }),
+ });
+ }
+ if (!lifecycleEntry.ownershipToken) {
+ throw new Error(
+ "Lifecycle supervisor ownership is missing; cleanup was refused."
+ );
+ }
+ await opts.assertFresh?.();
+ await stopLifecycleSupervisor({
+ projectDir: opts.project.projectDir,
+ composeProject: opts.composeProject,
+ ownershipToken: lifecycleEntry.ownershipToken,
+ });
+ await opts.assertFresh?.();
+ await removeLifecycleStateEntryIfOwned({
+ projectDir: opts.project.projectDir,
+ composeProject: opts.composeProject,
+ ownershipToken: lifecycleEntry.ownershipToken,
+ });
+}
+
/** Missing mux state grants no authority to kill a possibly reused group ID. */
async function assertAbsentSessionGroupsRetired(opts: {
readonly entry: LifecycleStateEntry;
diff --git a/src/lib/project-lifecycle-hygiene.ts b/src/lib/project-lifecycle-hygiene.ts
index d6a86ab10..1fc940294 100644
--- a/src/lib/project-lifecycle-hygiene.ts
+++ b/src/lib/project-lifecycle-hygiene.ts
@@ -10,6 +10,11 @@ import {
readLifecycleState,
removeLifecycleStateEntryIfMatching,
} from "./lifecycle-runtime.ts";
+import {
+ inspectLifecycleSupervisor,
+ type LifecycleSupervisorReceipt,
+ stopLifecycleSupervisor,
+} from "./lifecycle-supervisor.ts";
import { readProcessIdentities } from "./process-tree.ts";
import {
createLifecycleSessionRetirement,
@@ -80,8 +85,12 @@ export async function inspectProjectLifecycleHygiene(opts: {
const [sessionsByBackend, snapshot, activeComposeProjects] =
await Promise.all([
- listSessionsByBackend(),
- readProcessSnapshot(),
+ entries.some((entry) => entry.backend !== "supervisor")
+ ? listSessionsByBackend()
+ : Promise.resolve(new Map>()),
+ entries.some((entry) => entry.backend !== "supervisor")
+ ? readProcessSnapshot()
+ : Promise.resolve([]),
readActiveComposeProjects({ projectDir: opts.projectDir }),
]);
const stateInspection = inspectLifecycleStateEntries({
@@ -94,14 +103,44 @@ export async function inspectProjectLifecycleHygiene(opts: {
projectRoot: opts.projectRoot,
});
const sessionHygiene = classifyLifecycleSessionHygiene({
- entries,
+ entries: entries.filter((entry) => entry.backend !== "supervisor"),
sessionInspections,
activeComposeProjects,
});
+ const supervisorReceipts = new Map<
+ string,
+ LifecycleSupervisorReceipt | null
+ >();
+ for (const entry of entries.filter((item) => item.backend === "supervisor")) {
+ const receipt = entry.ownershipToken
+ ? await inspectLifecycleSupervisor({
+ projectDir: opts.projectDir,
+ composeProject: entry.composeProject,
+ ownershipToken: entry.ownershipToken,
+ }).catch(() => null)
+ : null;
+ supervisorReceipts.set(entry.composeProject, receipt);
+ }
+ const supervisorInspection = classifySupervisorLifecycleHygiene({
+ entries,
+ receipts: supervisorReceipts,
+ activeComposeProjects,
+ });
return {
- ...stateInspection,
- ...sessionHygiene,
+ staleEntries: [
+ ...stateInspection.staleEntries,
+ ...supervisorInspection.staleEntries,
+ ],
+ orphanedProcessGroups: stateInspection.orphanedProcessGroups,
+ orphanedSessions: [
+ ...sessionHygiene.orphanedSessions,
+ ...supervisorInspection.orphanedSessions,
+ ],
+ unverifiedSessions: [
+ ...sessionHygiene.unverifiedSessions,
+ ...supervisorInspection.unverifiedSessions,
+ ],
runtimeAvailable: activeComposeProjects !== null,
};
}
@@ -119,6 +158,11 @@ export function inspectLifecycleStateEntries(opts: {
const orphanedProcessGroups = new Set();
for (const entry of opts.entries) {
+ // Native status owns the supervisor's identity/liveness proof. Absence
+ // from a mux inventory never grants stale-state or cleanup authority.
+ if (entry.backend === "supervisor") {
+ continue;
+ }
const sessions = opts.sessionsByBackend.get(entry.backend);
const sessionPresent = sessions?.has(entry.sessionName) ?? false;
if (sessionPresent) {
@@ -150,6 +194,61 @@ export function inspectLifecycleStateEntries(opts: {
};
}
+/** Classify native read-only evidence without treating unknown as absence. */
+export function classifySupervisorLifecycleHygiene(opts: {
+ readonly entries: readonly LifecycleStateEntry[];
+ readonly receipts: ReadonlyMap;
+ readonly activeComposeProjects: ReadonlySet | null;
+ readonly now?: Date;
+}): Pick<
+ LifecycleHygieneInspection,
+ "staleEntries" | "orphanedSessions" | "unverifiedSessions"
+> {
+ const staleEntries: StaleLifecycleStateEntry[] = [];
+ const orphanedSessions: OrphanedLifecycleSession[] = [];
+ const unverifiedSessions: UnverifiedLifecycleSession[] = [];
+ for (const entry of opts.entries) {
+ if (entry.backend !== "supervisor") {
+ continue;
+ }
+ const receipt = opts.receipts.get(entry.composeProject);
+ if (
+ !receipt ||
+ receipt.unknownProcessNames.length > 0 ||
+ receipt.outcome !== "observed"
+ ) {
+ unverifiedSessions.push({
+ entry,
+ reason:
+ "Lifecycle supervisor ownership or liveness could not be verified.",
+ });
+ continue;
+ }
+ if (receipt.liveProcessNames.length === 0) {
+ staleEntries.push({ entry, liveProcessGroups: [] });
+ continue;
+ }
+ if (
+ opts.activeComposeProjects === null ||
+ opts.activeComposeProjects.has(entry.composeProject)
+ ) {
+ continue;
+ }
+ const age =
+ (opts.now ?? new Date()).getTime() - Date.parse(entry.updatedAt);
+ if (!Number.isFinite(age) || age < LIFECYCLE_SESSION_ORPHAN_GRACE_MS) {
+ unverifiedSessions.push({
+ entry,
+ reason:
+ "Lifecycle supervisor is recent enough to belong to an in-flight startup.",
+ });
+ continue;
+ }
+ orphanedSessions.push({ entry, classification: "owned-healthy" });
+ }
+ return { staleEntries, orphanedSessions, unverifiedSessions };
+}
+
export function classifyLifecycleSessionHygiene(opts: {
readonly entries: readonly LifecycleStateEntry[];
readonly sessionInspections: ReadonlyMap;
@@ -220,6 +319,8 @@ export async function repairProjectLifecycleSessions(opts: {
readonly retireSession?: ReturnType<
typeof createLifecycleSessionRetirement
>;
+ readonly inspectSupervisor?: typeof inspectLifecycleSupervisor;
+ readonly stopSupervisor?: typeof stopLifecycleSupervisor;
};
}): Promise {
const {
@@ -229,6 +330,8 @@ export async function repairProjectLifecycleSessions(opts: {
readPanes = readRepairPanes,
readIdentities = readProcessIdentities,
retireSession,
+ inspectSupervisor = inspectLifecycleSupervisor,
+ stopSupervisor = stopLifecycleSupervisor,
} = opts.boundary ?? {};
const initial = await inspect(opts);
const repairedSessions: string[] = [];
@@ -245,6 +348,25 @@ export async function repairProjectLifecycleSessions(opts: {
);
continue;
}
+ if (!sameRepairEntry(candidate.entry, orphan.entry)) {
+ failures.push(
+ `${candidate.entry.sessionName}: lifecycle generation changed before repair`
+ );
+ continue;
+ }
+ if (orphan.entry.backend === "supervisor") {
+ const result = await repairSupervisorSession({
+ projectDir: opts.projectDir,
+ projectRoot: opts.projectRoot,
+ entry: orphan.entry,
+ inspect,
+ inspectSupervisor,
+ stopSupervisor,
+ });
+ repairedSessions.push(...result.repairedSessions);
+ failures.push(...result.failures);
+ continue;
+ }
const backend = backends().get(orphan.entry.backend);
if (!backend?.available) {
failures.push(`${orphan.entry.sessionName}: mux backend unavailable`);
@@ -257,10 +379,7 @@ export async function repairProjectLifecycleSessions(opts: {
expectedProjectRoot: opts.projectRoot,
expectedDefinitionHash: orphan.entry.definitionHash ?? "",
});
- if (
- inspection.decision.kind === "block" ||
- inspection.decision.kind === "create"
- ) {
+ if (["block", "create"].includes(inspection.decision.kind)) {
failures.push(
`${orphan.entry.sessionName}: ownership proof changed before repair`
);
@@ -309,6 +428,89 @@ export async function repairProjectLifecycleSessions(opts: {
return { repairedSessions, failures };
}
+async function repairSupervisorSession(opts: {
+ readonly projectDir: string;
+ readonly projectRoot: string;
+ readonly entry: LifecycleStateEntry;
+ readonly inspect: typeof inspectProjectLifecycleHygiene;
+ readonly inspectSupervisor: typeof inspectLifecycleSupervisor;
+ readonly stopSupervisor: typeof stopLifecycleSupervisor;
+}): Promise {
+ try {
+ const ownershipToken = opts.entry.ownershipToken;
+ if (!ownershipToken) {
+ throw new Error("Lifecycle supervisor ownership token is missing");
+ }
+ const control = {
+ projectDir: opts.projectDir,
+ composeProject: opts.entry.composeProject,
+ ownershipToken,
+ };
+ const observed = await opts.inspectSupervisor(control);
+ if (
+ observed.outcome !== "observed" ||
+ observed.unknownProcessNames.length > 0
+ ) {
+ throw new Error("Lifecycle supervisor ownership is unverified");
+ }
+ // Native observation can wait on a process census. Refresh runtime absence
+ // and startup grace after that wait, then fence the exact stored generation.
+ const fresh = await opts.inspect({
+ projectDir: opts.projectDir,
+ projectRoot: opts.projectRoot,
+ });
+ if (
+ !fresh.orphanedSessions.some((orphan) =>
+ sameRepairEntry(orphan.entry, opts.entry)
+ )
+ ) {
+ throw new Error("Lifecycle supervisor is no longer runtime-orphaned");
+ }
+ await assertRepairEntryCurrent({
+ projectDir: opts.projectDir,
+ entry: opts.entry,
+ });
+ const stopped = await opts.stopSupervisor(control);
+ if (
+ stopped.outcome !== "stopped" ||
+ stopped.liveProcessNames.length > 0 ||
+ stopped.unknownProcessNames.length > 0
+ ) {
+ throw new Error("Lifecycle supervisor stop was not confirmed");
+ }
+ if (
+ !(await removeLifecycleStateEntryIfMatching({
+ projectDir: opts.projectDir,
+ expectedEntry: opts.entry,
+ }))
+ ) {
+ throw new Error("Lifecycle state changed after supervisor stop");
+ }
+ return { repairedSessions: [opts.entry.sessionName], failures: [] };
+ } catch {
+ return {
+ repairedSessions: [],
+ failures: [
+ `${opts.entry.sessionName}: supervisor retirement unconfirmed; kept ownership state`,
+ ],
+ };
+ }
+}
+
+function sameRepairEntry(
+ left: LifecycleStateEntry,
+ right: LifecycleStateEntry
+): boolean {
+ return (
+ left.composeProject === right.composeProject &&
+ left.backend === right.backend &&
+ left.sessionName === right.sessionName &&
+ left.ownershipToken === right.ownershipToken &&
+ left.cleanupEpoch === right.cleanupEpoch &&
+ left.updatedAt === right.updatedAt
+ );
+}
+
function repairFailureMessage(error: unknown): string {
return error instanceof Error
? error.message
@@ -436,6 +638,7 @@ async function assertRepairEntryCurrent(opts: {
current.sessionName !== opts.entry.sessionName ||
current.backend !== opts.entry.backend ||
current.updatedAt !== opts.entry.updatedAt ||
+ current.cleanupEpoch !== opts.entry.cleanupEpoch ||
current.ownershipToken !== opts.entry.ownershipToken
) {
throw new Error("Lifecycle ownership changed before repair retirement");
@@ -446,9 +649,15 @@ async function inspectOwnedLifecycleSessions(opts: {
readonly entries: readonly LifecycleStateEntry[];
readonly projectRoot: string;
}): Promise> {
- const backends = getMuxBackends();
const inspections = new Map();
+ if (!opts.entries.some((entry) => entry.backend !== "supervisor")) {
+ return inspections;
+ }
+ const backends = getMuxBackends();
for (const entry of opts.entries) {
+ if (entry.backend === "supervisor") {
+ continue;
+ }
const backend = backends.get(entry.backend);
if (!backend?.available) {
continue;
diff --git a/src/lib/project-lifecycle-supervisor.ts b/src/lib/project-lifecycle-supervisor.ts
new file mode 100644
index 000000000..223f763ee
--- /dev/null
+++ b/src/lib/project-lifecycle-supervisor.ts
@@ -0,0 +1,420 @@
+import { dirname } from "node:path";
+import { logger } from "../ui/logger.ts";
+import { childEnvironment } from "./child-environment.ts";
+import {
+ ensurePrivateLifecycleLog,
+ type LifecycleStateEntry,
+ type LifecycleStateProcess,
+ readLifecycleState,
+ removeLifecycleStateEntryIfOwned,
+ resolveLifecycleLogPath,
+ upsertLifecycleStateEntry,
+} from "./lifecycle-runtime.ts";
+import {
+ inspectLifecycleSupervisor,
+ type LifecycleSupervisorReceipt,
+ launchLifecycleSupervisorProcess,
+ resolveLifecycleSupervisorRoot,
+ stopLifecycleSupervisor,
+ withLifecycleSupervisorControllerLock,
+} from "./lifecycle-supervisor.ts";
+import { ensurePrivateDirectory } from "./private-file.ts";
+import type { ProjectLifecycleProcess } from "./project.ts";
+import {
+ type createLifecycleProcessController,
+ installLifecycleSignalCleanup,
+ LIFECYCLE_SESSION_RETAINED,
+ LifecycleSessionDisplacedError,
+ resolveLifecycleCwd,
+ resolveLifecycleSessionName,
+ resolveLifecycleSingletonDecisionForProcess,
+} from "./project-lifecycle-controller.ts";
+import { createLifecycleOwnershipToken } from "./project-lifecycle-sessions.ts";
+
+type Options = Parameters[0];
+type Controller = ReturnType;
+
+/** The native owner preserves the mux controller's adoption and rollback fences. */
+export function createSupervisorLifecycleController(
+ opts: Options & { readonly stopExisting: () => Promise }
+): Controller {
+ const sessionName = resolveLifecycleSessionName(opts);
+ const logPath = resolveLifecycleLogPath({
+ projectDir: opts.project.projectDir,
+ composeProject: opts.composeProject,
+ });
+ const instance = {
+ projectDir: opts.project.projectDir,
+ composeProject: opts.composeProject,
+ };
+ let token: string | null = null;
+ let created = false;
+ let decided = false;
+ let ownerPid: number | undefined;
+ let cleanupEpoch: string | undefined;
+ let processes: readonly LifecycleStateProcess[] = [];
+ let signals: ReturnType | null = null;
+ let mutation: Promise = Promise.resolve();
+
+ async function underLock(run: () => Promise): Promise {
+ const locked = () => withControllerLock(run);
+ return opts.sessionOwnership
+ ? await opts.sessionOwnership.withLock(locked)
+ : await locked();
+ }
+ async function withControllerLock(run: () => Promise): Promise {
+ return await withLifecycleSupervisorControllerLock({ ...instance, run });
+ }
+ async function current(): Promise {
+ return (await readLifecycleState(instance)).find(
+ (entry) => entry.composeProject === opts.composeProject
+ );
+ }
+ async function assertOwned(): Promise {
+ await opts.assertFresh?.();
+ const observed = await current();
+ if (
+ !token ||
+ observed?.ownershipToken !== token ||
+ observed.cleanupEpoch !== cleanupEpoch
+ ) {
+ created = false;
+ throw new LifecycleSessionDisplacedError(sessionName);
+ }
+ }
+ async function publish(): Promise {
+ if (!token) {
+ return;
+ }
+ await assertOwned();
+ await upsertLifecycleStateEntry({
+ projectDir: instance.projectDir,
+ entry: entry(),
+ });
+ }
+ function entry(): LifecycleStateEntry {
+ return {
+ composeProject: opts.composeProject,
+ projectName: opts.projectName,
+ branch: opts.branch,
+ sessionName,
+ backend: "supervisor",
+ ownershipToken: token ?? undefined,
+ definitionHash: opts.definitionHash,
+ processes,
+ updatedAt: new Date().toISOString(),
+ ...(ownerPid ? { supervisorPid: ownerPid } : {}),
+ ...(cleanupEpoch ? { cleanupEpoch } : {}),
+ };
+ }
+ async function decide(): Promise {
+ if (decided) {
+ return;
+ }
+ await underLock(async () => {
+ await opts.assertFresh?.();
+ const existing = await current();
+ if (existing) {
+ if (existing.backend === "supervisor" && (await adopt(existing))) {
+ decided = true;
+ return;
+ }
+ await opts.stopExisting();
+ }
+ decided = true;
+ });
+ }
+ async function adopt(existing: LifecycleStateEntry): Promise {
+ if (!existing.ownershipToken) {
+ throw new Error(
+ "Lifecycle supervisor ownership is missing; nothing was started."
+ );
+ }
+ const status = await inspectLifecycleSupervisor({
+ ...instance,
+ ownershipToken: existing.ownershipToken,
+ });
+ if (status.unknownProcessNames.length > 0) {
+ throw new Error(
+ "Lifecycle supervisor identity could not be verified; ownership was retained."
+ );
+ }
+ if (canAdopt(existing, status)) {
+ token = existing.ownershipToken;
+ processes = existing.processes;
+ ownerPid = existing.supervisorPid;
+ cleanupEpoch = createLifecycleOwnershipToken();
+ await upsertLifecycleStateEntry({
+ projectDir: instance.projectDir,
+ entry: entry(),
+ });
+ await opts.sessionOwnership?.record("adopted");
+ return true;
+ }
+ return false;
+ }
+ function canAdopt(
+ existing: LifecycleStateEntry,
+ status: LifecycleSupervisorReceipt
+ ): boolean {
+ return (
+ existing.definitionHash === opts.definitionHash &&
+ existing.processes.length > 0 &&
+ existing.processes.every((process) =>
+ status.liveProcessNames.includes(process.name)
+ )
+ );
+ }
+ async function begin(): Promise {
+ if (token) {
+ return;
+ }
+ // decide() may have released the lock while a singleton probe ran. Never
+ // publish a new token over a start that won that interval.
+ if (await current()) {
+ throw new LifecycleSessionDisplacedError(sessionName);
+ }
+ await ensurePrivateLifecycleLog({ root: dirname(logPath), logPath });
+ await ensurePrivateDirectory(resolveLifecycleSupervisorRoot(instance));
+ token = createLifecycleOwnershipToken();
+ cleanupEpoch = createLifecycleOwnershipToken();
+ created = true;
+ signals = (opts.installSignalCleanup ?? installLifecycleSignalCleanup)({
+ cleanup: abort,
+ });
+ // Publication precedes even the native owner's startup. Every error after
+ // this point leaves an owner token that down can use to inspect/retire it.
+ await upsertLifecycleStateEntry({
+ projectDir: instance.projectDir,
+ entry: entry(),
+ });
+ await opts.sessionOwnership?.record("created");
+ }
+ function accept(receipt: LifecycleSupervisorReceipt): void {
+ if (receipt.supervisorPid > 1) {
+ ownerPid = receipt.supervisorPid;
+ }
+ processes = receipt.processes.map((process) => ({
+ name: process.name,
+ windowName: process.name,
+ logPath,
+ processGroupId: process.processGroupId,
+ guardPid: process.guardPid,
+ guardStartTime: process.guardStartTime,
+ bootId: process.bootId,
+ ...(process.commandPid ? { commandPid: process.commandPid } : {}),
+ }));
+ }
+ async function start(process: ProjectLifecycleProcess): Promise {
+ await decide();
+ if (processes.some((started) => started.name === process.name)) {
+ await underLock(assertOwned);
+ return;
+ }
+ const singleton = await resolveLifecycleSingletonDecisionForProcess({
+ process,
+ projectDir: instance.projectDir,
+ composeProject: opts.composeProject,
+ });
+ if (singleton.kind === "adopt") {
+ logger.info({ message: singleton.message });
+ return;
+ }
+ if (singleton.kind === "fail") {
+ throw new Error(singleton.message);
+ }
+ if (opts.prepareLaunch) {
+ throw new Error(
+ "Lifecycle supervisor does not support this launch adapter."
+ );
+ }
+ await underLock(async () => {
+ await begin();
+ await assertOwned();
+ if (!token) {
+ throw new Error("Lifecycle supervisor ownership was not established.");
+ }
+ const launch = {
+ ...instance,
+ ownershipToken: token,
+ process: {
+ name: process.name,
+ argv: ["/bin/sh", "-c", process.command],
+ cwd: resolveLifecycleCwd({
+ projectRoot: opts.project.projectRoot,
+ cwd: process.cwd,
+ }),
+ env: childEnvironment(undefined, opts.env),
+ },
+ };
+ // An owner can naturally retire between hooks. Retry serve only after
+ // inspecting native receipts; never infer absence from a socket error.
+ let receipt: LifecycleSupervisorReceipt;
+ if (processes.length === 0) {
+ receipt = await launchLifecycleSupervisorProcess({
+ ...launch,
+ action: "serve",
+ });
+ } else {
+ try {
+ receipt = await launchLifecycleSupervisorProcess({
+ ...launch,
+ action: "add",
+ });
+ } catch {
+ receipt = await reconcileAdd(launch);
+ }
+ }
+ accept(receipt);
+ await publish();
+ });
+ }
+ async function reconcileAdd(
+ launch: Omit<
+ Parameters[0],
+ "action"
+ >
+ ): Promise {
+ await assertOwned();
+ const status = await inspectLifecycleSupervisor({
+ ...instance,
+ ownershipToken: launch.ownershipToken,
+ });
+ if (status.liveProcessNames.includes(launch.process.name)) {
+ accept(status);
+ await publish();
+ throw new Error(
+ "Lifecycle supervisor launch completed after its reply was lost; ownership was retained."
+ );
+ }
+ if (
+ status.liveProcessNames.length > 0 ||
+ status.unknownProcessNames.length > 0
+ ) {
+ throw new Error(
+ "Lifecycle supervisor launch could not be confirmed; ownership was retained."
+ );
+ }
+ return await serveAfterAbsence(launch);
+ }
+ async function serveAfterAbsence(
+ launch: Omit<
+ Parameters[0],
+ "action"
+ >
+ ): Promise {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ await assertOwned();
+ try {
+ return await launchLifecycleSupervisorProcess({
+ ...launch,
+ action: "serve",
+ });
+ } catch {
+ const status = await inspectLifecycleSupervisor({
+ ...instance,
+ ownershipToken: launch.ownershipToken,
+ });
+ if (
+ status.liveProcessNames.length ||
+ status.unknownProcessNames.length ||
+ attempt === 2
+ ) {
+ throw new Error(
+ "Lifecycle supervisor launch could not be confirmed; ownership was retained."
+ );
+ }
+ await Bun.sleep(50);
+ }
+ }
+ throw new Error("Lifecycle supervisor launch could not be confirmed.");
+ }
+ async function stopOwned(): Promise<
+ undefined | typeof LIFECYCLE_SESSION_RETAINED
+ > {
+ if (!(created && token)) {
+ return undefined;
+ }
+ if ((await current())?.cleanupEpoch !== cleanupEpoch) {
+ created = false;
+ return undefined;
+ }
+ await assertOwned();
+ try {
+ await stopLifecycleSupervisor({ ...instance, ownershipToken: token });
+ await assertOwned();
+ await removeLifecycleStateEntryIfOwned({
+ ...instance,
+ ownershipToken: token,
+ });
+ created = false;
+ return undefined;
+ } catch {
+ logger.warn({
+ message:
+ "Could not confirm lifecycle supervisor shutdown; ownership and dependencies were retained. Run hack down to retry.",
+ });
+ return LIFECYCLE_SESSION_RETAINED;
+ }
+ }
+ async function abort(): Promise<
+ undefined | typeof LIFECYCLE_SESSION_RETAINED
+ > {
+ await mutation.catch(() => undefined);
+ if (!created) {
+ return undefined;
+ }
+ if (!opts.sessionOwnership) {
+ return await withControllerLock(stopOwned);
+ }
+ let stopped: undefined | typeof LIFECYCLE_SESSION_RETAINED;
+ let result: Awaited<
+ ReturnType["runIfOwned"]>
+ >;
+ try {
+ result = await opts.sessionOwnership.runIfOwned(async () => {
+ stopped = await withControllerLock(stopOwned);
+ });
+ } catch {
+ result = "undecidable";
+ }
+ if (result === "adopted") {
+ created = false;
+ return undefined;
+ }
+ return result === "undecidable" ? LIFECYCLE_SESSION_RETAINED : stopped;
+ }
+ async function startTracked(process: ProjectLifecycleProcess): Promise {
+ const pending = start(process);
+ mutation = pending;
+ await pending;
+ }
+ return {
+ sessionName,
+ startFromCommand: ({ command, serviceName }) =>
+ startTracked({
+ name: serviceName,
+ command: command.command,
+ ...(command.cwd ? { cwd: command.cwd } : {}),
+ ...(command.singleton ? { singleton: command.singleton } : {}),
+ }),
+ startMany: async ({ processes }) => {
+ await decide();
+ for (const process of processes) {
+ await startTracked(process);
+ }
+ },
+ finalize: async () => {
+ await underLock(publish);
+ },
+ abort: async () => {
+ const result = await abort();
+ signals?.dispose();
+ return result;
+ },
+ hasStarted: () => processes.length > 0,
+ getOperationCleanup: () =>
+ created ? () => withControllerLock(stopOwned) : null,
+ getSignalCleanup: () => signals,
+ };
+}
diff --git a/src/lib/project.ts b/src/lib/project.ts
index 885ec255c..e2aa7b1f2 100644
--- a/src/lib/project.ts
+++ b/src/lib/project.ts
@@ -13,6 +13,11 @@ import {
import { parseDotEnv } from "./env.ts";
import { pathExists, readTextFile } from "./fs.ts";
import { getRecord, getString, isRecord } from "./guards.ts";
+import {
+ LIFECYCLE_RUNNER_CONFIG_ERROR,
+ type LifecycleRunner,
+ parseLifecycleRunner,
+} from "./lifecycle-runner.ts";
import {
type OpenHostPreference,
parseOpenHostPreference,
@@ -252,6 +257,8 @@ export interface ProjectConfig {
* `parseError`, which would otherwise launch services with no helper.
*/
readonly sharedHelpersError?: string;
+ /** Invalid runner selection must refuse lifecycle effects, not fall back. */
+ readonly lifecycleRunnerError?: string;
}
export interface ProjectOpenConfig {
@@ -287,6 +294,8 @@ export interface ProjectSessionsConfig {
}
export interface ProjectLifecycleConfig {
+ /** Persistent host-process runner; omitted means mux. */
+ readonly runner?: LifecycleRunner;
readonly up?: ProjectLifecycleHooks;
readonly down?: ProjectLifecycleHooks;
readonly processes?: readonly ProjectLifecycleProcess[];
@@ -549,6 +558,11 @@ function parseProjectConfigRecord(value: unknown, path: string): ProjectConfig {
const worktree = parseWorktreeConfig(getRecord(value, "worktree"));
const sessions = parseSessionsConfig(getRecord(value, "sessions"));
const lifecycleRecord = getRecord(value, "lifecycle");
+ const lifecycleRunnerError =
+ lifecycleRecord?.runner !== undefined &&
+ parseLifecycleRunner(lifecycleRecord.runner) === null
+ ? LIFECYCLE_RUNNER_CONFIG_ERROR
+ : undefined;
const lifecycleBase = parseLifecycleConfig(lifecycleRecord);
const sharedHelpers = parseProjectSharedHelpers(
lifecycleRecord?.sharedHelpers
@@ -584,6 +598,7 @@ function parseProjectConfigRecord(value: unknown, path: string): ProjectConfig {
...(parsedOwnership.parseError ? [parsedOwnership.parseError] : []),
...(parsedOpen.parseError ? [parsedOpen.parseError] : []),
...sharedHelpers.errors,
+ ...(lifecycleRunnerError ? [lifecycleRunnerError] : []),
];
return {
@@ -605,6 +620,7 @@ function parseProjectConfigRecord(value: unknown, path: string): ProjectConfig {
...(sharedHelpers.errors.length > 0
? { sharedHelpersError: sharedHelpers.errors.join(" ") }
: {}),
+ ...(lifecycleRunnerError ? { lifecycleRunnerError } : {}),
configPath: path,
};
}
@@ -815,6 +831,7 @@ function mergeLifecycleWithStartup(opts: {
const processes = [...lifecycleProcesses, ...startup.processes];
return buildLifecycleConfig({
+ runner: opts.lifecycle?.runner,
upBefore,
upAfter: lifecycleUpAfter,
downBefore: lifecycleDownBefore,
@@ -857,6 +874,7 @@ function mapStartupEntriesToLifecycle(opts: {
}
function buildLifecycleConfig(opts: {
+ readonly runner?: LifecycleRunner;
readonly upBefore: readonly ProjectLifecycleCommand[];
readonly upAfter: readonly ProjectLifecycleCommand[] | undefined;
readonly downBefore: readonly ProjectLifecycleCommand[] | undefined;
@@ -864,6 +882,7 @@ function buildLifecycleConfig(opts: {
readonly processes: readonly ProjectLifecycleProcess[];
}): ProjectLifecycleConfig | undefined {
const lifecycle: ProjectLifecycleConfig = {
+ ...(opts.runner ? { runner: opts.runner } : {}),
...(opts.upBefore.length > 0 || opts.upAfter
? {
up: {
@@ -896,8 +915,10 @@ function parseLifecycleConfig(
const up = parseLifecycleHooks(getRecord(value, "up"));
const down = parseLifecycleHooks(getRecord(value, "down"));
const processes = parseLifecycleProcesses(value.processes);
+ const runner = parseLifecycleRunner(value.runner);
const out: ProjectLifecycleConfig = {
+ ...(runner ? { runner } : {}),
...(up ? { up } : {}),
...(down ? { down } : {}),
...(processes ? { processes } : {}),
diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts
index d85a30822..1cdf4188b 100644
--- a/src/lib/runtime-projects.ts
+++ b/src/lib/runtime-projects.ts
@@ -9,6 +9,7 @@ import { pathExists } from "./fs.ts";
import { getString, isRecord } from "./guards.ts";
import { parseJsonLines } from "./json-lines.ts";
import { readLifecycleState } from "./lifecycle-runtime.ts";
+import { inspectLifecycleSupervisor } from "./lifecycle-supervisor.ts";
import {
createOperationTimings,
type OperationTimings,
@@ -363,6 +364,7 @@ export async function autoRegisterRuntimeHackProjects(opts: {
type LifecycleActivity = {
readonly sessionActive: boolean;
readonly runningWindows: ReadonlySet | null;
+ readonly unknownWindows?: ReadonlySet;
};
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Lifecycle pseudo-services are derived from several runtime sources in one normalization step.
@@ -403,18 +405,24 @@ async function addLifecycleProcessServices(opts: {
continue;
}
- const activity = await resolveLifecycleActivity({
- backend: entry.backend,
- sessionName: entry.sessionName,
- tmuxWindowsBySession,
- getZellijSessions: async () => {
- if (zellijSessions) {
- return zellijSessions;
- }
- zellijSessions = await readZellijSessionNames();
- return zellijSessions;
- },
- });
+ const activity =
+ entry.backend === "supervisor"
+ ? await readSupervisorLifecycleActivity({
+ entry,
+ projectDir: workingDir,
+ })
+ : await resolveLifecycleActivity({
+ backend: entry.backend,
+ sessionName: entry.sessionName,
+ tmuxWindowsBySession,
+ getZellijSessions: async () => {
+ if (zellijSessions) {
+ return zellijSessions;
+ }
+ zellijSessions = await readZellijSessionNames();
+ return zellijSessions;
+ },
+ });
const services = new Map(runtime.services);
for (const process of entry.processes) {
@@ -422,8 +430,15 @@ async function addLifecycleProcessServices(opts: {
activity.runningWindows !== null
? activity.runningWindows.has(process.windowName)
: activity.sessionActive;
- const stateValue = running ? "running" : "exited";
- const status = running ? "Up (lifecycle)" : "Exited (lifecycle)";
+ const unknown = activity.unknownWindows?.has(process.windowName) === true;
+ const observedState = running ? "running" : "exited";
+ const stateValue = unknown ? "unknown" : observedState;
+ const statusByState = {
+ unknown: "Unknown (lifecycle)",
+ running: "Up (lifecycle)",
+ exited: "Exited (lifecycle)",
+ };
+ const status = statusByState[stateValue];
const id = `lifecycle-${runtime.project}-${sanitizeLifecycleToken(process.name)}`;
const syntheticContainer: RuntimeContainer = {
id,
@@ -468,6 +483,51 @@ async function addLifecycleProcessServices(opts: {
return out;
}
+/** Native observations alone qualify supervisor service liveness. */
+export async function readSupervisorLifecycleActivity(opts: {
+ readonly entry: Awaited>[number];
+ readonly projectDir: string;
+ readonly inspect?: typeof inspectLifecycleSupervisor;
+}): Promise {
+ const unknown = () => ({
+ sessionActive: false,
+ runningWindows: new Set(),
+ unknownWindows: new Set(
+ opts.entry.processes.map((process) => process.windowName)
+ ),
+ });
+ if (!opts.entry.ownershipToken) {
+ return unknown();
+ }
+ try {
+ const receipt = await (opts.inspect ?? inspectLifecycleSupervisor)({
+ projectDir: opts.projectDir,
+ composeProject: opts.entry.composeProject,
+ ownershipToken: opts.entry.ownershipToken,
+ });
+ if (receipt.outcome !== "observed") {
+ return unknown();
+ }
+ return {
+ sessionActive: receipt.liveProcessNames.length > 0,
+ runningWindows: new Set(
+ opts.entry.processes
+ .filter((process) => receipt.liveProcessNames.includes(process.name))
+ .map((process) => process.windowName)
+ ),
+ unknownWindows: new Set(
+ opts.entry.processes
+ .filter((process) =>
+ receipt.unknownProcessNames.includes(process.name)
+ )
+ .map((process) => process.windowName)
+ ),
+ };
+ } catch {
+ return unknown();
+ }
+}
+
async function resolveLifecycleActivity(opts: {
readonly backend: "tmux" | "zellij";
readonly sessionName: string;
diff --git a/src/templates.ts b/src/templates.ts
index 6963a1225..a4db165d3 100644
--- a/src/templates.ts
+++ b/src/templates.ts
@@ -663,6 +663,13 @@ export function renderProjectConfigSchemaJson(): string {
type: "object",
additionalProperties: false,
properties: {
+ runner: {
+ type: "string",
+ enum: ["mux", "supervisor"],
+ default: "mux",
+ description:
+ "Runner for persistent host lifecycle processes. Interactive workspaces still use sessions.mux.",
+ },
up: {
type: "object",
additionalProperties: false,
diff --git a/tests/e2e/scenarios/lifecycle-host-process.ts b/tests/e2e/scenarios/lifecycle-host-process.ts
index 51ea01049..82a4b7e85 100644
--- a/tests/e2e/scenarios/lifecycle-host-process.ts
+++ b/tests/e2e/scenarios/lifecycle-host-process.ts
@@ -1,4 +1,7 @@
-import { join } from "node:path";
+import { readdir } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { isRecord } from "../../../src/lib/guards.ts";
+import { restoreEnv } from "../../helpers/env.ts";
import { createMonorepoFixture } from "../fixture.ts";
import { expect, expectExit, runCommand, type Scenario } from "../harness.ts";
@@ -20,6 +23,7 @@ export const lifecycleHostProcessScenario: Scenario = {
summary: "lifecycle hooks and command-group teardown run during up/down",
run: async (ctx) => {
await requireDockerPreconditions({ ctx });
+ const supervisor = process.env.HACK_LIFECYCLE_RUNNER === "supervisor";
const markerFile = join(ctx.tempRoot, "lifecycle-marker.txt");
const envMarkerFile = join(ctx.tempRoot, "lifecycle-env-marker.txt");
@@ -33,9 +37,39 @@ export const lifecycleHostProcessScenario: Scenario = {
standaloneContainers: true,
},
});
+ if (supervisor) {
+ const binary =
+ process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY ??
+ resolve(
+ ctx.repoRoot,
+ ".hack-local/target/lifecycle-integration/debug/hack-lifecycle-supervisor"
+ );
+ expect({
+ that: await Bun.file(binary).exists(),
+ message:
+ "supervisor host scenario requires its current-source executable",
+ });
+ // Store only the runner choice in this disposable fixture. The native
+ // executable remains an explicit test environment override.
+ await runSupervisorHostProcess({
+ ctx,
+ fixture,
+ markerFile,
+ envMarkerFile,
+ binary,
+ });
+ return;
+ }
+ const previousTmuxRoot = process.env.TMUX_TMPDIR;
+ process.env.TMUX_TMPDIR = ctx.tempRoot;
+ const cli: typeof ctx.cli = (invocation) =>
+ ctx.cli({
+ ...invocation,
+ env: { ...invocation.env, TMUX_TMPDIR: ctx.tempRoot },
+ });
try {
- const up = await ctx.cli({
+ const up = await cli({
args: ["up", "--detach"],
cwd: fixture.root,
timeoutMs: UP_TIMEOUT_MS,
@@ -140,7 +174,7 @@ export const lifecycleHostProcessScenario: Scenario = {
result: owner,
});
- const repeatedUp = await ctx.cli({
+ const repeatedUp = await cli({
args: ["up", "--detach"],
cwd: fixture.root,
timeoutMs: UP_TIMEOUT_MS,
@@ -202,7 +236,7 @@ export const lifecycleHostProcessScenario: Scenario = {
message: "legacy fixture should have no session ownership token",
});
- const recoveredUp = await ctx.cli({
+ const recoveredUp = await cli({
args: ["up", "--detach"],
cwd: fixture.root,
timeoutMs: UP_TIMEOUT_MS,
@@ -220,7 +254,7 @@ export const lifecycleHostProcessScenario: Scenario = {
result: recoveredUp,
});
- const down = await ctx.cli({
+ const down = await cli({
args: ["down"],
cwd: fixture.root,
timeoutMs: UP_TIMEOUT_MS,
@@ -260,7 +294,7 @@ export const lifecycleHostProcessScenario: Scenario = {
standaloneContainers: true,
},
});
- const failedUp = await ctx.cli({
+ const failedUp = await cli({
args: ["up", "--detach", "--json"],
cwd: failingFixture.root,
timeoutMs: UP_TIMEOUT_MS,
@@ -298,11 +332,214 @@ export const lifecycleHostProcessScenario: Scenario = {
result: failedUp,
});
} finally {
- await downBestEffort({ ctx, fixture });
+ try {
+ await downBestEffort({ ctx: { ...ctx, cli }, fixture });
+ } finally {
+ await runCommand({ argv: ["tmux", "kill-server"], cwd: fixture.root });
+ restoreEnv("TMUX_TMPDIR", previousTmuxRoot);
+ }
}
},
};
+async function runSupervisorHostProcess(opts: {
+ readonly ctx: Parameters[0];
+ readonly fixture: Awaited>;
+ readonly markerFile: string;
+ readonly envMarkerFile: string;
+ readonly binary: string;
+}): Promise {
+ const env = {
+ HACK_LIFECYCLE_RUNNER: "supervisor",
+ HACK_LIFECYCLE_SUPERVISOR_BINARY: opts.binary,
+ };
+ const statePath = join(
+ opts.fixture.hackDir,
+ ".internal/lifecycle/state.json"
+ );
+ const cli = (args: readonly string[]) =>
+ opts.ctx.cli({
+ args,
+ cwd: opts.fixture.root,
+ env,
+ timeoutMs: UP_TIMEOUT_MS,
+ });
+ try {
+ const up = await cli(["up", "--detach"]);
+ expectExit({
+ result: up,
+ codes: [0],
+ message: "supervisor host fixture should start real Compose services",
+ });
+ expect({
+ that: (await Bun.file(opts.markerFile).text()).includes("up-before-ran"),
+ message: "supervisor before hook did not precede Compose",
+ result: up,
+ });
+ expect({
+ that:
+ (await Bun.file(opts.envMarkerFile).text()) === "host-value|host-only",
+ message: "supervisor before hook lost resolved environment",
+ result: up,
+ });
+ const first = (await readLifecycleState({ path: statePath })).entries[0];
+ const guard = first?.processes?.find(
+ (process) => process.name === "e2e-sleeper"
+ )?.processGroupId;
+ expect({
+ that:
+ first?.backend === "supervisor" &&
+ Boolean(first.ownershipToken) &&
+ Number.isInteger(guard) &&
+ (guard ?? 0) > 1,
+ message: "native lifecycle state lost owner/session identity",
+ result: up,
+ });
+ const leader = await runCommand({
+ argv: ["ps", "-o", "pid=,pgid=", "-p", String(guard)],
+ cwd: opts.fixture.root,
+ });
+ expectExit({
+ result: leader,
+ codes: [0],
+ message: "recorded native guard should be alive",
+ });
+ expect({
+ that: leader.stdout
+ .trim()
+ .split(/\s+/)
+ .every((value) => Number(value) === guard),
+ message: "native guard should lead its own group",
+ result: leader,
+ });
+ const repeated = await cli(["up", "--detach"]);
+ expectExit({
+ result: repeated,
+ codes: [0],
+ message: "supervisor host fixture should adopt healthy owner",
+ });
+ expect({
+ that:
+ (await readLifecycleState({ path: statePath })).entries[0]
+ ?.ownershipToken === first?.ownershipToken,
+ message: "native healthy adoption replaced ownership",
+ result: repeated,
+ });
+ const down = await cli(["down"]);
+ expectExit({
+ result: down,
+ codes: [0],
+ message: "native down should confirm host session retirement",
+ });
+ expect({
+ that:
+ (await readLifecycleState({ path: statePath })).entries.length === 0,
+ message: "native down retained stopped state",
+ result: down,
+ });
+ if (guard && guard > 1) {
+ let alive = true;
+ try {
+ process.kill(guard, 0);
+ } catch {
+ alive = false;
+ }
+ expect({
+ that: !alive,
+ message: "native guard survived confirmed down",
+ result: down,
+ });
+ }
+ const failing = await createMonorepoFixture({
+ parentDir: opts.ctx.tempRoot,
+ withHackConfig: true,
+ lifecycle: {
+ persistentProcess: true,
+ composeFailure: true,
+ standaloneContainers: true,
+ },
+ });
+ const failingState = join(
+ failing.hackDir,
+ ".internal/lifecycle/state.json"
+ );
+ try {
+ const failedUp = await opts.ctx.cli({
+ args: ["up", "--detach", "--json"],
+ cwd: failing.root,
+ env,
+ timeoutMs: UP_TIMEOUT_MS,
+ });
+ expect({
+ that: failedUp.exitCode !== 0 && !failedUp.timedOut,
+ message:
+ "real Compose failure must propagate after native lifecycle startup",
+ result: failedUp,
+ });
+ expect({
+ that:
+ (await readLifecycleState({ path: failingState })).entries.length ===
+ 0,
+ message: "real Compose failure left native lifecycle ownership",
+ result: failedUp,
+ });
+ const nativeRoot = join(
+ failing.hackDir,
+ ".internal/lifecycle",
+ `${failing.name}.log.supervisor`
+ );
+ const receipts = (await readdir(nativeRoot)).filter((name) =>
+ name.endsWith(".json")
+ );
+ expect({
+ that: receipts.length > 0,
+ message: "failure fixture did not reach native lifecycle startup",
+ result: failedUp,
+ });
+ for (const name of receipts) {
+ const state: unknown = await Bun.file(join(nativeRoot, name)).json();
+ expect({
+ that:
+ isRecord(state) &&
+ Number.isInteger(state.commandPid) &&
+ (state.phase === "exited" || state.phase === "killed"),
+ message:
+ "Compose failure did not retain confirmed native retirement evidence",
+ result: failedUp,
+ });
+ }
+ } finally {
+ const cleanup = await opts.ctx.cli({
+ args: ["down"],
+ cwd: failing.root,
+ env,
+ timeoutMs: UP_TIMEOUT_MS,
+ });
+ if (
+ cleanup.timedOut ||
+ (await readLifecycleState({ path: failingState })).entries.length > 0
+ ) {
+ opts.ctx.retainFixtures(
+ "Failed Compose cleanup retained native ownership evidence."
+ );
+ }
+ await downBestEffort({ ctx: opts.ctx, fixture: failing });
+ }
+ } finally {
+ const cleanup = await cli(["down"]);
+ if (
+ cleanup.exitCode !== 0 ||
+ cleanup.timedOut ||
+ (await readLifecycleState({ path: statePath })).entries.length > 0
+ ) {
+ opts.ctx.retainFixtures(
+ "Supervisor host cleanup was not confirmed; retained private ownership evidence."
+ );
+ }
+ await downBestEffort({ ctx: opts.ctx, fixture: opts.fixture });
+ }
+}
+
type LifecycleStateFixture = {
readonly entries: ReadonlyArray<{
readonly ownershipToken?: string;
diff --git a/tests/e2e/scenarios/lifecycle-session-recovery.ts b/tests/e2e/scenarios/lifecycle-session-recovery.ts
index ea57f6dae..48f25d585 100644
--- a/tests/e2e/scenarios/lifecycle-session-recovery.ts
+++ b/tests/e2e/scenarios/lifecycle-session-recovery.ts
@@ -1,5 +1,6 @@
import { chmod, mkdir } from "node:fs/promises";
-import { join } from "node:path";
+import { join, resolve } from "node:path";
+import { restoreEnv } from "../../helpers/env.ts";
import { resolveExecutableCapability } from "../capabilities.ts";
import { createMonorepoFixture } from "../fixture.ts";
@@ -20,6 +21,10 @@ export const lifecycleSessionRecoveryScenario: Scenario = {
tier: "local",
summary: "owned lifecycle sessions reconcile and clean up without Docker",
run: async (ctx) => {
+ if (process.env.HACK_LIFECYCLE_RUNNER === "supervisor") {
+ await runSupervisorRecovery({ ctx });
+ return;
+ }
const tmux = resolveExecutableCapability({
executable: "tmux",
executablePath: Bun.which("tmux"),
@@ -44,7 +49,13 @@ export const lifecycleSessionRecoveryScenario: Scenario = {
await writeFakeDocker({ fakeBin });
const path = `${fakeBin}:${process.env.PATH ?? ""}`;
const dockerLog = join(ctx.tempRoot, "fake-docker.log");
- const cliEnv = { PATH: path, E2E_DOCKER_LOG: dockerLog };
+ const previousTmuxRoot = process.env.TMUX_TMPDIR;
+ process.env.TMUX_TMPDIR = ctx.tempRoot;
+ const cliEnv = {
+ PATH: path,
+ E2E_DOCKER_LOG: dockerLog,
+ TMUX_TMPDIR: ctx.tempRoot,
+ };
const sessionName = `${fixture.name}--lifecycle`;
const statePath = join(
fixture.hackDir,
@@ -555,10 +566,283 @@ export const lifecycleSessionRecoveryScenario: Scenario = {
argv: ["tmux", "kill-session", "-t", sessionName],
cwd: fixture.root,
});
+ // This server is scoped to the scenario's private TMUX_TMPDIR; remove
+ // branch sessions too if an earlier assertion interrupted their cleanup.
+ await runCommand({ argv: ["tmux", "kill-server"], cwd: fixture.root });
+ restoreEnv("TMUX_TMPDIR", previousTmuxRoot);
}
},
};
+/** The supervisor path needs no mux. Exercise the same public CLI ownership,
+ * overlays, branches, restart, rollback and doctor boundaries with native state. */
+async function runSupervisorRecovery(opts: {
+ readonly ctx: ScenarioContext;
+}): Promise {
+ const { ctx } = opts;
+ const binary =
+ process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY ??
+ resolve(
+ ctx.repoRoot,
+ ".hack-local/target/lifecycle-integration/debug/hack-lifecycle-supervisor"
+ );
+ expect({
+ that: binary.startsWith("/") && (await Bun.file(binary).exists()),
+ message:
+ "supervisor recovery requires a built current-source supervisor executable",
+ });
+ const fakeBin = join(ctx.tempRoot, "fake-supervisor-bin");
+ await writeFakeDocker({ fakeBin });
+ const cliEnv = {
+ PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
+ HACK_LIFECYCLE_RUNNER: "supervisor",
+ HACK_LIFECYCLE_SUPERVISOR_BINARY: binary,
+ };
+ const fixture = await createMonorepoFixture({
+ parentDir: ctx.tempRoot,
+ withHackConfig: true,
+ lifecycle: {
+ persistentUpBefore: true,
+ persistentProcess: true,
+ disableInternal: true,
+ persistentUpBeforeCommand:
+ 'bun -e \'console.log("supervisor-ready " + process.env.E2E_PLAIN); let n=0; setInterval(() => console.log("supervisor-tick " + ++n), 100)\'',
+ },
+ });
+ const statePath = join(fixture.hackDir, ".internal/lifecycle/state.json");
+ const cli = (
+ args: readonly string[],
+ env: Readonly> = cliEnv
+ ) => ctx.cli({ args, cwd: fixture.root, env });
+ const assertOk = (result: Awaited>, message: string) =>
+ expectExit({ result, codes: [0], message });
+ const readEntry = async (project = fixture.name) =>
+ (await readState({ statePath })).entries.find(
+ (entry) => entry.composeProject === project
+ );
+ const branches = ["feat-supervisor-handoff"];
+ try {
+ assertOk(
+ await cli(["up", "--detach", "--json"]),
+ "supervisor up should start persistent hook and process"
+ );
+ const initial = await readEntry();
+ expect({
+ that:
+ initial?.backend === "supervisor" &&
+ Boolean(initial.ownershipToken) &&
+ initial.processes?.length === 2,
+ message:
+ "supervisor state must persist both ordered commands and ownership",
+ });
+ assertOk(
+ await cli(["up", "--detach", "--json"]),
+ "supervisor repeated up should adopt"
+ );
+ expect({
+ that: (await readEntry())?.ownershipToken === initial?.ownershipToken,
+ message: "supervisor adoption replaced healthy ownership",
+ });
+ const logPath = join(
+ fixture.hackDir,
+ ".internal/lifecycle",
+ `${fixture.name}.log`
+ );
+ await waitForText({ path: logPath, text: "supervisor-ready host-value" });
+ const logs = await cli(["logs", "e2e-tunnel", "--no-follow"]);
+ assertOk(logs, "supervisor logs should read the common private log");
+ expect({
+ that: logs.combined.includes("supervisor-ready host-value"),
+ message: "supervisor output did not reach hack logs",
+ result: logs,
+ });
+ await expectSupervisorLogFollow({
+ ctx,
+ cwd: fixture.root,
+ env: cliEnv,
+ logPath,
+ });
+ await writeEnvOverlay({
+ hackDir: fixture.hackDir,
+ envName: "qa",
+ value: "qa-first",
+ });
+ assertOk(
+ await cli(["up", "--detach", "--json", "--env", "qa"]),
+ "supervisor overlay replacement should succeed"
+ );
+ expect({
+ that: (await readEntry())?.ownershipToken !== initial?.ownershipToken,
+ message: "supervisor overlay change reused old command environment",
+ });
+ await waitForText({ path: logPath, text: "supervisor-ready qa-first" });
+ const branch = branches[0] ?? "feat-supervisor-handoff";
+ assertOk(
+ await cli(["up", "--detach", "--json", "--branch", branch]),
+ "supervisor branch should start"
+ );
+ const branchProject = `${fixture.name}--${branch}`;
+ const branchToken = (await readEntry(branchProject))?.ownershipToken;
+ assertOk(
+ await cli(["restart", "--json"]),
+ "supervisor restart should succeed"
+ );
+ expect({
+ that: (await readEntry(branchProject))?.ownershipToken === branchToken,
+ message: "primary supervisor restart changed branch ownership",
+ });
+ const beforeKill = await readEntry();
+ const owner = beforeKill?.supervisorPid;
+ expect({
+ that: Number.isInteger(owner) && (owner ?? 0) > 1,
+ message: "supervisor owner PID was not recorded",
+ });
+ if (owner && owner > 1) {
+ // This PID is read immediately from the fixture's own current launch receipt.
+ process.kill(owner, "SIGKILL");
+ }
+ await Bun.sleep(150);
+ assertOk(
+ await cli(["down", "--json"]),
+ "down must recover native groups after project supervisor SIGKILL"
+ );
+ expect({
+ that: !(await readEntry()),
+ message: "recovery down retained confirmed-stopped supervisor entry",
+ });
+ assertOk(
+ await cli(["down", "--json", "--branch", branch]),
+ "branch supervisor down should succeed"
+ );
+ const failedUp = await cli(["up", "--detach", "--json"], {
+ ...cliEnv,
+ E2E_DOCKER_FAIL_UP: "1",
+ });
+ expect({
+ that: failedUp.exitCode !== 0 && !failedUp.timedOut,
+ message: "compose failure must propagate under supervisor",
+ result: failedUp,
+ });
+ expect({
+ that: !(await readEntry()),
+ message:
+ "compose failure retained confirmed-stopped supervisor ownership",
+ });
+ assertOk(
+ await cli(["up", "--detach", "--json"]),
+ "supervisor doctor fixture should start"
+ );
+ await ageLifecycleState({ statePath });
+ const doctor = await cli(["doctor"]);
+ expect({
+ that:
+ doctor.combined.includes("owned lifecycle session") &&
+ doctor.combined.includes("hack doctor --fix"),
+ message: "doctor did not classify native ownership-proven orphan",
+ result: doctor,
+ });
+ const fixed = await cli(["doctor", "--fix"]);
+ expect({
+ that: !fixed.timedOut && fixed.combined.includes("lifecycle repair"),
+ message: "doctor fix did not inspect supervisor retirement",
+ result: fixed,
+ });
+ expect({
+ that: !(await readEntry()),
+ message: "doctor repair did not retire native ownership",
+ });
+ } finally {
+ const cleanup = [await cli(["down", "--json"])];
+ for (const branch of branches) {
+ cleanup.push(await cli(["down", "--json", "--branch", branch]));
+ }
+ if (
+ cleanup.some((result) => result.exitCode !== 0 || result.timedOut) ||
+ (await readState({ statePath })).entries.length > 0
+ ) {
+ ctx.retainFixtures(
+ "Supervisor recovery cleanup was not confirmed; retained private ownership evidence."
+ );
+ }
+ }
+}
+
+async function expectSupervisorLogFollow(opts: {
+ readonly ctx: ScenarioContext;
+ readonly cwd: string;
+ readonly env: Readonly>;
+ readonly logPath: string;
+}): Promise {
+ const previous = Math.max(
+ 0,
+ ...[
+ ...(await Bun.file(opts.logPath).text()).matchAll(
+ /supervisor-tick (\d+)/g
+ ),
+ ].map((match) => Number(match[1]))
+ );
+ const follower = Bun.spawn(
+ resolveCliSpawnArgs(["logs", "-f", "e2e-tunnel"]),
+ {
+ cwd: opts.cwd,
+ env: buildCliEnv({ hackHome: opts.ctx.hackHome, extra: opts.env }),
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ }
+ );
+ const stderr = new Response(follower.stderr).text();
+ const reader = follower.stdout.getReader();
+ let text = "";
+ let timer: ReturnType | undefined;
+ try {
+ await Promise.race([
+ (async () => {
+ while (
+ ![...text.matchAll(/supervisor-tick (\d+)/g)].some(
+ (match) => Number(match[1]) > previous
+ )
+ ) {
+ const result = await reader.read();
+ if (result.done) {
+ throw new Error("supervisor log follower ended before output");
+ }
+ text = (text + new TextDecoder().decode(result.value)).slice(-4096);
+ }
+ })(),
+ new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new Error("supervisor log follower timed out")),
+ SESSION_WAIT_TIMEOUT_MS
+ );
+ }),
+ ]);
+ } finally {
+ clearTimeout(timer);
+ follower.kill("SIGINT");
+ await follower.exited;
+ await reader.cancel();
+ await stderr;
+ }
+}
+
+async function waitForText(opts: {
+ readonly path: string;
+ readonly text: string;
+}): Promise {
+ const deadline = Date.now() + SESSION_WAIT_TIMEOUT_MS;
+ while (Date.now() < deadline) {
+ const file = Bun.file(opts.path);
+ if ((await file.exists()) && (await file.text()).includes(opts.text)) {
+ return;
+ }
+ await Bun.sleep(100);
+ }
+ throw new Error(
+ "native lifecycle output did not reach the private log before deadline"
+ );
+}
+
/**
* HACK-1797: without a UTF-8 locale tmux rewrote the session listing's tab
* separators, so `down` missed the owned session (leaving it and its tunnel
@@ -798,6 +1082,7 @@ async function runSignalProbe(opts: {
hackHome: opts.hackHome,
extra: {
PATH: opts.path,
+ TMUX_TMPDIR: process.env.TMUX_TMPDIR ?? "",
E2E_DOCKER_BLOCK_UP: "1",
},
}),
@@ -891,6 +1176,8 @@ async function inspectSessionPresence(opts: {
type LifecycleState = {
readonly entries: ReadonlyArray<{
readonly composeProject: string;
+ readonly backend?: string;
+ readonly supervisorPid?: number;
readonly ownershipToken?: string;
readonly processes?: ReadonlyArray<{
readonly processGroupId?: number;
diff --git a/tests/lifecycle-runner-config.test.ts b/tests/lifecycle-runner-config.test.ts
new file mode 100644
index 000000000..3756fc977
--- /dev/null
+++ b/tests/lifecycle-runner-config.test.ts
@@ -0,0 +1,169 @@
+import { expect, test } from "bun:test";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ PROJECT_COMPOSE_FILENAME,
+ PROJECT_CONFIG_FILENAME,
+ PROJECT_ENV_FILENAME,
+} from "../src/constants.ts";
+import { resolveLifecycleRunner } from "../src/lib/lifecycle-runner.ts";
+import { readProjectConfig } from "../src/lib/project.ts";
+import { renderProjectConfigSchemaJson } from "../src/templates.ts";
+
+async function config(value: unknown) {
+ const root = await mkdtemp(join(tmpdir(), "hack-runner-config-"));
+ try {
+ const projectRoot = join(root, "repo");
+ const projectDir = join(projectRoot, ".hack");
+ await mkdir(projectDir, { recursive: true });
+ const configFile = join(projectDir, PROJECT_CONFIG_FILENAME);
+ const composeFile = join(projectDir, PROJECT_COMPOSE_FILENAME);
+ const envFile = join(projectDir, PROJECT_ENV_FILENAME);
+ await writeFile(configFile, JSON.stringify(value));
+ await writeFile(composeFile, "services: {}\n");
+ await writeFile(envFile, "");
+ return await readProjectConfig({
+ projectRoot,
+ projectDir,
+ projectDirName: ".hack",
+ composeFile,
+ configFile,
+ envFile,
+ });
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+}
+
+test("runner-only lifecycle config is retained", async () => {
+ const cfg = await config({ lifecycle: { runner: "supervisor" } });
+ expect(cfg.lifecycle).toEqual({ runner: "supervisor" });
+ expect(cfg.lifecycleRunnerError).toBeUndefined();
+});
+
+test("startup merging preserves runner and command ordering", async () => {
+ const cfg = await config({
+ lifecycle: {
+ runner: "supervisor",
+ up: { before: ["echo before"] },
+ processes: [{ name: "existing", command: "example-worker" }],
+ },
+ startup: [
+ "echo shorthand",
+ { name: "additional", run: "example-helper", persistent: true },
+ ],
+ });
+ expect(cfg.lifecycle?.runner).toBe("supervisor");
+ expect(cfg.lifecycle?.up?.before).toEqual([
+ { command: "echo before" },
+ { command: "echo shorthand" },
+ ]);
+ expect(cfg.lifecycle?.processes).toEqual([
+ { name: "existing", command: "example-worker" },
+ { name: "additional", command: "example-helper" },
+ ]);
+});
+
+test("omitted runner defaults to mux without changing parsed config", async () => {
+ const cfg = await config({
+ startup: [{ run: "example-worker", persistent: true }],
+ });
+ expect(cfg.lifecycle?.runner).toBeUndefined();
+ expect(
+ resolveLifecycleRunner({ runner: cfg.lifecycle?.runner, env: {} })
+ ).toBe("mux");
+ expect(resolveLifecycleRunner({ env: {} })).toBe("mux");
+});
+
+test("environment override precedes either configured runner", () => {
+ expect(
+ resolveLifecycleRunner({
+ runner: "mux",
+ env: { HACK_LIFECYCLE_RUNNER: "supervisor" },
+ })
+ ).toBe("supervisor");
+ expect(
+ resolveLifecycleRunner({
+ runner: "supervisor",
+ env: { HACK_LIFECYCLE_RUNNER: "mux" },
+ })
+ ).toBe("mux");
+});
+
+test.each([
+ "",
+ "auto",
+ "SUPERVISOR",
+ "synthetic-invalid-input",
+ null,
+ 7,
+])("invalid explicit runner config is marked for fail-closed startup (%p)", async (runner) => {
+ const cfg = await config({
+ lifecycle: { runner },
+ startup: ["echo preparation"],
+ });
+ expect(cfg.lifecycleRunnerError).toContain("lifecycle.runner");
+ expect(cfg.lifecycleRunnerError).toContain('"mux" or "supervisor"');
+ expect(cfg.parseError).toContain(
+ cfg.lifecycleRunnerError ?? "missing-validation"
+ );
+ expect(cfg.lifecycleRunnerError).not.toContain("synthetic-invalid-input");
+ expect(cfg.lifecycle?.up?.before).toEqual([{ command: "echo preparation" }]);
+ expect(() => resolveLifecycleRunner({ runner, env: {} })).toThrow(
+ "lifecycle.runner"
+ );
+});
+
+test.each([
+ "",
+ "auto",
+ "SUPERVISOR",
+ "synthetic-invalid-input",
+])("invalid explicit environment override never falls back (%p)", (override) => {
+ expect(() =>
+ resolveLifecycleRunner({
+ runner: "mux",
+ env: { HACK_LIFECYCLE_RUNNER: override },
+ })
+ ).toThrow("HACK_LIFECYCLE_RUNNER");
+ try {
+ resolveLifecycleRunner({
+ runner: "mux",
+ env: { HACK_LIFECYCLE_RUNNER: override },
+ });
+ } catch (error: unknown) {
+ expect(error instanceof Error && error.message).not.toContain(
+ "synthetic-invalid-input"
+ );
+ }
+});
+
+test("runner schema accepts only the named options and documents mux default", () => {
+ const schema = JSON.parse(renderProjectConfigSchemaJson());
+ expect(schema.properties.lifecycle.properties.runner).toMatchObject({
+ type: "string",
+ enum: ["mux", "supervisor"],
+ default: "mux",
+ });
+});
+
+test("runner-only config survives shorthand and shared-helper reconstruction", async () => {
+ const cfg = await config({
+ lifecycle: {
+ runner: "mux",
+ sharedHelpers: [
+ {
+ name: "example-helper",
+ command: ["example-helper"],
+ port: 4100,
+ upstream: "https://example.invalid",
+ },
+ ],
+ },
+ startup: ["echo preparation"],
+ });
+ expect(cfg.lifecycle?.runner).toBe("mux");
+ expect(cfg.lifecycle?.sharedHelpers).toHaveLength(1);
+ expect(cfg.lifecycle?.up?.before).toEqual([{ command: "echo preparation" }]);
+});
diff --git a/tests/lifecycle-state-publication.test.ts b/tests/lifecycle-state-publication.test.ts
new file mode 100644
index 000000000..b63847d8c
--- /dev/null
+++ b/tests/lifecycle-state-publication.test.ts
@@ -0,0 +1,255 @@
+import { expect, test } from "bun:test";
+import {
+ chmod,
+ link,
+ lstat,
+ mkdir,
+ mkdtemp,
+ readFile,
+ rm,
+ symlink,
+ writeFile,
+} from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ type LifecycleStateEntry,
+ readLifecycleState,
+ removeLifecycleStateEntry,
+ removeLifecycleStateEntryIfMatching,
+ removeLifecycleStateEntryIfOwned,
+ resolveLifecycleRootDir,
+ resolveLifecycleStatePath,
+ upsertLifecycleStateEntry,
+} from "../src/lib/lifecycle-runtime.ts";
+
+function entry(name: string, cleanupEpoch = "epoch-a"): LifecycleStateEntry {
+ return {
+ composeProject: name,
+ projectName: "example-project",
+ branch: name,
+ sessionName: `example-lifecycle-${name}`,
+ backend: "supervisor",
+ ownershipToken: "synthetic-owner",
+ cleanupEpoch,
+ supervisorPid: 42,
+ processes: [
+ {
+ name: "worker",
+ windowName: "worker",
+ logPath: "example.log",
+ guardPid: 43,
+ guardStartTime: 44,
+ commandPid: 45,
+ processGroupId: 43,
+ bootId: "synthetic-boot",
+ },
+ ],
+ updatedAt: "2026-01-01T00:00:00Z",
+ };
+}
+
+async function fixture(
+ run: (opts: {
+ projectDir: string;
+ root: string;
+ path: string;
+ }) => Promise
+) {
+ const projectDir = await mkdtemp(join(tmpdir(), "hack-state-publication-"));
+ const root = resolveLifecycleRootDir({ projectDir });
+ const path = resolveLifecycleStatePath({ projectDir });
+ try {
+ await mkdir(root, { recursive: true, mode: 0o700 });
+ await run({ projectDir, root, path });
+ } finally {
+ await rm(projectDir, { recursive: true, force: true });
+ }
+}
+
+test("parallel branch publication and all deletion variants preserve unrelated ownership", async () => {
+ await fixture(async ({ projectDir }) => {
+ const names = ["branch-a", "branch-b", "branch-c", "branch-d"];
+ await Promise.all(
+ names.map((name) =>
+ upsertLifecycleStateEntry({ projectDir, entry: entry(name) })
+ )
+ );
+ expect(
+ (await readLifecycleState({ projectDir })).map(
+ (item) => item.composeProject
+ )
+ ).toEqual(names);
+ await Promise.all([
+ removeLifecycleStateEntry({ projectDir, composeProject: "branch-a" }),
+ removeLifecycleStateEntryIfOwned({
+ projectDir,
+ composeProject: "branch-b",
+ ownershipToken: "synthetic-owner",
+ }),
+ removeLifecycleStateEntryIfMatching({
+ projectDir,
+ expectedEntry: entry("branch-c"),
+ }),
+ upsertLifecycleStateEntry({ projectDir, entry: entry("branch-e") }),
+ ]);
+ expect(
+ (await readLifecycleState({ projectDir })).map(
+ (item) => item.composeProject
+ )
+ ).toEqual(["branch-d", "branch-e"]);
+ });
+});
+
+test("old cleanup epoch cannot delete an adopted entry with the same owner token", async () => {
+ await fixture(async ({ projectDir }) => {
+ await upsertLifecycleStateEntry({
+ projectDir,
+ entry: entry("branch-a", "adopted"),
+ });
+ expect(
+ await removeLifecycleStateEntryIfMatching({
+ projectDir,
+ expectedEntry: entry("branch-a"),
+ })
+ ).toBe(false);
+ expect(
+ await removeLifecycleStateEntryIfOwned({
+ projectDir,
+ composeProject: "branch-a",
+ ownershipToken: "foreign-owner",
+ })
+ ).toBe(false);
+ const states = await readLifecycleState({ projectDir });
+ expect(states).toEqual([entry("branch-a", "adopted")]);
+ });
+});
+
+test("atomic readers observe a complete old or new snapshot", async () => {
+ await fixture(async ({ projectDir }) => {
+ await upsertLifecycleStateEntry({
+ projectDir,
+ entry: entry("branch-a", "0"),
+ });
+ let writing = true;
+ const writer = (async () => {
+ for (let index = 1; index <= 25; index += 1) {
+ await upsertLifecycleStateEntry({
+ projectDir,
+ entry: entry("branch-a", String(index)),
+ });
+ }
+ })().finally(() => {
+ writing = false;
+ });
+ let observations = 0;
+ while (writing) {
+ const states = await readLifecycleState({ projectDir });
+ expect(states).toHaveLength(1);
+ expect(states[0]?.composeProject).toBe("branch-a");
+ expect(Number(states[0]?.cleanupEpoch)).toBeWithin(0, 26);
+ observations += 1;
+ }
+ await writer;
+ expect(observations).toBeGreaterThan(0);
+ expect((await readLifecycleState({ projectDir }))[0]?.cleanupEpoch).toBe(
+ "25"
+ );
+ });
+});
+
+test.each([
+ "",
+ "{",
+ "null",
+ '{"entries":{}}',
+ '{"entries":[{}]}',
+])("malformed state refuses mutation without overwriting (%p)", async (text) => {
+ await fixture(async ({ projectDir, path }) => {
+ await writeFile(path, text, { mode: 0o600 });
+ await expect(readLifecycleState({ projectDir })).rejects.toThrow(
+ "Lifecycle ownership state"
+ );
+ await expect(
+ upsertLifecycleStateEntry({ projectDir, entry: entry("branch-a") })
+ ).rejects.toThrow("Lifecycle ownership state");
+ expect(await readFile(path, "utf8")).toBe(text);
+ });
+});
+
+test.each([
+ "symlink",
+ "hardlink",
+])("aliased ownership state is refused without changing either name (%s)", async (kind) => {
+ await fixture(async ({ projectDir, root, path }) => {
+ const target = join(root, "retained.json");
+ const contents = JSON.stringify({ entries: [entry("branch-a")] });
+ await writeFile(target, contents, { mode: 0o600 });
+ if (kind === "symlink") {
+ await symlink(target, path);
+ } else {
+ await link(target, path);
+ }
+ await expect(
+ upsertLifecycleStateEntry({ projectDir, entry: entry("branch-b") })
+ ).rejects.toThrow();
+ await expect(readLifecycleState({ projectDir })).rejects.toThrow();
+ expect(await readFile(target, "utf8")).toBe(contents);
+ expect(await readFile(path, "utf8")).toBe(contents);
+ });
+});
+
+test("legacy owner-only regular state migrates to private atomic publication", async () => {
+ await fixture(async ({ projectDir, root, path }) => {
+ await chmod(root, 0o755);
+ await writeFile(path, JSON.stringify({ entries: [entry("branch-a")] }), {
+ mode: 0o644,
+ });
+ expect(await readLifecycleState({ projectDir })).toEqual([
+ entry("branch-a"),
+ ]);
+ await upsertLifecycleStateEntry({ projectDir, entry: entry("branch-b") });
+ expect((await lstat(root)).mode & 0o777).toBe(0o700);
+ expect((await lstat(path)).mode & 0o777).toBe(0o600);
+ expect(await readLifecycleState({ projectDir })).toEqual([
+ entry("branch-a"),
+ entry("branch-b"),
+ ]);
+ });
+});
+
+test("duplicate ownership entries and malformed process rows fail closed", async () => {
+ await fixture(async ({ projectDir, path }) => {
+ for (const entries of [
+ [entry("branch-a"), entry("branch-a")],
+ [{ ...entry("branch-a"), processes: [{}] }],
+ ]) {
+ const text = JSON.stringify({ entries });
+ await writeFile(path, text, { mode: 0o600 });
+ await expect(
+ removeLifecycleStateEntry({ projectDir, composeProject: "branch-a" })
+ ).rejects.toThrow("Lifecycle ownership state");
+ expect(await readFile(path, "utf8")).toBe(text);
+ }
+ });
+});
+
+test("oversized existing and proposed state are refused without replacement", async () => {
+ await fixture(async ({ projectDir, path }) => {
+ const oversized = " ".repeat(1024 * 1024 + 1);
+ await writeFile(path, oversized, { mode: 0o600 });
+ await expect(
+ upsertLifecycleStateEntry({ projectDir, entry: entry("branch-a") })
+ ).rejects.toThrow();
+ expect(await readFile(path, "utf8")).toBe(oversized);
+ const text = JSON.stringify({ entries: [entry("branch-a")] });
+ await writeFile(path, text, { mode: 0o600 });
+ await expect(
+ upsertLifecycleStateEntry({
+ projectDir,
+ entry: { ...entry("branch-b"), definitionHash: oversized },
+ })
+ ).rejects.toThrow();
+ expect(await readFile(path, "utf8")).toBe(text);
+ });
+});
diff --git a/tests/lifecycle-supervisor-controller.test.ts b/tests/lifecycle-supervisor-controller.test.ts
new file mode 100644
index 000000000..04cafe83b
--- /dev/null
+++ b/tests/lifecycle-supervisor-controller.test.ts
@@ -0,0 +1,313 @@
+import { afterAll, beforeAll, expect, test } from "bun:test";
+import { existsSync } from "node:fs";
+import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import {
+ readLifecycleState,
+ resolveLifecycleLogPath,
+} from "../src/lib/lifecycle-runtime.ts";
+import { inspectLifecycleSupervisor } from "../src/lib/lifecycle-supervisor.ts";
+import {
+ createLifecycleProcessController,
+ stopLifecycleProcessController,
+} from "../src/lib/project-lifecycle-controller.ts";
+import { restoreEnv } from "./helpers/env.ts";
+
+const previous = process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY;
+const supervisorBinary =
+ process.env.HACK_TEST_SUPERVISOR_BINARY ??
+ resolve(
+ ".hack-local/target/lifecycle-integration/debug/hack-lifecycle-supervisor"
+ );
+if (
+ process.env.HACK_TEST_REQUIRE_SUPERVISOR === "1" &&
+ !existsSync(supervisorBinary)
+) {
+ throw new Error(
+ "The required current-source supervisor executable was not prepared."
+ );
+}
+const nativeTest = existsSync(supervisorBinary) ? test : test.skip;
+beforeAll(() => {
+ process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY = supervisorBinary;
+});
+afterAll(() => restoreEnv("HACK_LIFECYCLE_SUPERVISOR_BINARY", previous));
+
+async function fixture(
+ run: (project: { projectRoot: string; projectDir: string }) => Promise
+) {
+ const root = await mkdtemp(join(tmpdir(), "supervisor-controller-"));
+ const project = { projectRoot: root, projectDir: join(root, ".hack") };
+ await mkdir(project.projectDir);
+ try {
+ await run(project);
+ } finally {
+ await stopLifecycleProcessController({
+ project,
+ projectName: "fixture",
+ composeProject: "fixture",
+ branch: null,
+ definitionHash: "synthetic",
+ }).catch(() => undefined);
+ await rm(root, { recursive: true, force: true });
+ }
+}
+function controller(
+ project: { projectRoot: string; projectDir: string },
+ hash = "synthetic"
+) {
+ return createLifecycleProcessController({
+ runner: "supervisor",
+ project,
+ projectName: "fixture",
+ composeProject: "fixture",
+ branch: null,
+ definitionHash: hash,
+ env: { HOME: project.projectRoot, HACK_DAEMON_AUTOSTART_DISABLE: "1" },
+ mux: async () => {
+ throw new Error("mux must not be consulted");
+ },
+ installSignalCleanup: () => ({ dispose() {} }),
+ });
+}
+const command = "printf 'controller-ready\\n'; exec sleep 60";
+async function eventually(check: () => Promise) {
+ const end = performance.now() + 5000;
+ while (!(await check())) {
+ if (performance.now() > end) {
+ throw new Error("fixture observation timed out");
+ }
+ await Bun.sleep(20);
+ }
+}
+
+nativeTest(
+ "supervisor controller starts persistent hooks incrementally without mux and keeps common logs",
+ () =>
+ fixture(async (project) => {
+ const owner = controller(project);
+ await owner.startFromCommand({
+ command: { command, persistent: true },
+ serviceName: "first",
+ });
+ let entries = await readLifecycleState(project);
+ expect(entries[0]?.processes.map((p) => p.name)).toEqual(["first"]);
+ await owner.startMany({ processes: [{ name: "second", command }] });
+ await owner.finalize();
+ entries = await readLifecycleState(project);
+ expect(entries[0]?.backend).toBe("supervisor");
+ expect(entries[0]?.processes.map((p) => p.name).sort()).toEqual([
+ "first",
+ "second",
+ ]);
+ const logPath = resolveLifecycleLogPath({
+ ...project,
+ composeProject: "fixture",
+ });
+ await eventually(async () =>
+ (await readFile(logPath, "utf8")).includes("controller-ready")
+ );
+ expect(await owner.abort()).toBeUndefined();
+ expect(await readLifecycleState(project)).toHaveLength(0);
+ })
+);
+
+nativeTest(
+ "adopting a healthy supervisor retains its token and does not acquire rollback authority",
+ () =>
+ fixture(async (project) => {
+ const first = controller(project);
+ await first.startMany({ processes: [{ name: "worker", command }] });
+ await first.finalize();
+ const original = (await readLifecycleState(project))[0];
+ const second = controller(project);
+ await second.startMany({ processes: [{ name: "worker", command }] });
+ await second.finalize();
+ expect((await readLifecycleState(project))[0]?.ownershipToken).toBe(
+ original?.ownershipToken
+ );
+ expect(second.getOperationCleanup()).toBeNull();
+ await second.abort();
+ expect((await readLifecycleState(project))[0]?.ownershipToken).toBe(
+ original?.ownershipToken
+ );
+ })
+);
+
+nativeTest(
+ "persisted supervisor stop works after owner SIGKILL and current runner changes",
+ () =>
+ fixture(async (project) => {
+ const owner = controller(project);
+ await owner.startMany({ processes: [{ name: "worker", command }] });
+ await owner.finalize();
+ const entry = (await readLifecycleState(project))[0];
+ if (!(entry?.supervisorPid && entry.ownershipToken)) {
+ throw new Error("fixture missing owner");
+ }
+ process.kill(entry.supervisorPid, "SIGKILL");
+ const status = await inspectLifecycleSupervisor({
+ ...project,
+ composeProject: "fixture",
+ ownershipToken: entry.ownershipToken,
+ });
+ expect(status.liveProcessNames).toEqual(["worker"]);
+ await stopLifecycleProcessController({
+ project,
+ projectName: "fixture",
+ composeProject: "fixture",
+ branch: null,
+ definitionHash: "changed",
+ mux: async () => {
+ throw new Error("mux must not be consulted");
+ },
+ });
+ expect(await readLifecycleState(project)).toHaveLength(0);
+ })
+);
+
+nativeTest(
+ "creator rollback cannot stop a supervisor another operation adopted",
+ () =>
+ fixture(async (project) => {
+ const first = controller(project);
+ await first.startMany({ processes: [{ name: "worker", command }] });
+ await first.finalize();
+ const second = controller(project);
+ await second.startMany({ processes: [{ name: "worker", command }] });
+ await second.finalize();
+ await first.abort();
+ const entry = (await readLifecycleState(project))[0];
+ if (!entry?.ownershipToken) {
+ throw new Error("adopted ownership discarded");
+ }
+ const status = await inspectLifecycleSupervisor({
+ ...project,
+ composeProject: "fixture",
+ ownershipToken: entry.ownershipToken,
+ });
+ expect(status.liveProcessNames).toEqual(["worker"]);
+ })
+);
+
+nativeTest(
+ "missing native root is absent while missing executable with retained root stays unknown",
+ () =>
+ fixture(async (project) => {
+ const empty = await inspectLifecycleSupervisor({
+ ...project,
+ composeProject: "fixture",
+ ownershipToken: "synthetic",
+ });
+ expect(empty.liveProcessNames).toEqual([]);
+ const owner = controller(project);
+ await owner.startMany({ processes: [{ name: "worker", command }] });
+ await owner.finalize();
+ const entry = (await readLifecycleState(project))[0];
+ if (!entry?.ownershipToken) {
+ throw new Error("fixture missing token");
+ }
+ const binary = process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY;
+ process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY =
+ "/missing-synthetic-supervisor";
+ try {
+ await expect(
+ inspectLifecycleSupervisor({
+ ...project,
+ composeProject: "fixture",
+ ownershipToken: entry.ownershipToken,
+ })
+ ).rejects.toThrow("unavailable");
+ expect((await readLifecycleState(project))[0]?.ownershipToken).toBe(
+ entry.ownershipToken
+ );
+ } finally {
+ restoreEnv("HACK_LIFECYCLE_SUPERVISOR_BINARY", binary);
+ }
+ })
+);
+
+nativeTest(
+ "human service names stay distinct while native state names remain safe",
+ () =>
+ fixture(async (project) => {
+ const owner = controller(project);
+ const names = ["service with spaces/../label", "Web", "web"];
+ await owner.startMany({
+ processes: names.map((name) => ({ name, command })),
+ });
+ await owner.finalize();
+ const entry = (await readLifecycleState(project))[0];
+ expect(entry?.processes.map((p) => p.name).sort()).toEqual(
+ [...names].sort()
+ );
+ if (!entry?.ownershipToken) {
+ throw new Error("fixture missing token");
+ }
+ const observed = await inspectLifecycleSupervisor({
+ ...project,
+ composeProject: "fixture",
+ ownershipToken: entry.ownershipToken,
+ });
+ expect([...observed.liveProcessNames].sort()).toEqual([...names].sort());
+ expect(await owner.abort()).toBeUndefined();
+ })
+);
+
+nativeTest(
+ "lost incremental reply publishes the verified late command before failure",
+ () =>
+ fixture(async (project) => {
+ const wrapper = join(project.projectRoot, "drop-add-reply");
+ await Bun.write(
+ wrapper,
+ `#!${process.execPath} --no-env-file
+const reader = Bun.stdin.stream().getReader();
+let frame = Buffer.alloc(0);
+while (true) {
+ const part = await reader.read();
+ if (part.done) process.exit(91);
+ frame = Buffer.concat([frame, part.value]);
+ if (frame.length >= 4 && frame.length >= frame.readUInt32BE(0) + 4) break;
+}
+await reader.cancel();
+const action = process.argv.at(-1);
+const child = Bun.spawn([${JSON.stringify(supervisorBinary)}, action], {env:{},stdin:frame,stdout:"pipe",stderr:"ignore"});
+child.unref();
+const reply = await new Response(child.stdout).arrayBuffer();
+if (action === "add") { await child.exited; process.exit(1); }
+await Bun.write(Bun.stdout, reply);
+if (action !== "serve") process.exit(await child.exited);
+`
+ );
+ const { chmod } = await import("node:fs/promises");
+ await chmod(wrapper, 0o700);
+ const saved = process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY;
+ process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY = wrapper;
+ const owner = controller(project);
+ try {
+ await owner.startMany({ processes: [{ name: "first", command }] });
+ await expect(
+ owner.startMany({ processes: [{ name: "late", command }] })
+ ).rejects.toThrow("reply was lost");
+ const entry = (await readLifecycleState(project))[0];
+ expect(entry?.processes.map((p) => p.name).sort()).toEqual([
+ "first",
+ "late",
+ ]);
+ if (!entry?.ownershipToken) {
+ throw new Error("late ownership missing");
+ }
+ const status = await inspectLifecycleSupervisor({
+ ...project,
+ composeProject: "fixture",
+ ownershipToken: entry.ownershipToken,
+ });
+ expect([...status.liveProcessNames].sort()).toEqual(["first", "late"]);
+ await owner.abort();
+ } finally {
+ restoreEnv("HACK_LIFECYCLE_SUPERVISOR_BINARY", saved);
+ }
+ })
+);
diff --git a/tests/lifecycle-supervisor-observers.test.ts b/tests/lifecycle-supervisor-observers.test.ts
new file mode 100644
index 000000000..9a49208c6
--- /dev/null
+++ b/tests/lifecycle-supervisor-observers.test.ts
@@ -0,0 +1,333 @@
+import { expect, test } from "bun:test";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { observeSupervisorLifecycleEntry } from "../src/commands/host-ps.ts";
+import {
+ type LifecycleStateEntry,
+ readLifecycleState,
+ upsertLifecycleStateEntry,
+} from "../src/lib/lifecycle-runtime.ts";
+import type { LifecycleSupervisorReceipt } from "../src/lib/lifecycle-supervisor.ts";
+import {
+ classifySupervisorLifecycleHygiene,
+ inspectLifecycleStateEntries,
+ type LifecycleHygieneInspection,
+ repairProjectLifecycleSessions,
+} from "../src/lib/project-lifecycle-hygiene.ts";
+import { readSupervisorLifecycleActivity } from "../src/lib/runtime-projects.ts";
+
+function entry(
+ overrides: Partial = {}
+): LifecycleStateEntry {
+ return {
+ composeProject: "sample",
+ projectName: "sample",
+ branch: null,
+ sessionName: "sample--lifecycle",
+ backend: "supervisor",
+ ownershipToken: "synthetic-owner",
+ definitionHash: "synthetic-definition",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ processes: [
+ {
+ name: "worker",
+ windowName: "worker-window",
+ logPath: "/fixture/sample.log",
+ },
+ ],
+ ...overrides,
+ };
+}
+
+function receipt(
+ overrides: Partial = {}
+): LifecycleSupervisorReceipt {
+ return {
+ version: 1,
+ supervisorPid: 500,
+ outcome: "observed",
+ processes: [
+ {
+ version: 1,
+ name: "worker",
+ ownerToken: "synthetic-owner",
+ guardPid: 501,
+ processGroupId: 501,
+ guardStartTime: 123,
+ bootId: "synthetic-boot",
+ uid: 1000,
+ commandPid: 502,
+ phase: "running",
+ exitCode: null,
+ signal: null,
+ },
+ ],
+ liveProcessNames: ["worker"],
+ unknownProcessNames: [],
+ ...overrides,
+ };
+}
+
+function classify(
+ observation: LifecycleSupervisorReceipt | null,
+ options: {
+ active?: ReadonlySet | null;
+ state?: LifecycleStateEntry;
+ } = {}
+) {
+ const state = options.state ?? entry();
+ return classifySupervisorLifecycleHygiene({
+ entries: [state],
+ receipts: new Map([[state.composeProject, observation]]),
+ activeComposeProjects:
+ options.active === undefined ? new Set() : options.active,
+ now: new Date("2026-02-01T00:00:00.000Z"),
+ });
+}
+
+test("mux absence grants no stale-state authority over supervisor ownership", () => {
+ const result = inspectLifecycleStateEntries({
+ entries: [entry()],
+ sessionsByBackend: new Map(),
+ snapshot: [],
+ });
+ expect(result.staleEntries).toEqual([]);
+ expect(result.orphanedProcessGroups).toEqual([]);
+});
+
+test("supervisor hygiene distinguishes native absence from missing and unknown evidence", () => {
+ for (const observation of [
+ null,
+ receipt({ unknownProcessNames: ["worker"] }),
+ receipt({ outcome: "refused" }),
+ ]) {
+ const result = classify(observation);
+ expect(result.staleEntries).toEqual([]);
+ expect(result.orphanedSessions).toEqual([]);
+ expect(result.unverifiedSessions).toHaveLength(1);
+ }
+ expect(classify(receipt({ liveProcessNames: [] })).staleEntries).toHaveLength(
+ 1
+ );
+});
+
+test("supervisor orphan repair admission requires absent runtime and elapsed startup grace", () => {
+ expect(classify(receipt()).orphanedSessions).toHaveLength(1);
+ expect(
+ classify(receipt(), { active: new Set(["sample"]) }).orphanedSessions
+ ).toEqual([]);
+ expect(classify(receipt(), { active: null }).orphanedSessions).toEqual([]);
+ const recent = classify(receipt(), {
+ state: entry({ updatedAt: "2026-02-01T00:00:00.000Z" }),
+ });
+ expect(recent.orphanedSessions).toEqual([]);
+ expect(recent.unverifiedSessions).toHaveLength(1);
+});
+
+test("runtime lifecycle service status preserves unknown without consulting mux", async () => {
+ const state = entry();
+ const active = await readSupervisorLifecycleActivity({
+ entry: state,
+ projectDir: "/fixture",
+ inspect: () => Promise.resolve(receipt()),
+ });
+ expect(active.runningWindows?.has("worker-window")).toBe(true);
+ const unknown = await readSupervisorLifecycleActivity({
+ entry: state,
+ projectDir: "/fixture",
+ inspect: () => Promise.reject(new Error("native evidence unavailable")),
+ });
+ expect(unknown.runningWindows?.size).toBe(0);
+ expect(unknown.unknownWindows?.has("worker-window")).toBe(true);
+});
+
+test("host ps uses native ownership and does not invent process-group CPU totals", async () => {
+ const opts = {
+ entry: entry(),
+ projectDir: "/fixture",
+ projectRoot: "/fixture",
+ };
+ const active = await observeSupervisorLifecycleEntry({
+ ...opts,
+ inspect: () => Promise.resolve(receipt()),
+ });
+ expect(active.ownership).toBe("owned-healthy");
+ expect(active.sessionIds).toEqual([501]);
+ expect(active.liveCpuTimeMs).toBeNull();
+ expect(active.processGroupIds).toEqual([]);
+ const unknown = await observeSupervisorLifecycleEntry({
+ ...opts,
+ inspect: () =>
+ Promise.resolve(receipt({ unknownProcessNames: ["worker"] })),
+ });
+ expect(unknown.ownership).toBe("unknown");
+ expect(unknown.attention).toBe("ownership_unverified");
+});
+
+function repairInspection(
+ state: LifecycleStateEntry
+): LifecycleHygieneInspection {
+ return {
+ staleEntries: [],
+ orphanedProcessGroups: [],
+ orphanedSessions: [{ entry: state, classification: "owned-healthy" }],
+ unverifiedSessions: [],
+ runtimeAvailable: true,
+ };
+}
+
+for (const outcome of ["stopped", "unknown"] as const) {
+ test(`supervisor doctor repair retains state unless native stop is confirmed (${outcome})`, async () => {
+ const root = await mkdtemp(join(tmpdir(), "supervisor-observer-"));
+ const state = entry();
+ let effects = 0;
+ try {
+ await upsertLifecycleStateEntry({ projectDir: root, entry: state });
+ const result = await repairProjectLifecycleSessions({
+ projectDir: root,
+ projectRoot: root,
+ boundary: {
+ inspect: () => Promise.resolve(repairInspection(state)),
+ backends: () => {
+ throw new Error("Supervisor repair consulted mux");
+ },
+ inspectSupervisor: () => Promise.resolve(receipt()),
+ stopSupervisor: ({ ownershipToken }) => {
+ expect(ownershipToken).toBe("synthetic-owner");
+ effects += 1;
+ return Promise.resolve(
+ receipt({
+ outcome,
+ liveProcessNames: outcome === "stopped" ? [] : ["worker"],
+ })
+ );
+ },
+ },
+ });
+ expect(effects).toBe(1);
+ expect(result.failures.length).toBe(outcome === "stopped" ? 0 : 1);
+ expect((await readLifecycleState({ projectDir: root })).length).toBe(
+ outcome === "stopped" ? 0 : 1
+ );
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+ });
+}
+
+test("doctor rechecks runtime absence after native inspection before stop", async () => {
+ const root = await mkdtemp(join(tmpdir(), "supervisor-observer-"));
+ const state = entry();
+ let inspections = 0;
+ let effects = 0;
+ try {
+ await upsertLifecycleStateEntry({ projectDir: root, entry: state });
+ const result = await repairProjectLifecycleSessions({
+ projectDir: root,
+ projectRoot: root,
+ boundary: {
+ inspect: () => {
+ inspections += 1;
+ return Promise.resolve({
+ ...repairInspection(state),
+ orphanedSessions:
+ inspections < 3
+ ? [{ entry: state, classification: "owned-healthy" as const }]
+ : [],
+ });
+ },
+ inspectSupervisor: () => Promise.resolve(receipt()),
+ stopSupervisor: () => {
+ effects += 1;
+ return Promise.resolve(
+ receipt({ outcome: "stopped", liveProcessNames: [] })
+ );
+ },
+ },
+ });
+ expect(inspections).toBe(3);
+ expect(effects).toBe(0);
+ expect(result.failures).toHaveLength(1);
+ expect(await readLifecycleState({ projectDir: root })).toHaveLength(1);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("doctor cannot stop a newly adopted cleanup epoch with the same token and timestamp", async () => {
+ const root = await mkdtemp(join(tmpdir(), "supervisor-observer-"));
+ const state = entry({ cleanupEpoch: "synthetic-first-epoch" });
+ const adopted = { ...state, cleanupEpoch: "synthetic-adopted-epoch" };
+ let effects = 0;
+ try {
+ await upsertLifecycleStateEntry({ projectDir: root, entry: state });
+ const result = await repairProjectLifecycleSessions({
+ projectDir: root,
+ projectRoot: root,
+ boundary: {
+ inspect: () => Promise.resolve(repairInspection(state)),
+ inspectSupervisor: async () => {
+ await upsertLifecycleStateEntry({ projectDir: root, entry: adopted });
+ return receipt();
+ },
+ stopSupervisor: () => {
+ effects += 1;
+ return Promise.resolve(
+ receipt({ outcome: "stopped", liveProcessNames: [] })
+ );
+ },
+ },
+ });
+ expect(effects).toBe(0);
+ expect(result.failures).toHaveLength(1);
+ expect(
+ (await readLifecycleState({ projectDir: root }))[0]?.cleanupEpoch
+ ).toBe(adopted.cleanupEpoch);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test("doctor rechecks exact persisted owner after native inspection and before stop", async () => {
+ const root = await mkdtemp(join(tmpdir(), "supervisor-observer-"));
+ const state = entry();
+ const replacement = entry({
+ ownershipToken: "synthetic-replacement",
+ updatedAt: "2026-01-02T00:00:00.000Z",
+ });
+ let effects = 0;
+ try {
+ await upsertLifecycleStateEntry({ projectDir: root, entry: state });
+ const result = await repairProjectLifecycleSessions({
+ projectDir: root,
+ projectRoot: root,
+ boundary: {
+ inspect: () => Promise.resolve(repairInspection(state)),
+ backends: () => {
+ throw new Error("Supervisor repair consulted mux");
+ },
+ inspectSupervisor: async () => {
+ await upsertLifecycleStateEntry({
+ projectDir: root,
+ entry: replacement,
+ });
+ return receipt();
+ },
+ stopSupervisor: () => {
+ effects += 1;
+ return Promise.resolve(
+ receipt({ outcome: "stopped", liveProcessNames: [] })
+ );
+ },
+ },
+ });
+ expect(effects).toBe(0);
+ expect(result.failures).toHaveLength(1);
+ expect(
+ (await readLifecycleState({ projectDir: root }))[0]?.ownershipToken
+ ).toBe(replacement.ownershipToken);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/tests/models/tla/lifecycle-controller/COVERAGE.md b/tests/models/tla/lifecycle-controller/COVERAGE.md
new file mode 100644
index 000000000..f09f06b49
--- /dev/null
+++ b/tests/models/tla/lifecycle-controller/COVERAGE.md
@@ -0,0 +1,49 @@
+# Opt-in lifecycle controller ownership model
+
+This finite model covers the CLI controller above the standalone physical-process
+model. It has one instance, one creating operation and at most one adopting
+operation. There is one initial launch and at most one incremental add. The native
+owner token remains stable (`token = 1`), while adoption advances the cleanup epoch
+from 1 to 2. It does not model arbitrary strings or independent project instances.
+
+| Transition/property | Source owner | Regression boundary |
+| --- | --- | --- |
+| `Begin`, `PublishPending`, `Launch`; `DurableBeforeLaunch` | `createSupervisorLifecycleController.begin`, controller lock, common state publication before native `serve` | `tests/lifecycle-supervisor-controller.test.ts`; native launch failure retains a durable cleanup owner |
+| `Accept`; `StableNativeOwner` | Controller `accept` and `publish`; native framed launch receipt | Controller readback and incremental real-process tests |
+| `Adopt`, `Abort`; `NoAdoptedStop` | `decide` preserves the native ownership token and writes a new cleanup epoch; `stopOwned` checks that epoch before signaling | Creator abort after another controller adopts cannot stop the adopted process |
+| `LoseReply`, `PublishLate`, `FailUnknown`; `LateFailurePublished` | Incremental launch failure inspects native state, accepts verified receipts and publishes before reporting the uncertain outcome | The real controller regression drops an incremental reply after its native effect and asserts both common-state publication before failure and native liveness of the late command |
+| `Add`, `Stop`; `StoppedPreventsAdd` | Per-instance controller lock plus native launch/stop admission lock | `tests/lifecycle-supervisor-controller.test.ts`; `packages/lifecycle-supervisor/tests/incremental.rs` concurrent add/stop and add after completed stop |
+
+Positive TLC exploration: **21 distinct states**, queue exhausted, depth 10.
+`negative.cfg` removes only the cleanup epoch fence: `Abort` violates
+`NoAdoptedStop`, with `unsafeStop = TRUE`, `epoch = 2`, `creatorEpoch = 1`,
+`adopted = TRUE` and `stage = "Aborted"` in the same state. `pending.cfg` removes
+only the pre-launch publication fence: `Launch` violates `DurableBeforeLaunch`
+with `unsafeLaunch = TRUE`, `pending = FALSE`, `live = TRUE` and
+`stage = "AwaitReply"` in the same state. Both controls exit 12 for the intended
+invariant, rather than a parser or incomplete-state error. The maintained registry
+pins the count and both complete same-state witnesses.
+
+Checker: pinned TLA+ 1.7.4 jar (TLC reports 2.19), Java 17, two workers, 512 MiB
+maximum heap and breadth-first search. Apalache is not qualified here.
+
+The model treats each controller/native lock region as atomic. Therefore stop can
+order before or after add but cannot interrupt its protected launch/publication.
+The abstract stop proves absence; real unknown outcomes retain ownership and do
+not authorize deletion. `nativePublished` represents the command receipt for the
+current launch; it is cleared for add so a lost incremental reply must publish a
+fresh verified receipt, rather than rely on the earlier launch's publication.
+No fairness or eventual command exit is assumed.
+
+Assumptions: private lock integrity, serialized writers for this instance, atomic
+state publication, native readback that correctly separates live/unknown/absent,
+and no hidden late effect after the protected native admission has settled. A
+transport timeout can precede settlement: implementation reconciliation and native
+locks handle that boundary, not an inference from socket absence.
+
+Omissions: actual PID/session identity, native guardian failure, byte framing,
+socket replacement, credentials, filesystem sync/power loss, scheduler timing,
+multiple processes' independent liveness, multiple instances, command output and
+default-runner migration. Physical-process ownership remains covered separately
+by `LifecycleSupervisor.tla` and Rust tests. This small controller model is not
+whole-product, macOS runtime, packaging or installed-artifact qualification.
diff --git a/tests/models/tla/lifecycle-controller/LifecycleController.tla b/tests/models/tla/lifecycle-controller/LifecycleController.tla
new file mode 100644
index 000000000..cd89601e2
--- /dev/null
+++ b/tests/models/tla/lifecycle-controller/LifecycleController.tla
@@ -0,0 +1,79 @@
+------------------------- MODULE LifecycleController -------------------------
+EXTENDS Naturals
+CONSTANTS SkipEpochFence, SkipPendingFence
+VARIABLES stage, token, epoch, creatorEpoch, adopted, live, pending,
+ nativePublished, stopped, unsafeStop, unsafeLaunch, unknown
+vars == <>
+
+Init == /\ stage = "Idle" /\ token = 0 /\ epoch = 0 /\ creatorEpoch = 0
+ /\ adopted = FALSE /\ live = FALSE /\ pending = FALSE
+ /\ nativePublished = FALSE /\ stopped = FALSE
+ /\ unsafeStop = FALSE /\ unsafeLaunch = FALSE /\ unknown = FALSE
+
+Begin == /\ stage = "Idle" /\ ~stopped
+ /\ stage' = "Starting" /\ token' = 1
+ /\ epoch' = 1 /\ creatorEpoch' = 1
+ /\ UNCHANGED <>
+PublishPending == /\ stage = "Starting" /\ ~pending
+ /\ pending' = TRUE
+ /\ UNCHANGED <>
+Launch == /\ stage = "Starting" /\ (pending \/ SkipPendingFence)
+ /\ stage' = "AwaitReply" /\ live' = TRUE
+ /\ unsafeLaunch' = (unsafeLaunch \/ ~pending)
+ /\ UNCHANGED <>
+Accept == /\ stage = "AwaitReply"
+ /\ stage' = "Ready" /\ nativePublished' = TRUE
+ /\ UNCHANGED <>
+LoseReply == /\ stage = "AwaitReply"
+ /\ stage' = "InspectLate"
+ /\ UNCHANGED <>
+PublishLate == /\ stage = "InspectLate" /\ live
+ /\ stage' = "LatePublished" /\ nativePublished' = TRUE
+ /\ UNCHANGED <>
+FailUnknown == /\ stage = "LatePublished" /\ nativePublished
+ /\ stage' = "Failed" /\ unknown' = TRUE
+ /\ UNCHANGED <>
+Adopt == /\ stage \in {"Ready", "Failed"} /\ live /\ ~adopted
+ /\ epoch = 1 /\ stage' = "Adopted" /\ epoch' = 2
+ /\ adopted' = TRUE
+ /\ UNCHANGED <>
+Abort == /\ stage \in {"Ready", "Failed", "Adopted"} /\ live
+ /\ (epoch = creatorEpoch \/ SkipEpochFence)
+ /\ stage' = "Aborted" /\ live' = FALSE
+ /\ unsafeStop' = (unsafeStop \/ (epoch # creatorEpoch))
+ /\ UNCHANGED <>
+Stop == /\ stage \in {"Ready", "Failed", "Adopted"}
+ /\ stage' = "Stopped" /\ live' = FALSE /\ stopped' = TRUE
+ /\ UNCHANGED <>
+Add == /\ stage = "Adopted" /\ live /\ ~stopped
+ /\ stage' = "AwaitReply" /\ nativePublished' = FALSE /\ unknown' = FALSE
+ /\ UNCHANGED <>
+Next == Begin \/ PublishPending \/ Launch \/ Accept \/ LoseReply
+ \/ PublishLate \/ FailUnknown \/ Adopt \/ Abort \/ Stop \/ Add
+Spec == Init /\ [][Next]_vars
+TypeOK == /\ stage \in {"Idle", "Starting", "AwaitReply", "Ready", "InspectLate",
+ "LatePublished", "Failed", "Adopted", "Aborted", "Stopped"}
+ /\ token \in 0..1 /\ epoch \in 0..2 /\ creatorEpoch \in 0..1
+ /\ \A b \in {adopted, live, pending, nativePublished, stopped,
+ unsafeStop, unsafeLaunch, unknown} : b \in BOOLEAN
+NoAdoptedStop == ~unsafeStop
+DurableBeforeLaunch == ~unsafeLaunch
+LateFailurePublished == unknown => nativePublished
+StoppedPreventsAdd == stopped => ~live
+StableNativeOwner == (epoch > 0) => token = 1
+=============================================================================
diff --git a/tests/models/tla/lifecycle-controller/negative.cfg b/tests/models/tla/lifecycle-controller/negative.cfg
new file mode 100644
index 000000000..f6748721f
--- /dev/null
+++ b/tests/models/tla/lifecycle-controller/negative.cfg
@@ -0,0 +1,4 @@
+SPECIFICATION Spec
+CONSTANTS SkipEpochFence = TRUE SkipPendingFence = FALSE
+INVARIANTS TypeOK NoAdoptedStop DurableBeforeLaunch LateFailurePublished StoppedPreventsAdd StableNativeOwner
+CHECK_DEADLOCK FALSE
diff --git a/tests/models/tla/lifecycle-controller/pending.cfg b/tests/models/tla/lifecycle-controller/pending.cfg
new file mode 100644
index 000000000..6ab799b7b
--- /dev/null
+++ b/tests/models/tla/lifecycle-controller/pending.cfg
@@ -0,0 +1,4 @@
+SPECIFICATION Spec
+CONSTANTS SkipEpochFence = FALSE SkipPendingFence = TRUE
+INVARIANTS TypeOK NoAdoptedStop DurableBeforeLaunch LateFailurePublished StoppedPreventsAdd StableNativeOwner
+CHECK_DEADLOCK FALSE
diff --git a/tests/models/tla/lifecycle-controller/positive.cfg b/tests/models/tla/lifecycle-controller/positive.cfg
new file mode 100644
index 000000000..29c68afda
--- /dev/null
+++ b/tests/models/tla/lifecycle-controller/positive.cfg
@@ -0,0 +1,4 @@
+SPECIFICATION Spec
+CONSTANTS SkipEpochFence = FALSE SkipPendingFence = FALSE
+INVARIANTS TypeOK NoAdoptedStop DurableBeforeLaunch LateFailurePublished StoppedPreventsAdd StableNativeOwner
+CHECK_DEADLOCK FALSE
From 9f60e7d0bb4e2fbff6721b3f0fd872b1313ea214 Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 20:28:24 -0500
Subject: [PATCH 04/55] fix(lifecycle): isolate unreadable project state in
global observations
---
docs/lifecycle.md | 15 ++++
src/commands/host-ps.ts | 18 ++++-
src/lib/lifecycle-runtime.ts | 2 +-
src/lib/project-lifecycle-supervisor.ts | 14 ++++
src/lib/runtime-projects.ts | 36 ++++++++-
tests/lifecycle-supervisor-controller.test.ts | 45 +++++++++++
tests/lifecycle-supervisor-observers.test.ts | 78 +++++++++++++++++++
7 files changed, 202 insertions(+), 6 deletions(-)
diff --git a/docs/lifecycle.md b/docs/lifecycle.md
index b3a5551b8..0bc1200c4 100644
--- a/docs/lifecycle.md
+++ b/docs/lifecycle.md
@@ -640,3 +640,18 @@ with `hack logs -f `. Stop and restart use the recorded owner even after t
runner setting changes. Unknown shutdown retains the ownership record and any
shared helper holders, and fails so `hack down` can be retried. Shared helper
commands themselves keep their existing ownership contract in this unit.
+
+### Refused ownership state
+
+Hack refuses malformed or unsafe `.hack/.internal/lifecycle/state.json` rather
+than treating it as an empty owner list. This also applies to the default mux
+runner. Global project listings continue and mark the affected project's
+lifecycle as `state_unreadable`; this is unknown, not stopped.
+
+To recover, inspect that project's state file and the processes or sessions it
+records. Confirm identity before stopping any leftover work; a PID alone may have
+been reused. Preserve the file for diagnosis. Once the recorded work is confirmed
+stopped, move the file aside within the private lifecycle directory and retry
+`hack up`. Do not discard ownership records while commands may still run. A
+symlink, foreign owner, or unreadable directory needs its owner to repair the path
+before Hack can safely proceed. `hack doctor --fix` does not bypass these checks.
diff --git a/src/commands/host-ps.ts b/src/commands/host-ps.ts
index ee1d1f348..170d1eb5c 100644
--- a/src/commands/host-ps.ts
+++ b/src/commands/host-ps.ts
@@ -88,7 +88,7 @@ export const hostPsCommand = withHandler(spec, async ({ args }) => {
return 0;
});
-async function readLifecycleObservations({
+export async function readLifecycleObservations({
registry,
filter,
snapshot,
@@ -111,7 +111,21 @@ async function readLifecycleObservations({
}
}
for (const [projectDir, projectRoot] of directories) {
- for (const entry of await readLifecycleState({ projectDir })) {
+ let entries: readonly LifecycleStateEntry[];
+ try {
+ entries = await readLifecycleState({ projectDir });
+ } catch {
+ lifecycle.push({
+ project: projectDir,
+ projectRoot,
+ session: null,
+ ownership: "unknown",
+ status: "unknown",
+ reason: "state_unreadable",
+ });
+ continue;
+ }
+ for (const entry of entries) {
lifecycle.push(
await observeLifecycleEntry({
entry,
diff --git a/src/lib/lifecycle-runtime.ts b/src/lib/lifecycle-runtime.ts
index 2ab06a580..5290702de 100644
--- a/src/lib/lifecycle-runtime.ts
+++ b/src/lib/lifecycle-runtime.ts
@@ -553,7 +553,7 @@ export async function drainLifecycleOutput(opts: {
function lifecycleStateRefused(): Error {
return new Error(
- "Lifecycle ownership state is invalid or unsafe; kept existing state. Values omitted."
+ "Lifecycle ownership state at .hack/.internal/lifecycle/state.json is invalid or unsafe; kept existing state. Inspect the file and stop verified leftover processes before moving it aside; see docs/lifecycle.md. Values omitted."
);
}
diff --git a/src/lib/project-lifecycle-supervisor.ts b/src/lib/project-lifecycle-supervisor.ts
index 223f763ee..af4e15272 100644
--- a/src/lib/project-lifecycle-supervisor.ts
+++ b/src/lib/project-lifecycle-supervisor.ts
@@ -234,6 +234,20 @@ export function createSupervisorLifecycleController(
if (!token) {
throw new Error("Lifecycle supervisor ownership was not established.");
}
+ const observed = await inspectLifecycleSupervisor({
+ ...instance,
+ ownershipToken: token,
+ });
+ if (observed.liveProcessNames.includes(process.name)) {
+ accept(observed);
+ await publish();
+ return;
+ }
+ if (observed.unknownProcessNames.includes(process.name)) {
+ throw new Error(
+ "Lifecycle command identity is unknown; ownership was retained."
+ );
+ }
const launch = {
...instance,
ownershipToken: token,
diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts
index 1cdf4188b..e50879467 100644
--- a/src/lib/runtime-projects.ts
+++ b/src/lib/runtime-projects.ts
@@ -368,7 +368,7 @@ type LifecycleActivity = {
};
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Lifecycle pseudo-services are derived from several runtime sources in one normalization step.
-async function addLifecycleProcessServices(opts: {
+export async function addLifecycleProcessServices(opts: {
readonly runtime: readonly RuntimeProject[];
}): Promise {
if (opts.runtime.length === 0) {
@@ -379,13 +379,18 @@ async function addLifecycleProcessServices(opts: {
string,
Awaited>
>();
+ const unreadable = new Set();
for (const runtime of opts.runtime) {
const workingDir = runtime.workingDir;
if (!workingDir || stateByWorkingDir.has(workingDir)) {
continue;
}
- const state = await readLifecycleState({ projectDir: workingDir });
- stateByWorkingDir.set(workingDir, state);
+ try {
+ const state = await readLifecycleState({ projectDir: workingDir });
+ stateByWorkingDir.set(workingDir, state);
+ } catch {
+ unreadable.add(workingDir);
+ }
}
const tmuxWindowsBySession = new Map | null>();
@@ -398,6 +403,31 @@ async function addLifecycleProcessServices(opts: {
out.push(runtime);
continue;
}
+ if (unreadable.has(workingDir)) {
+ const services = new Map(runtime.services);
+ const service = "lifecycle-state";
+ services.set(service, {
+ service,
+ containers: [
+ {
+ id: `lifecycle-state-${runtime.project}`,
+ project: runtime.project,
+ service,
+ state: "unknown",
+ status: "Unknown (lifecycle: state_unreadable)",
+ name: `${runtime.project}-lifecycle-state`,
+ ports: "",
+ workingDir,
+ image: null,
+ labels: { "hack.lifecycle.error": "state_unreadable" },
+ mounts: [],
+ networks: [],
+ },
+ ],
+ });
+ out.push({ ...runtime, services });
+ continue;
+ }
const state = stateByWorkingDir.get(workingDir) ?? [];
const entry = state.find((item) => item.composeProject === runtime.project);
if (!entry) {
diff --git a/tests/lifecycle-supervisor-controller.test.ts b/tests/lifecycle-supervisor-controller.test.ts
index 04cafe83b..e56d74fe9 100644
--- a/tests/lifecycle-supervisor-controller.test.ts
+++ b/tests/lifecycle-supervisor-controller.test.ts
@@ -311,3 +311,48 @@ if (action !== "serve") process.exit(await child.exited);
}
})
);
+
+nativeTest(
+ "a native command published after adoption is refreshed before another add",
+ () =>
+ fixture(async (project) => {
+ const { launchLifecycleSupervisorProcess } = await import(
+ "../src/lib/lifecycle-supervisor.ts"
+ );
+ const creator = controller(project);
+ await creator.startMany({ processes: [{ name: "first", command }] });
+ await creator.finalize();
+ const adopter = controller(project);
+ await adopter.startMany({ processes: [{ name: "first", command }] });
+ const entry = (await readLifecycleState(project))[0];
+ if (!entry?.ownershipToken) {
+ throw new Error("fixture missing token");
+ }
+ // Schedule the protected native effect after the adopter's initial snapshot.
+ await launchLifecycleSupervisorProcess({
+ ...project,
+ composeProject: "fixture",
+ ownershipToken: entry.ownershipToken,
+ action: "add",
+ process: {
+ name: "second",
+ argv: ["/bin/sh", "-c", command],
+ cwd: project.projectRoot,
+ env: {
+ HOME: project.projectRoot,
+ PATH: "/usr/bin:/bin",
+ HACK_DAEMON_AUTOSTART_DISABLE: "1",
+ },
+ },
+ });
+ await adopter.startMany({
+ processes: [{ name: "second", command: "exit 99" }],
+ });
+ await adopter.finalize();
+ expect(
+ (await readLifecycleState(project))[0]?.processes
+ .map((p) => p.name)
+ .sort()
+ ).toEqual(["first", "second"]);
+ })
+);
diff --git a/tests/lifecycle-supervisor-observers.test.ts b/tests/lifecycle-supervisor-observers.test.ts
index 9a49208c6..b4e978eea 100644
--- a/tests/lifecycle-supervisor-observers.test.ts
+++ b/tests/lifecycle-supervisor-observers.test.ts
@@ -331,3 +331,81 @@ test("doctor rechecks exact persisted owner after native inspection and before s
await rm(root, { recursive: true, force: true });
}
});
+
+test("one malformed project stays unknown without aborting global observers", async () => {
+ const { mkdir, writeFile } = await import("node:fs/promises");
+ const { readLifecycleObservations } = await import(
+ "../src/commands/host-ps.ts"
+ );
+ const { addLifecycleProcessServices } = await import(
+ "../src/lib/runtime-projects.ts"
+ );
+ const root = await mkdtemp(join(tmpdir(), "lifecycle-global-unreadable-"));
+ const bad = join(root, "bad", ".hack");
+ const good = join(root, "good", ".hack");
+ try {
+ await mkdir(join(bad, ".internal", "lifecycle"), { recursive: true });
+ await mkdir(good, { recursive: true });
+ await writeFile(
+ join(bad, ".internal", "lifecycle", "state.json"),
+ "{truncated"
+ );
+ const common = {
+ projectDirName: ".hack" as const,
+ createdAt: "2026-01-01T00:00:00Z",
+ };
+ const registry = {
+ version: 1 as const,
+ projects: [
+ {
+ ...common,
+ id: "bad",
+ name: "bad",
+ projectDir: bad,
+ repoRoot: join(root, "bad"),
+ },
+ {
+ ...common,
+ id: "good",
+ name: "good",
+ projectDir: good,
+ repoRoot: join(root, "good"),
+ },
+ ],
+ };
+ const host = await readLifecycleObservations({
+ registry,
+ filter: undefined,
+ snapshot: [],
+ });
+ expect(host).toHaveLength(1);
+ expect(host[0]?.reason).toBe("state_unreadable");
+ expect(host[0]?.ownership).toBe("unknown");
+ const runtime = await addLifecycleProcessServices({
+ runtime: [
+ {
+ project: "bad",
+ workingDir: bad,
+ services: new Map(),
+ isGlobal: false,
+ },
+ {
+ project: "good",
+ workingDir: good,
+ services: new Map(),
+ isGlobal: false,
+ },
+ ],
+ });
+ expect(runtime).toHaveLength(2);
+ expect(
+ runtime[0]?.services.get("lifecycle-state")?.containers[0]?.state
+ ).toBe("unknown");
+ expect(runtime[1]?.services.size).toBe(0);
+ await expect(readLifecycleState({ projectDir: bad })).rejects.toThrow(
+ "state.json"
+ );
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
From bb285fdda3ad951034fa89451e624256c3d34ed2 Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 20:30:44 -0500
Subject: [PATCH 05/55] fix(lifecycle): exclude unreadable-state placeholders
from container cleanup
---
src/commands/host-ps.ts | 24 ++++++++----
src/lib/runtime-projects.ts | 7 +++-
tests/lifecycle-supervisor-observers.test.ts | 40 ++++++++++++++++++++
3 files changed, 62 insertions(+), 9 deletions(-)
diff --git a/src/commands/host-ps.ts b/src/commands/host-ps.ts
index 170d1eb5c..b08a743d8 100644
--- a/src/commands/host-ps.ts
+++ b/src/commands/host-ps.ts
@@ -98,30 +98,38 @@ export async function readLifecycleObservations({
readonly snapshot: readonly ObservedProcess[] | null;
}): Promise[]> {
const lifecycle: Record[] = [];
- const directories = new Map();
+ const directories = new Map<
+ string,
+ { readonly root: string; readonly name: string }
+ >();
for (const project of registry.projects.filter(
(project) => !filter || project.name === filter
)) {
- directories.set(project.projectDir, project.repoRoot);
+ directories.set(project.projectDir, {
+ root: project.repoRoot,
+ name: project.name,
+ });
for (const worktree of project.worktrees ?? []) {
- directories.set(
- resolve(worktree.path, project.projectDirName),
- worktree.path
- );
+ directories.set(resolve(worktree.path, project.projectDirName), {
+ root: worktree.path,
+ name: project.name,
+ });
}
}
- for (const [projectDir, projectRoot] of directories) {
+ for (const [projectDir, project] of directories) {
+ const projectRoot = project.root;
let entries: readonly LifecycleStateEntry[];
try {
entries = await readLifecycleState({ projectDir });
} catch {
lifecycle.push({
- project: projectDir,
+ project: project.name,
projectRoot,
session: null,
ownership: "unknown",
status: "unknown",
reason: "state_unreadable",
+ attention: "state_unreadable",
});
continue;
}
diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts
index e50879467..eea843a1f 100644
--- a/src/lib/runtime-projects.ts
+++ b/src/lib/runtime-projects.ts
@@ -406,9 +406,11 @@ export async function addLifecycleProcessServices(opts: {
if (unreadable.has(workingDir)) {
const services = new Map(runtime.services);
const service = "lifecycle-state";
+ const existing = services.get(service);
services.set(service, {
service,
containers: [
+ ...(existing?.containers ?? []),
{
id: `lifecycle-state-${runtime.project}`,
project: runtime.project,
@@ -419,7 +421,10 @@ export async function addLifecycleProcessServices(opts: {
ports: "",
workingDir,
image: null,
- labels: { "hack.lifecycle.error": "state_unreadable" },
+ labels: {
+ "hack.lifecycle.process": "true",
+ "hack.lifecycle.error": "state_unreadable",
+ },
mounts: [],
networks: [],
},
diff --git a/tests/lifecycle-supervisor-observers.test.ts b/tests/lifecycle-supervisor-observers.test.ts
index b4e978eea..7b33432e6 100644
--- a/tests/lifecycle-supervisor-observers.test.ts
+++ b/tests/lifecycle-supervisor-observers.test.ts
@@ -402,6 +402,46 @@ test("one malformed project stays unknown without aborting global observers", as
runtime[0]?.services.get("lifecycle-state")?.containers[0]?.state
).toBe("unknown");
expect(runtime[1]?.services.size).toBe(0);
+ const { findOrphanRuntimeProjects } = await import(
+ "../src/lib/project-runtime-hygiene.ts"
+ );
+ expect(await findOrphanRuntimeProjects({ runtime })).toEqual([]);
+ const real = {
+ id: "synthetic-real-container",
+ project: "bad",
+ service: "lifecycle-state",
+ state: "running",
+ status: "Up",
+ name: "synthetic-real-container",
+ ports: "",
+ workingDir: bad,
+ image: null,
+ labels: {},
+ mounts: [],
+ networks: [],
+ };
+ const withExisting = await addLifecycleProcessServices({
+ runtime: [
+ {
+ project: "bad",
+ workingDir: bad,
+ services: new Map([
+ [
+ "lifecycle-state",
+ { service: "lifecycle-state", containers: [real] },
+ ],
+ ]),
+ isGlobal: false,
+ },
+ ],
+ });
+ expect(
+ withExisting[0]?.services.get("lifecycle-state")?.containers
+ ).toHaveLength(2);
+ expect(
+ (await findOrphanRuntimeProjects({ runtime: withExisting }))[0]
+ ?.containerIds
+ ).toEqual([real.id]);
await expect(readLifecycleState({ projectDir: bad })).rejects.toThrow(
"state.json"
);
From 55aec84e21f30b68cd570003b27866df037a913f Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 20:46:32 -0500
Subject: [PATCH 06/55] fix(lifecycle): recover receipts across case-only
command renames
---
packages/lifecycle-supervisor/src/os.rs | 24 +++++++++++++++++--
packages/lifecycle-supervisor/src/storage.rs | 4 +++-
.../lifecycle-supervisor/tests/fake_caller.rs | 15 ++++++++++++
3 files changed, 40 insertions(+), 3 deletions(-)
diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs
index 7ec5c168e..126ae4a43 100644
--- a/packages/lifecycle-supervisor/src/os.rs
+++ b/packages/lifecycle-supervisor/src/os.rs
@@ -62,12 +62,12 @@ pub struct Identity {
pub fn inspect(pid: i32) -> Result> {
use std::os::unix::fs::MetadataExt;
let path = format!("/proc/{pid}/stat");
- let text = match fs::read_to_string(&path) {
+ let bytes = match fs::read(&path) {
Ok(value) => value,
Err(error) if absent_error(&error) => return Ok(None),
Err(error) => return Err(error),
};
- let tail = text.rsplit_once(") ").ok_or_else(refused)?.1;
+ let tail = proc_stat_tail(&bytes)?;
let fields: Vec<_> = tail.split_whitespace().collect();
let group = fields
.get(2)
@@ -96,6 +96,17 @@ pub fn inspect(pid: i32) -> Result > {
}))
}
+#[cfg(target_os = "linux")]
+fn proc_stat_tail(bytes: &[u8]) -> Result<&str> {
+ // comm is arbitrary bytes and may itself contain parentheses. Only the
+ // fields following its final delimiter have an ASCII/UTF-8 contract.
+ let end = bytes
+ .windows(2)
+ .rposition(|v| v == b") ")
+ .ok_or_else(refused)?;
+ std::str::from_utf8(&bytes[end + 2..]).map_err(|_| refused())
+}
+
#[cfg(target_os = "linux")]
pub fn census() -> Result> {
let mut result = Vec::new();
@@ -543,6 +554,15 @@ pub fn timestamp() -> Result {
#[cfg(test)]
mod tests {
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn non_utf8_process_name_does_not_invalidate_identity_fields() {
+ assert_eq!(
+ super::proc_stat_tail(b"42 (name\xff) nested) S 1 2 3").unwrap(),
+ "S 1 2 3"
+ );
+ assert!(super::proc_stat_tail(b"42 (name) S \xff").is_err());
+ }
#[test]
fn proc_exit_during_open_or_read_is_absent_not_census_failure() {
for errno in [libc::ENOENT, libc::ESRCH] {
diff --git a/packages/lifecycle-supervisor/src/storage.rs b/packages/lifecycle-supervisor/src/storage.rs
index 593560a0c..6c28444fa 100644
--- a/packages/lifecycle-supervisor/src/storage.rs
+++ b/packages/lifecycle-supervisor/src/storage.rs
@@ -184,7 +184,9 @@ impl Root {
Read::by_ref(&mut file).take(8193).read_to_end(&mut bytes)?;
let state: State = serde_json::from_slice(&bytes).map_err(|_| refused())?;
if state.version != 1
- || state.name != name
+ // Case-insensitive filesystems may return a retained receipt under
+ // its previous spelling after a case-only command rename.
+ || !state.name.eq_ignore_ascii_case(name)
|| state.guard_pid <= 1
|| state.process_group_id != state.guard_pid
|| state.guard_start_time == 0
diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs
index bb2683c59..dd3a3aae7 100644
--- a/packages/lifecycle-supervisor/tests/fake_caller.rs
+++ b/packages/lifecycle-supervisor/tests/fake_caller.rs
@@ -804,6 +804,21 @@ fn new_generation_ignores_retained_terminal_receipts() {
);
}
+#[test]
+fn case_only_rename_accepts_terminal_receipt_with_previous_spelling() {
+ let f = Fixture::new();
+ let (mut first, _) = f.start(vec![f.process("Worker", "hold")]);
+ f.await_log("fixture-ready");
+ f.stop();
+ assert!(wait(&mut first, TIMEOUT).success());
+ // Also emulate the case-insensitive lookup on Linux: the payload retains
+ // its original spelling while the lookup uses the new command name.
+ fs::rename(f.root.join("Worker.json"), f.root.join("worker.json")).unwrap();
+ let (mut next, _) = f.start(vec![f.process("worker", "hold")]);
+ f.stop();
+ assert!(wait(&mut next, TIMEOUT).success());
+}
+
#[test]
fn command_joining_guard_group_still_receives_kill() {
let f = Fixture::new();
From c2bc1ff15cabbf4848c2a21006e9ac6c46c49701 Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 20:52:18 -0500
Subject: [PATCH 07/55] fix(lifecycle): isolate supervisor control and
serialize doctor retirement
---
docs/lifecycle-supervisor.md | 14 +++++
.../lifecycle-supervisor/src/incremental.rs | 49 +++++++++++++++++
src/lib/lifecycle-supervisor.ts | 30 +++++++++--
src/lib/project-lifecycle-hygiene.ts | 16 ++++++
tests/lifecycle-supervisor-observers.test.ts | 54 ++++++++++++++++++-
5 files changed, 158 insertions(+), 5 deletions(-)
diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md
index 24a426ce9..544507e94 100644
--- a/docs/lifecycle-supervisor.md
+++ b/docs/lifecycle-supervisor.md
@@ -225,3 +225,17 @@ missing tools and unknown identities cannot authorize repair or state removal.
Sending TERM to the project owner invokes its owned rollback path; this is not a
finite protocol request and has no response frame. Use the finite `stop` operation
when a confirmation receipt is needed. Logs currently have no automatic rotation.
+
+The CLI keeps native receipts, owner capabilities and `control.sock` in the
+host's Hack home under `lifecycle-supervisors/`, outside the
+checkout. The shared log stays in the existing project log location. Do not bind
+mount the host Hack home into containers. A Hack home inside the checkout is
+refused for this runner. Linux incremental launch peers must have the same UID
+and PID, user and mount namespaces; macOS peers must have the same UID (Compose
+runs in a separate VM). Same-user host code is trusted. Earlier experimental
+checkout-local owners must be stopped with their original CLI before upgrading;
+leftover native roots are never treated as proof of absence.
+
+Doctor holds the same instance controller lock as `up` throughout final
+inspection, generation validation, native stop and conditional state removal.
+Adoption cannot interleave between validation and the stop effect.
diff --git a/packages/lifecycle-supervisor/src/incremental.rs b/packages/lifecycle-supervisor/src/incremental.rs
index c8abf596b..4b9040f8b 100644
--- a/packages/lifecycle-supervisor/src/incremental.rs
+++ b/packages/lifecycle-supervisor/src/incremental.rs
@@ -23,6 +23,54 @@ use std::{
const SOCKET: &str = "control.sock";
+/// Socket permissions alone do not distinguish a same-UID container peer.
+#[cfg(target_os = "linux")]
+fn verify_peer(stream: &UnixStream) -> Result<()> {
+ use std::os::unix::fs::MetadataExt;
+ // SAFETY: getsockopt writes at most the supplied live ucred buffer.
+ let credentials = unsafe {
+ let mut credentials: libc::ucred = std::mem::zeroed();
+ let mut length = std::mem::size_of_val(&credentials) as libc::socklen_t;
+ if libc::getsockopt(
+ stream.as_raw_fd(),
+ libc::SOL_SOCKET,
+ libc::SO_PEERCRED,
+ (&mut credentials as *mut libc::ucred).cast(),
+ &mut length,
+ ) != 0
+ || length as usize != std::mem::size_of_val(&credentials)
+ {
+ return Err(refused());
+ }
+ credentials
+ };
+ // SAFETY: geteuid has no preconditions.
+ if credentials.uid != unsafe { libc::geteuid() } || credentials.pid <= 1 {
+ return Err(refused());
+ }
+ for namespace in ["pid", "user", "mnt"] {
+ let own = fs::metadata(format!("/proc/self/ns/{namespace}"))?;
+ let peer = fs::metadata(format!("/proc/{}/ns/{namespace}", credentials.pid))?;
+ if own.dev() != peer.dev() || own.ino() != peer.ino() {
+ return Err(refused());
+ }
+ }
+ Ok(())
+}
+
+#[cfg(target_os = "macos")]
+fn verify_peer(stream: &UnixStream) -> Result<()> {
+ let mut uid = 0;
+ let mut gid = 0;
+ // SAFETY: getpeereid writes two live scalar buffers; the descriptor is live.
+ if unsafe { libc::getpeereid(stream.as_raw_fd(), &mut uid, &mut gid) } != 0
+ || uid != unsafe { libc::geteuid() }
+ {
+ return Err(refused());
+ }
+ Ok(())
+}
+
struct SocketOwner {
device: u64,
inode: u64,
@@ -261,6 +309,7 @@ fn handle_add(
children: &mut Vec,
response_started: &mut bool,
) -> Result<()> {
+ verify_peer(stream)?;
let request: Launch = protocol::read(stream)?;
supervisor::validate_launch(&request)?;
if request.root != root.path
diff --git a/src/lib/lifecycle-supervisor.ts b/src/lib/lifecycle-supervisor.ts
index 48d7b44b1..053fffbb9 100644
--- a/src/lib/lifecycle-supervisor.ts
+++ b/src/lib/lifecycle-supervisor.ts
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { lstat } from "node:fs/promises";
-import { dirname, resolve } from "node:path";
+import { dirname, isAbsolute, relative, resolve } from "node:path";
+import { resolveGlobalHackDir } from "./config-paths.ts";
import { isRecord } from "./guards.ts";
import { resolveLifecycleLogPath } from "./lifecycle-runtime.ts";
import { ensurePrivateDirectory } from "./private-file.ts";
@@ -46,12 +47,22 @@ type Instance = {
const LIMIT = 1024 * 1024;
const REQUEST_TIMEOUT_MS = 35_000;
-/** The common log stays where existing lifecycle readers expect it. */
+/** Control capabilities must never be published through checkout bind mounts. */
export function resolveLifecycleSupervisorRoot(opts: {
readonly projectDir: string;
readonly composeProject: string;
}): string {
- return `${resolveLifecycleLogPath(opts)}.supervisor`;
+ const hostRoot = resolve(resolveGlobalHackDir(), "lifecycle-supervisors");
+ const inside = relative(dirname(resolve(opts.projectDir)), hostRoot);
+ if (inside === "" || !(inside.startsWith("../") || isAbsolute(inside))) {
+ throw new Error(
+ "Lifecycle supervisor state must be outside the project checkout."
+ );
+ }
+ const key = createHash("sha256")
+ .update(JSON.stringify([resolve(opts.projectDir), opts.composeProject]))
+ .digest("hex");
+ return resolve(hostRoot, key);
}
/** Resolve only the current installation or an explicit test executable. */
@@ -113,7 +124,18 @@ async function lifecycleSupervisorRootAbsent(opts: Instance): Promise {
return false;
} catch (error) {
if (isRecord(error) && error.code === "ENOENT") {
- return true;
+ // Never turn an earlier experimental checkout-local owner into absence.
+ try {
+ await lstat(`${resolveLifecycleLogPath(opts)}.supervisor`);
+ } catch (legacyError) {
+ if (isRecord(legacyError) && legacyError.code === "ENOENT") {
+ return true;
+ }
+ throw failure();
+ }
+ throw new Error(
+ "Stop the earlier experimental supervisor with its original CLI before upgrading."
+ );
}
throw failure();
}
diff --git a/src/lib/project-lifecycle-hygiene.ts b/src/lib/project-lifecycle-hygiene.ts
index 1fc940294..a98da9ea4 100644
--- a/src/lib/project-lifecycle-hygiene.ts
+++ b/src/lib/project-lifecycle-hygiene.ts
@@ -14,6 +14,7 @@ import {
inspectLifecycleSupervisor,
type LifecycleSupervisorReceipt,
stopLifecycleSupervisor,
+ withLifecycleSupervisorControllerLock,
} from "./lifecycle-supervisor.ts";
import { readProcessIdentities } from "./process-tree.ts";
import {
@@ -435,6 +436,21 @@ async function repairSupervisorSession(opts: {
readonly inspect: typeof inspectProjectLifecycleHygiene;
readonly inspectSupervisor: typeof inspectLifecycleSupervisor;
readonly stopSupervisor: typeof stopLifecycleSupervisor;
+}): Promise {
+ return await withLifecycleSupervisorControllerLock({
+ projectDir: opts.projectDir,
+ composeProject: opts.entry.composeProject,
+ run: () => repairSupervisorSessionLocked(opts),
+ });
+}
+
+async function repairSupervisorSessionLocked(opts: {
+ readonly projectDir: string;
+ readonly projectRoot: string;
+ readonly entry: LifecycleStateEntry;
+ readonly inspect: typeof inspectProjectLifecycleHygiene;
+ readonly inspectSupervisor: typeof inspectLifecycleSupervisor;
+ readonly stopSupervisor: typeof stopLifecycleSupervisor;
}): Promise {
try {
const ownershipToken = opts.entry.ownershipToken;
diff --git a/tests/lifecycle-supervisor-observers.test.ts b/tests/lifecycle-supervisor-observers.test.ts
index 7b33432e6..35dd2db28 100644
--- a/tests/lifecycle-supervisor-observers.test.ts
+++ b/tests/lifecycle-supervisor-observers.test.ts
@@ -8,7 +8,11 @@ import {
readLifecycleState,
upsertLifecycleStateEntry,
} from "../src/lib/lifecycle-runtime.ts";
-import type { LifecycleSupervisorReceipt } from "../src/lib/lifecycle-supervisor.ts";
+import {
+ type LifecycleSupervisorReceipt,
+ resolveLifecycleSupervisorRoot,
+ withLifecycleSupervisorControllerLock,
+} from "../src/lib/lifecycle-supervisor.ts";
import {
classifySupervisorLifecycleHygiene,
inspectLifecycleStateEntries,
@@ -449,3 +453,51 @@ test("one malformed project stays unknown without aborting global observers", as
await rm(root, { recursive: true, force: true });
}
});
+
+test("supervisor control state is outside the checkout", () => {
+ const root = resolveLifecycleSupervisorRoot({
+ projectDir: "/synthetic-checkout/.hack",
+ composeProject: "sample",
+ });
+ expect(root.startsWith("/synthetic-checkout/")).toBe(false);
+ expect(root).toContain("/lifecycle-supervisors/");
+});
+
+test("doctor holds the controller lock through native stop and state removal", async () => {
+ const root = await mkdtemp(join(tmpdir(), "supervisor-observer-"));
+ const state = entry();
+ let acquired = false;
+ let competitor: Promise | undefined;
+ try {
+ await upsertLifecycleStateEntry({ projectDir: root, entry: state });
+ const result = await repairProjectLifecycleSessions({
+ projectDir: root,
+ projectRoot: root,
+ boundary: {
+ inspect: () => Promise.resolve(repairInspection(state)),
+ inspectSupervisor: () => Promise.resolve(receipt()),
+ stopSupervisor: async () => {
+ competitor = withLifecycleSupervisorControllerLock({
+ projectDir: root,
+ composeProject: state.composeProject,
+ run: async () => {
+ acquired = true;
+ expect(
+ await readLifecycleState({ projectDir: root })
+ ).toHaveLength(0);
+ },
+ });
+ await Bun.sleep(50);
+ expect(acquired).toBe(false);
+ return receipt({ outcome: "stopped", liveProcessNames: [] });
+ },
+ },
+ });
+ expect(result.failures).toHaveLength(0);
+ await competitor;
+ expect(acquired).toBe(true);
+ } finally {
+ await competitor;
+ await rm(root, { recursive: true, force: true });
+ }
+});
From 597eb47788f579b5073a99b3ba70902bbfd4000d Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 20:54:38 -0500
Subject: [PATCH 08/55] fix(lifecycle): accept inherited subprocess environment
names
---
packages/lifecycle-supervisor/src/protocol.rs | 27 ++++++++++++++-----
1 file changed, 21 insertions(+), 6 deletions(-)
diff --git a/packages/lifecycle-supervisor/src/protocol.rs b/packages/lifecycle-supervisor/src/protocol.rs
index 0a795a3e2..1781fa52a 100644
--- a/packages/lifecycle-supervisor/src/protocol.rs
+++ b/packages/lifecycle-supervisor/src/protocol.rs
@@ -77,12 +77,7 @@ impl Process {
|| self.argv[0].is_empty()
|| self.argv.iter().any(|v| v.contains('\0'))
|| self.env.iter().any(|(key, value)| {
- let mut bytes = key.bytes();
- !bytes
- .next()
- .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
- || !bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
- || value.contains('\0')
+ key.is_empty() || key.contains(['\0', '=']) || value.contains('\0')
})
{
return Err(refused());
@@ -91,6 +86,26 @@ impl Process {
}
}
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn inherited_environment_uses_exec_constraints_not_shell_identifiers() {
+ let mut process: super::Process = serde_json::from_value(serde_json::json!({
+ "name": "worker", "argv": ["/usr/bin/true"], "cwd": "/",
+ "env": {"BASH_FUNC_fixture%%": "() { :; }", "1.name": "synthetic"}
+ }))
+ .unwrap();
+ assert!(process.validate().is_ok());
+ for key in ["", "bad=name", "bad\0name"] {
+ process.env.insert(key.into(), "synthetic".into());
+ assert!(process.validate().is_err());
+ process.env.remove(key);
+ }
+ process.env.insert("VALID".into(), "bad\0value".into());
+ assert!(process.validate().is_err());
+ }
+}
+
/// Length and deadline, never descriptor closure, delimit a handoff. Polling
/// handles Darwin's brief inheritable-descriptor window without waiting on EOF.
pub fn read(input: &mut (impl Read + AsRawFd)) -> Result {
From bd9d8f43ba83c486636a4dbfa17de188453b4223 Mon Sep 17 00:00:00 2001
From: Codex
Date: Fri, 13 Nov 2026 20:54:45 -0500
Subject: [PATCH 09/55] feat(lifecycle): package supervisor with CLI
distributions
---
.github/workflows/ci.yml | 5 +-
.github/workflows/release.yml | 2 +
.hack/README.md | 1 +
.hack/toolchain/run.sh | 4 +-
docker/slim-runtime/Dockerfile | 8 +-
docs/guides/prereleases.md | 17 ++
docs/versions/v5/install.md | 17 ++
package.json | 1 +
packages/cli/package.json | 4 +-
scripts/build-lifecycle-supervisor.ts | 84 +++++++++
scripts/build-native-candidate.sh | 7 +-
scripts/build-release.ts | 12 ++
scripts/check-release-lifecycle-supervisor.ts | 171 ++++++++++++++++++
scripts/install-cli.ts | 63 +++++--
scripts/install-codex-slim.sh | 1 +
scripts/install-prerelease.py | 9 +-
scripts/installer/bundle.ts | 1 +
scripts/installer/integrity.ts | 13 +-
scripts/prerelease-plan.ts | 12 +-
scripts/update-homebrew-tap.ts | 2 +
src/lib/release-install.ts | 66 +++++++
src/lib/release-staging.ts | 2 +
tests/prerelease-plan.test.ts | 48 ++++-
tests/python/test_prerelease_install.py | 19 ++
tests/release-download-installer.test.ts | 22 ++-
tests/release-host-smoke.test.ts | 8 +
tests/update-homebrew-tap.test.ts | 1 +
tests/upgrade-installer.test.ts | 35 ++++
28 files changed, 596 insertions(+), 39 deletions(-)
create mode 100644 scripts/build-lifecycle-supervisor.ts
create mode 100644 scripts/check-release-lifecycle-supervisor.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9579d8a32..ee1b59faf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -274,12 +274,13 @@ jobs:
"$RUNNER_TEMP/config-schema/bin/python" scripts/check-config-schema.py
- name: Test transport, projections and compiled bundle without host toolchains
run: |
- bunx ultracite check scripts/build-config-compiler.ts scripts/build-release.ts scripts/check-release-config-compiler.ts scripts/check-config-compiler-cli.ts tests/config-compiler-build.test.ts tests/native-config-compiler.test.ts tests/native-config-command.test.ts tests/native-config-dto.test.ts tests/native-project-inputs.test.ts tests/native-project-inputs-acquisition-race.test.ts tests/release-download-installer.test.ts
+ bunx ultracite check scripts/build-lifecycle-supervisor.ts scripts/check-release-lifecycle-supervisor.ts scripts/build-config-compiler.ts scripts/build-release.ts scripts/check-release-config-compiler.ts scripts/check-config-compiler-cli.ts tests/config-compiler-build.test.ts tests/native-config-compiler.test.ts tests/native-config-command.test.ts tests/native-config-dto.test.ts tests/native-project-inputs.test.ts tests/native-project-inputs-acquisition-race.test.ts tests/release-download-installer.test.ts
bun test tests/config-compiler-build.test.ts tests/native-config-compiler.test.ts tests/native-config-command.test.ts tests/native-config-dto.test.ts tests/native-project-inputs.test.ts tests/native-project-inputs-acquisition-race.test.ts
bun run --cwd packages/cli typecheck
bun test tests/release-download-installer.test.ts
bun run build:release --skip-tests --out=dist/config-compiler-release
bun scripts/check-release-config-compiler.ts dist/config-compiler-release
+ bun scripts/check-release-lifecycle-supervisor.ts dist/config-compiler-release
bun scripts/check-config-compiler-cli.ts
bunx ultracite check scripts/check-native-env-plan-cli.ts tests/native-env-plan-transport.test.ts tests/native-env-metadata.test.ts
bun test tests/native-env-plan-transport.test.ts tests/native-env-metadata.test.ts tests/project-env-config.test.ts tests/project-env-metadata.test.ts
@@ -597,7 +598,7 @@ jobs:
--tag hack-runtime-ci:slim
- name: Smoke slim runtime image defaults
run: |
- docker run --rm --entrypoint sh hack-runtime-ci:slim -lc 'command -v bun >/dev/null && command -v hack >/dev/null && test "${HACK_EXECUTION_MODE}" = "codex" && test "${HACK_DAEMON_DISABLE_DOCKER_EVENTS}" = "1" && hack --help >/tmp/hack-help.txt && grep -q "Usage:" /tmp/hack-help.txt'
+ docker run --rm --entrypoint sh hack-runtime-ci:slim -lc 'command -v bun >/dev/null && command -v hack >/dev/null && test -x /usr/local/bin/hack-lifecycle-supervisor && test "${HACK_EXECUTION_MODE}" = "codex" && test "${HACK_DAEMON_DISABLE_DOCKER_EVENTS}" = "1" && hack --help >/tmp/hack-help.txt && grep -q "Usage:" /tmp/hack-help.txt'
- name: Smoke slim runtime mounted-project env flow
run: bash scripts/portable-container-smoke.sh hack-runtime-ci:slim linux/amd64
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3af8a3708..a6f83675e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -121,6 +121,8 @@ jobs:
run: bun run build:release "--version=$RELEASE_VERSION" --skip-tests
- name: Qualify installed release compiler without host runtimes or network
run: bun scripts/check-release-config-compiler.ts dist/release
+ - name: Qualify installed lifecycle supervisor
+ run: bun scripts/check-release-lifecycle-supervisor.ts dist/release
- name: Upload tarball
env:
GH_TOKEN: ${{ github.token }}
diff --git a/.hack/README.md b/.hack/README.md
index a3ef5e841..0f64416ff 100644
--- a/.hack/README.md
+++ b/.hack/README.md
@@ -16,6 +16,7 @@ hack run --profile toolchain toolchain -- test
hack run --profile toolchain toolchain -- check
hack run --profile toolchain toolchain -- rust
hack run --profile toolchain toolchain -- rust-check
+hack run --profile toolchain toolchain -- lifecycle-supervisor
hack run --profile toolchain toolchain -- config-compiler
hack run --profile toolchain toolchain -- build
hack run --profile toolchain toolchain -- exec bun index.ts --help
diff --git a/.hack/toolchain/run.sh b/.hack/toolchain/run.sh
index d887d7f29..5f1b92b84 100644
--- a/.hack/toolchain/run.sh
+++ b/.hack/toolchain/run.sh
@@ -20,6 +20,7 @@ case "$task" in
check) install_deps; bun run typecheck; exec bun run check ;;
rust-check) cargo fmt --manifest-path packages/runtime-core/Cargo.toml --check; exec cargo clippy --locked --manifest-path packages/runtime-core/Cargo.toml --target-dir /build/rust --all-targets --all-features --jobs 2 -- -D warnings ;;
rust) exec cargo test --all-features --locked --manifest-path packages/runtime-core/Cargo.toml --target-dir /build/rust --jobs 2 "$@" ;;
+ lifecycle-supervisor) exec bun scripts/build-lifecycle-supervisor.ts ;;
config-compiler)
install_deps
cargo fmt --manifest-path packages/config-compiler/Cargo.toml --check
@@ -30,6 +31,7 @@ case "$task" in
;;
build)
install_deps
+ bun scripts/build-lifecycle-supervisor.ts
# Bun 1.3.9 stages in cwd; its cross-device fallback can emit zero-filled
# executables when cwd is the host bind mount and dist is a named volume.
cd /app/dist
@@ -38,5 +40,5 @@ case "$task" in
exec /app/dist/hack --version
;;
exec) exec "$@" ;;
- *) echo "Tasks: models, install, test [paths], check, rust [args], rust-check, config-compiler, build, exec " >&2; exit 2 ;;
+ *) echo "Tasks: models, install, test [paths], check, rust [args], rust-check, lifecycle-supervisor, config-compiler, build, exec " >&2; exit 2 ;;
esac
diff --git a/docker/slim-runtime/Dockerfile b/docker/slim-runtime/Dockerfile
index 7a0a9b174..468423658 100644
--- a/docker/slim-runtime/Dockerfile
+++ b/docker/slim-runtime/Dockerfile
@@ -1,5 +1,10 @@
# syntax=docker/dockerfile:1.7
+FROM rust:1.97.1-slim-bookworm AS supervisor-build
+WORKDIR /src
+COPY packages/lifecycle-supervisor ./packages/lifecycle-supervisor
+RUN cargo +1.97.1 build --locked --release --jobs 2 --manifest-path packages/lifecycle-supervisor/Cargo.toml --target-dir /build
+
FROM oven/bun:1.4.4 AS hack-build
WORKDIR /src
@@ -33,8 +38,9 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
COPY --from=hack-build /out/hack /usr/local/bin/hack
+COPY --from=supervisor-build /build/release/hack-lifecycle-supervisor /usr/local/bin/hack-lifecycle-supervisor
-RUN chmod +x /usr/local/bin/hack \
+RUN chmod 755 /usr/local/bin/hack /usr/local/bin/hack-lifecycle-supervisor \
&& mkdir -p /var/lib/hack /workspace
ENV HOME=/var/lib/hack
diff --git a/docs/guides/prereleases.md b/docs/guides/prereleases.md
index 8d2eba59f..a9c6bd62e 100644
--- a/docs/guides/prereleases.md
+++ b/docs/guides/prereleases.md
@@ -265,3 +265,20 @@ exec, run, restart and retaining shutdown, then exercise its browser sign-in,
data-backed pages and assets. Record the bundle version and source commit with
the result. Unit tests, hosted CI and a responding health endpoint do not replace
that application check.
+
+### Lifecycle supervisor packaging
+
+New release archives include `hack-lifecycle-supervisor` beside the CLI. The
+standard and slim installers install it with executable mode 0755; Homebrew keeps
+it beside `libexec/hack`. Source installs prepare it with
+`bun run build:lifecycle-supervisor` (Rust 1.97.1, locked dependencies).
+The slim runtime image includes the same sibling executable.
+
+Stable archives build it for macOS arm64/x86_64 and Linux arm64/x86_64. Existing
+prerelease bundles remain macOS arm64 only: their supervisor is ad-hoc signed and
+covered by the bundle checksums. Retained managers accept older bundles without
+this helper. Packaging does not change the lifecycle runner default.
+
+Self-upgrades stage the helper before publishing the CLI, retain its previous
+bytes and SHA-256 in the backup, and restore it if publication fails. Keep the
+helper with its matching CLI when moving an installation.
diff --git a/docs/versions/v5/install.md b/docs/versions/v5/install.md
index 256a86212..010375ff7 100644
--- a/docs/versions/v5/install.md
+++ b/docs/versions/v5/install.md
@@ -100,3 +100,20 @@ Stop its projects with `hack down` first. For a login daemon, use that install's
Removing an executable does not undo host DNS/trust, project files or volumes.
For a candidate rollback or return to stable, use
[the full procedure](../guides/candidate-install.md#upgrade-roll-back-and-return-to-stable).
+
+### Lifecycle supervisor packaging
+
+New release archives include `hack-lifecycle-supervisor` beside the CLI. The
+standard and slim installers install it with executable mode 0755; Homebrew keeps
+it beside `libexec/hack`. Source installs prepare it with
+`bun run build:lifecycle-supervisor` (Rust 1.97.1, locked dependencies).
+The slim runtime image includes the same sibling executable.
+
+Stable archives build it for macOS arm64/x86_64 and Linux arm64/x86_64. Existing
+prerelease bundles remain macOS arm64 only: their supervisor is ad-hoc signed and
+covered by the bundle checksums. Retained managers accept older bundles without
+this helper. Packaging does not change the lifecycle runner default.
+
+Self-upgrades stage the helper before publishing the CLI, retain its previous
+bytes and SHA-256 in the backup, and restore it if publication fails. Keep the
+helper with its matching CLI when moving an installation.
diff --git a/package.json b/package.json
index f8c87c7c8..4fe4eb9de 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"dev": "bun run --cwd packages/cli dev",
"build": "bun run --cwd packages/cli build",
"build:local": "sh scripts/build-hack-local.sh",
+ "build:lifecycle-supervisor": "bun scripts/build-lifecycle-supervisor.ts",
"build:config-compiler": "bun scripts/build-config-compiler.ts",
"check:config-compiler": "cargo fmt --manifest-path packages/config-compiler/Cargo.toml --check && cargo clippy --locked --manifest-path packages/config-compiler/Cargo.toml --target-dir .hack-local/config-compiler-target --all-targets --jobs 2 -- -D warnings",
"test:config-compiler": "cargo test --locked --manifest-path packages/config-compiler/Cargo.toml --target-dir .hack-local/config-compiler-target --jobs 2",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 860aa69a1..9ec177392 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -6,12 +6,12 @@
"module": "index.ts",
"scripts": {
"dev": "bun run ../../index.ts",
- "build": "cd ../.. && bun build index.ts --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig --no-compile-autoload-tsconfig --no-compile-autoload-package-json --outfile dist/hack",
+ "build": "cd ../.. && bun scripts/build-lifecycle-supervisor.ts && bun build index.ts --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig --no-compile-autoload-tsconfig --no-compile-autoload-package-json --outfile dist/hack",
"build:release": "cd ../.. && bun run scripts/build-release.ts",
"build:runtime-image": "cd ../.. && bun run scripts/build-node-runtime-image.ts",
"build:node-runtime-image": "cd ../.. && bun run scripts/build-node-runtime-image.ts --variant node-runtime",
"build:slim-runtime-image": "cd ../.. && bun run scripts/build-node-runtime-image.ts --variant slim",
- "install:dev": "cd ../.. && bun run scripts/install-cli.ts --mode=dev --force",
+ "install:dev": "cd ../.. && bun scripts/build-lifecycle-supervisor.ts && bun run scripts/install-cli.ts --mode=dev --force",
"install:bin": "cd ../.. && bun run scripts/install-cli.ts --mode=bin --force",
"install:status": "cd ../.. && bun run scripts/install-status.ts",
"uninstall:local": "cd ../.. && bun run scripts/uninstall-cli.ts",
diff --git a/scripts/build-lifecycle-supervisor.ts b/scripts/build-lifecycle-supervisor.ts
new file mode 100644
index 000000000..caef59064
--- /dev/null
+++ b/scripts/build-lifecycle-supervisor.ts
@@ -0,0 +1,84 @@
+#!/usr/bin/env bun
+import { chmod, copyFile, mkdir, mkdtemp, rename, rm } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { verifyConfigCompilerBuildPath } from "./build-config-compiler.ts";
+
+/** Build the host sibling with locked dependencies and bounded Cargo parallelism. */
+export async function buildLifecycleSupervisor(): Promise {
+ const root = resolve(import.meta.dir, "..");
+ const target = join(root, ".hack-local/lifecycle-supervisor-target");
+ const output = join(root, "dist/hack-lifecycle-supervisor");
+ for (const path of [
+ join(root, ".hack-local"),
+ target,
+ join(root, "dist"),
+ output,
+ ]) {
+ await verifyConfigCompilerBuildPath({ path });
+ }
+ const child = Bun.spawn(
+ [
+ "cargo",
+ "+1.97.1",
+ "build",
+ "--locked",
+ "--release",
+ "--jobs",
+ "2",
+ "--manifest-path",
+ "packages/lifecycle-supervisor/Cargo.toml",
+ "--target-dir",
+ target,
+ ],
+ {
+ cwd: root,
+ stdin: "ignore",
+ stdout: "inherit",
+ stderr: "inherit",
+ timeout: 300_000,
+ }
+ );
+ if ((await child.exited) !== 0) {
+ throw new Error("Lifecycle supervisor build failed.");
+ }
+ const binary = join(target, "release/hack-lifecycle-supervisor");
+ await verifyConfigCompilerBuildPath({
+ path: binary,
+ allowCargoHardLink: true,
+ });
+ await mkdir(join(root, "dist"), { recursive: true });
+ const stage = await mkdtemp(join(root, "dist/.lifecycle-supervisor-build-"));
+ try {
+ const staged = join(stage, "hack-lifecycle-supervisor");
+ await copyFile(binary, staged);
+ await chmod(staged, 0o755);
+ if (process.platform === "darwin") {
+ for (const args of [
+ ["--force", "--sign", "-"],
+ ["--verify", "--strict"],
+ ]) {
+ const signature = Bun.spawn(["/usr/bin/codesign", ...args, staged], {
+ stdin: "ignore",
+ stdout: "inherit",
+ stderr: "inherit",
+ timeout: 30_000,
+ });
+ if ((await signature.exited) !== 0) {
+ throw new Error(
+ "Lifecycle supervisor signature verification failed."
+ );
+ }
+ }
+ }
+ // A locally installed helper may still be supervising work. Replace its
+ // directory entry instead of writing to that running executable's inode.
+ await verifyConfigCompilerBuildPath({ path: output });
+ await rename(staged, output);
+ } finally {
+ await rm(stage, { recursive: true, force: true });
+ }
+}
+
+if (import.meta.main) {
+ await buildLifecycleSupervisor();
+}
diff --git a/scripts/build-native-candidate.sh b/scripts/build-native-candidate.sh
index 7a1cd776c..50d5da6e9 100755
--- a/scripts/build-native-candidate.sh
+++ b/scripts/build-native-candidate.sh
@@ -118,6 +118,11 @@ if [ -n "$version" ]; then
else
bun build index.ts --compile --outfile "$out/hack-cli"
fi
+bun scripts/build-lifecycle-supervisor.ts
+cp dist/hack-lifecycle-supervisor "$out/hack-lifecycle-supervisor"
+chmod 755 "$out/hack-lifecycle-supervisor"
+/usr/bin/codesign --force --sign - "$out/hack-lifecycle-supervisor"
+/usr/bin/codesign --verify --strict "$out/hack-lifecycle-supervisor"
bun scripts/build-config-compiler.ts
cp dist/hack-config-compiler "$out/hack-config-compiler"
cp packages/config-compiler/generated/hack.project.schema.json "$out/hack.project.schema.json"
@@ -158,7 +163,7 @@ fi
else
shasum -a 256 hack-native hack-relay-guest hack-storage-root-witness native-storage-tool.json hack-cli hack-v5 provider-pins.json README.md > SHA256SUMS
fi
- shasum -a 256 hack-config-compiler hack.project.schema.json hack.local.schema.json >> SHA256SUMS
+ shasum -a 256 hack-lifecycle-supervisor hack-config-compiler hack.project.schema.json hack.local.schema.json >> SHA256SUMS
shasum -a 256 mcp/*/manifest.json mcp/*/hack-mcp-adapter mcp/*/hack-mcp-owner mcp/*/hack-mcp-backend >> SHA256SUMS
)
echo "Candidate bundle: $out"
diff --git a/scripts/build-release.ts b/scripts/build-release.ts
index e5720b206..ff039b159 100644
--- a/scripts/build-release.ts
+++ b/scripts/build-release.ts
@@ -10,6 +10,7 @@ import {
renderProjectManagedEnvSchemaJson,
} from "../src/templates.ts";
import { buildConfigCompiler } from "./build-config-compiler.ts";
+import { buildLifecycleSupervisor } from "./build-lifecycle-supervisor.ts";
interface BuildArgs {
readonly outDirRaw: string | null;
@@ -80,6 +81,7 @@ async function main({ args }: { readonly args: BuildArgs }): Promise {
}
await buildConfigCompiler();
+ await buildLifecycleSupervisor();
const binaryPath = resolve(distRoot, "hack");
const buildExit = await run({
cmd: [
@@ -112,6 +114,14 @@ async function main({ args }: { readonly args: BuildArgs }): Promise {
});
await chmodExecutable({ path: compilerPath });
+ await copyFile({
+ from: resolve(distRoot, "hack-lifecycle-supervisor"),
+ to: resolve(releaseDir, "hack-lifecycle-supervisor"),
+ });
+ await chmodExecutable({
+ path: resolve(releaseDir, "hack-lifecycle-supervisor"),
+ });
+
const assetsDir = resolve(releaseDir, "assets");
const gifsDir = resolve(assetsDir, "gifs");
const schemasDir = resolve(assetsDir, "schemas");
@@ -390,6 +400,7 @@ export function renderInstallScript(): string {
...installExecutableFunction(),
'mkdir -p "$INSTALL_BIN" "$INSTALL_ASSETS"',
'install_executable "$ROOT/hack-config-compiler" "$INSTALL_BIN/hack-config-compiler"',
+ 'install_executable "$ROOT/hack-lifecycle-supervisor" "$INSTALL_BIN/hack-lifecycle-supervisor"',
'install_executable "$ROOT/hack" "$INSTALL_BIN/hack"',
"",
'if [ -d "$ASSETS_DIR" ]; then',
@@ -516,6 +527,7 @@ export function renderCodexSlimInstallScript(): string {
...installExecutableFunction(),
'mkdir -p "$INSTALL_BIN" "$INSTALL_ASSETS"',
'install_executable "$ROOT/hack-config-compiler" "$INSTALL_BIN/hack-config-compiler"',
+ 'install_executable "$ROOT/hack-lifecycle-supervisor" "$INSTALL_BIN/hack-lifecycle-supervisor"',
'install_executable "$ROOT/hack" "$REAL_BIN"',
"",
'if [ -d "$ASSETS_DIR" ]; then',
diff --git a/scripts/check-release-lifecycle-supervisor.ts b/scripts/check-release-lifecycle-supervisor.ts
new file mode 100644
index 000000000..b02979a92
--- /dev/null
+++ b/scripts/check-release-lifecycle-supervisor.ts
@@ -0,0 +1,171 @@
+#!/usr/bin/env bun
+import { createHash } from "node:crypto";
+import {
+ chmod,
+ copyFile,
+ lstat,
+ mkdir,
+ mkdtemp,
+ readdir,
+ rm,
+} from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { isRecord } from "../src/lib/guards.ts";
+
+const name = "hack-lifecycle-supervisor";
+const release = resolve(Bun.argv[2] ?? "dist/release");
+const suffix = `-${process.platform}-${process.arch === "x64" ? "x86_64" : process.arch}.tar.gz`;
+const archives = (await readdir(release)).filter(
+ (file) => file.startsWith("hack-") && file.endsWith(suffix)
+);
+assert(archives.length === 1, "exactly one host archive");
+const archive = archives[0];
+assert(archive, "host archive");
+const version = archive.slice(5, -suffix.length);
+const root = await mkdtemp(join(tmpdir(), "hack-release-supervisor-"));
+try {
+ await run([
+ "tar",
+ "--no-same-owner",
+ "-xzf",
+ join(release, archive),
+ "-C",
+ root,
+ ]);
+ const bundle = join(root, `hack-${version}-release`);
+ const bytes = await Bun.file(join(bundle, name)).bytes();
+ const hash = createHash("sha256").update(bytes).digest("hex");
+ assert(
+ (await Bun.file(join(bundle, "SHA256SUMS")).text())
+ .split("\n")
+ .includes(`${hash} ${name}`),
+ "archive checksum"
+ );
+ for (const kind of ["standard", "slim", "local-bin"]) {
+ const fixture = join(root, kind);
+ const bin = join(fixture, "bin");
+ const home = join(fixture, "home");
+ await mkdir(home, { recursive: true });
+ const env = {
+ HOME: home,
+ BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0",
+ HACK_DAEMON_AUTOSTART_DISABLE: "1",
+ HACK_HOME: join(home, ".hack"),
+ HACK_INSTALL_BIN: bin,
+ HACK_INSTALL_ASSETS: join(fixture, "assets"),
+ PATH: `${bin}:/usr/bin:/bin`,
+ };
+ if (kind === "local-bin") {
+ await run(
+ [
+ process.execPath,
+ "--no-env-file",
+ join(import.meta.dir, "install-cli.ts"),
+ "--mode=bin",
+ `--dir=${bin}`,
+ ],
+ env
+ );
+ } else {
+ await run(
+ [
+ "bash",
+ join(
+ bundle,
+ kind === "slim" ? "install-codex-slim.sh" : "install.sh"
+ ),
+ ],
+ env,
+ "n\n"
+ );
+ }
+ const installed = join(bin, name);
+ const info = await lstat(installed);
+ assert(
+ kind === "local-bin"
+ ? info.isSymbolicLink()
+ : info.isFile() &&
+ !info.isSymbolicLink() &&
+ info.nlink === 1 &&
+ (info.mode & 0o777) === 0o755,
+ "independent executable install"
+ );
+ assert(
+ Buffer.from(await Bun.file(installed).bytes()).equals(Buffer.from(bytes)),
+ "installed bytes"
+ );
+ const relocated = join(fixture, name);
+ await copyFile(installed, relocated);
+ await chmod(relocated, 0o755);
+ for (const binary of [installed, relocated]) {
+ if (process.platform === "darwin") {
+ await run(["/usr/bin/codesign", "--verify", "--strict", binary], env);
+ }
+ // Empty framed input must refuse without launching work or consulting any home.
+ const child = Bun.spawn([binary, "status"], {
+ env,
+ cwd: home,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ timeout: 15_000,
+ });
+ const [code, output, diagnostic] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).arrayBuffer(),
+ new Response(child.stderr).text(),
+ ]);
+ const frame = Buffer.from(output);
+ assert(
+ code === 1 &&
+ frame.length >= 4 &&
+ frame.readUInt32BE(0) === frame.length - 4,
+ "installed refusal frame"
+ );
+ const receipt: unknown = JSON.parse(frame.subarray(4).toString());
+ assert(
+ isRecord(receipt) &&
+ receipt.version === 1 &&
+ receipt.outcome === "refused",
+ "installed protocol receipt"
+ );
+ assert(
+ diagnostic ===
+ "Lifecycle supervisor refused or failed; values omitted.\n",
+ "value-free refusal"
+ );
+ }
+ assert((await readdir(home)).length === 0, "no home mutation");
+ }
+ process.stdout.write(
+ `Release supervisor acceptance (${process.platform}/${process.arch}): archive checksum, normal/slim/local-bin installs, exact bytes, modes, relocation and framed refusal passed\n`
+ );
+} finally {
+ await rm(root, { recursive: true, force: true });
+}
+
+function assert(value: unknown, message: string): asserts value {
+ if (!value) {
+ throw new Error(`Release supervisor acceptance failed: ${message}`);
+ }
+}
+async function run(
+ command: string[],
+ env?: Record,
+ input?: string
+) {
+ const child = Bun.spawn(command, {
+ env: env ?? { PATH: "/usr/bin:/bin" },
+ stdin: input ? new TextEncoder().encode(input) : "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ timeout: 30_000,
+ });
+ const [code, , stderr] = await Promise.all([
+ child.exited,
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ ]);
+ assert(code === 0, `installer/extraction: ${stderr}`);
+}
diff --git a/scripts/install-cli.ts b/scripts/install-cli.ts
index 35308a9e6..c694ff808 100644
--- a/scripts/install-cli.ts
+++ b/scripts/install-cli.ts
@@ -62,30 +62,44 @@ async function main(parsed: ParseOk): Promise {
return 1;
}
- const current = await readCurrentInstall(targetPath);
- if (current.status === "present" && isSameInstall({ current, desired })) {
- printSuccess({
- mode: parsed.mode,
- installDir,
- targetPath,
- repoRoot,
- note: "Already installed",
+ const desiredFiles: DesiredInstall[] = [desired];
+ if (desired.kind === "symlink") {
+ const helper = "hack-lifecycle-supervisor";
+ const linkTarget = resolve(repoRoot, "dist", helper);
+ // Keep both symlinks bound to the same checkout's build output.
+ desiredFiles.unshift({
+ ...desired,
+ targetPath: resolve(installDir, helper),
+ linkTarget,
});
- return 0;
}
-
- if (current.status === "present" && !parsed.force) {
- process.stderr.write(`Refusing to overwrite existing '${CLI_NAME}' at:\n`);
- process.stderr.write(` ${targetPath}\n`);
- process.stderr.write("Re-run with --force to replace it.\n");
- return 1;
+ const installs = await Promise.all(
+ desiredFiles.map(async (file) => ({
+ desired: file,
+ current: await readCurrentInstall(file.targetPath),
+ }))
+ );
+ for (const install of installs) {
+ if (
+ install.current.status === "present" &&
+ !isSameInstall(install) &&
+ !parsed.force
+ ) {
+ process.stderr.write(
+ `Refusing to overwrite existing install at ${install.desired.targetPath}. Re-run with --force to replace it.\n`
+ );
+ return 1;
+ }
}
-
- if (current.status === "present") {
- await rm(targetPath, { force: true });
+ for (const install of installs) {
+ if (isSameInstall(install)) {
+ continue;
+ }
+ if (install.current.status === "present") {
+ await rm(install.desired.targetPath, { force: true });
+ }
+ await applyInstall({ desired: install.desired });
}
-
- await applyInstall({ desired });
printSuccess({ mode: parsed.mode, installDir, targetPath, repoRoot });
return 0;
}
@@ -194,6 +208,15 @@ async function desiredInstall(opts: {
};
}
+ if (
+ !(await fileExists(
+ resolve(opts.repoRoot, "dist/hack-lifecycle-supervisor")
+ ))
+ ) {
+ throw new Error(
+ "Missing lifecycle supervisor. Run: bun run build:lifecycle-supervisor"
+ );
+ }
const linkTarget = resolve(opts.repoRoot, "dist/hack");
const exists = await fileExists(linkTarget);
if (!exists) {
diff --git a/scripts/install-codex-slim.sh b/scripts/install-codex-slim.sh
index bebbe3018..f4683512f 100755
--- a/scripts/install-codex-slim.sh
+++ b/scripts/install-codex-slim.sh
@@ -22,6 +22,7 @@ fi
repo_root="$(cd "${repo_root}" && pwd)"
bun_bin="$(command -v bun)"
+(cd "$repo_root" && bun scripts/build-lifecycle-supervisor.ts)
wrapper_path="${bin_dir}/hack"
mkdir -p "${bin_dir}" "${assets_dir}" "${HOME}/.hack"
diff --git a/scripts/install-prerelease.py b/scripts/install-prerelease.py
index 010fd8a1a..4928e415d 100644
--- a/scripts/install-prerelease.py
+++ b/scripts/install-prerelease.py
@@ -33,11 +33,12 @@
# manifest. The CLI resolves both as siblings of hack-native; older bundles omit both.
STORAGE_TOOL = "hack-storage-root-witness"
STORAGE_TOOL_MANIFEST = "native-storage-tool.json"
+SUPERVISOR_PAYLOAD = frozenset(("hack-lifecycle-supervisor",))
STORAGE_TOOL_PAYLOAD = frozenset((STORAGE_TOOL, STORAGE_TOOL_MANIFEST))
STORAGE_TOOL_KEYS = {"version", "kind", "platform", "architecture", "file", "sha256", "bytes",
"sourceRevision"}
EXECUTABLES = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5",
- "hack-config-compiler"))
+ "hack-config-compiler", "hack-lifecycle-supervisor"))
BUNDLE_FILES = PAYLOAD | {"SHA256SUMS"}
MCP_FILES = {"adapter": "hack-mcp-adapter", "owner": "hack-mcp-owner",
"backend": "hack-mcp-backend"}
@@ -210,7 +211,7 @@ def payload_inventory(names):
"Incomplete config compiler payload.")
storage = names & STORAGE_TOOL_PAYLOAD
require(not storage or storage == STORAGE_TOOL_PAYLOAD, "Incomplete storage tool payload.")
- extra = names - BUNDLE_FILES - COMPILER_PAYLOAD - STORAGE_TOOL_PAYLOAD
+ extra = names - BUNDLE_FILES - COMPILER_PAYLOAD - STORAGE_TOOL_PAYLOAD - SUPERVISOR_PAYLOAD
if extra:
matches = [MCP_MEMBER.fullmatch(name) for name in extra]
require(all(matches), "Foreign candidate MCP payload.")
@@ -335,6 +336,8 @@ def verify_signatures(bundle):
payload = bundle_inventory(bundle)
if "hack-config-compiler" in payload:
names.append("hack-config-compiler")
+ if "hack-lifecycle-supervisor" in payload:
+ names.append("hack-lifecycle-supervisor")
names.extend(sorted(name for name in payload
if MCP_MEMBER.fullmatch(name) and not name.endswith("/manifest.json")))
# codesign verifies every supplied path and fails if any is invalid. Keep all
@@ -363,7 +366,7 @@ def extract_archive(archive, checksum, destination, version, release_metadata=No
total = 0
for entry in source:
require(len(entries) < MAX_ARCHIVE_MEMBERS, "Too many archive entries.")
- require((entry.name in BUNDLE_FILES | COMPILER_PAYLOAD | STORAGE_TOOL_PAYLOAD
+ require((entry.name in BUNDLE_FILES | COMPILER_PAYLOAD | STORAGE_TOOL_PAYLOAD | SUPERVISOR_PAYLOAD
or MCP_MEMBER.fullmatch(entry.name)) and entry.name not in seen,
"Foreign, duplicate or traversing archive entry.")
require(entry.isfile() and not entry.issparse() and entry.size > 0
diff --git a/scripts/installer/bundle.ts b/scripts/installer/bundle.ts
index 1b68fc428..9c189960e 100644
--- a/scripts/installer/bundle.ts
+++ b/scripts/installer/bundle.ts
@@ -162,6 +162,7 @@ export function verifySignatures(bundle: string) {
...payload.filter(
(name) =>
name === "hack-config-compiler" ||
+ name === "hack-lifecycle-supervisor" ||
(MCP.test(name) && !name.endsWith("/manifest.json"))
),
];
diff --git a/scripts/installer/integrity.ts b/scripts/installer/integrity.ts
index d0bc27e29..b10b5f22e 100644
--- a/scripts/installer/integrity.ts
+++ b/scripts/installer/integrity.ts
@@ -39,10 +39,12 @@ const COMPILER = [
"hack.project.schema.json",
"hack.local.schema.json",
];
+const SUPERVISOR = ["hack-lifecycle-supervisor"];
const STORAGE = ["hack-storage-root-witness", "native-storage-tool.json"];
export const MCP =
/^mcp\/([0-9a-f]{64})\/(manifest\.json|hack-mcp-adapter|hack-mcp-owner|hack-mcp-backend)$(?![\s\S])/;
const EXECUTABLES = new Set([
+ ...SUPERVISOR,
"hack-native",
"hack-relay-guest",
"hack-cli",
@@ -384,7 +386,8 @@ export function inventory(names: string[]): string[] {
"Incomplete storage tool payload."
);
const extras = names.filter(
- (name) => ![...required, ...COMPILER, ...STORAGE].includes(name)
+ (name) =>
+ ![...required, ...COMPILER, ...STORAGE, ...SUPERVISOR].includes(name)
);
if (extras.length) {
const identity = MCP.exec(extras[0] ?? "")?.[1];
@@ -482,7 +485,13 @@ export function archiveEntries(compressed: Uint8Array) {
const name = `${prefix ? `${prefix}/` : ""}${field(header, 0, 100)}`;
requireThat(
!entries.has(name) &&
- ([...CORE, ...COMPILER, ...STORAGE, "SHA256SUMS"].includes(name) ||
+ ([
+ ...CORE,
+ ...COMPILER,
+ ...STORAGE,
+ ...SUPERVISOR,
+ "SHA256SUMS",
+ ].includes(name) ||
MCP.test(name)),
"Foreign, duplicate or traversing archive entry."
);
diff --git a/scripts/prerelease-plan.ts b/scripts/prerelease-plan.ts
index 55e272a3e..a33d7e3b9 100644
--- a/scripts/prerelease-plan.ts
+++ b/scripts/prerelease-plan.ts
@@ -48,6 +48,7 @@ export const PAYLOAD_MODES: Readonly> = {
[NATIVE_STORAGE_TOOL_FILE]: 0o500,
[NATIVE_STORAGE_TOOL_MANIFEST]: 0o600,
"hack-config-compiler": 0o755,
+ "hack-lifecycle-supervisor": 0o755,
"hack.project.schema.json": 0o600,
"hack.local.schema.json": 0o600,
};
@@ -775,12 +776,17 @@ export async function packagePrerelease({
throw new Error("Native bundle root must be a directory, not an alias");
}
const entries = await readdir(bundle);
- const compilerPayload = configCompilerPayload(entries);
+ const helperPayload = [
+ ...(entries.includes("hack-lifecycle-supervisor")
+ ? ["hack-lifecycle-supervisor"]
+ : []),
+ ...configCompilerPayload(entries),
+ ];
const mcpPayload = await nativeCandidateMcpPayload(bundle);
- const payload = [...PRERELEASE_PAYLOAD, ...compilerPayload, ...mcpPayload];
+ const payload = [...PRERELEASE_PAYLOAD, ...helperPayload, ...mcpPayload];
const expected = [
...PRERELEASE_PAYLOAD,
- ...compilerPayload,
+ ...helperPayload,
"SHA256SUMS",
...(mcpPayload.length ? ["mcp"] : []),
].sort();
diff --git a/scripts/update-homebrew-tap.ts b/scripts/update-homebrew-tap.ts
index cfd208b05..b133f6a9c 100644
--- a/scripts/update-homebrew-tap.ts
+++ b/scripts/update-homebrew-tap.ts
@@ -302,6 +302,7 @@ export function renderFormula({
" def install",
' libexec.install "hack"',
` libexec.install "${FORMULA_CONFIG_COMPILER}"`,
+ ' libexec.install "hack-lifecycle-supervisor"',
' (libexec/"assets").install Dir["assets/*"] if (buildpath/"assets").directory?',
' (libexec/"assets/binaries").install Dir["binaries/*"] if (buildpath/"binaries").directory?',
' (bin/"hack").write_env_script libexec/"hack", HACK_ASSETS_DIR: libexec/"assets"',
@@ -309,6 +310,7 @@ export function renderFormula({
"",
" test do",
' assert_match "hack", shell_output("#{bin}/hack --help")',
+ ' assert_predicate libexec/"hack-lifecycle-supervisor", :executable?',
` assert_match "transport_version", shell_output("#{libexec}/${FORMULA_CONFIG_COMPILER} --protocol")`,
' (testpath/".hack").mkpath',
' (testpath/".hack/hack.project.json").write <<~JSON',
diff --git a/src/lib/release-install.ts b/src/lib/release-install.ts
index 0433a03cc..2ef21e4a4 100644
--- a/src/lib/release-install.ts
+++ b/src/lib/release-install.ts
@@ -93,9 +93,20 @@ export async function publishRelease(opts: {
"hack-config-compiler"
);
const hasCompiler = Boolean(await present(compilerSource));
+ const supervisorPath = join(
+ dirname(opts.binaryPath),
+ "hack-lifecycle-supervisor"
+ );
+ const oldSupervisor = await present(supervisorPath);
+ const supervisorSource = join(
+ opts.extractedReleaseDir,
+ "hack-lifecycle-supervisor"
+ );
+ const hasSupervisor = Boolean(await present(supervisorSource));
if (
(oldBinary && !oldBinary.isFile()) ||
(oldCompiler && !oldCompiler.isFile()) ||
+ (oldSupervisor && !oldSupervisor.isFile()) ||
(oldAssets && !oldAssets.isDirectory())
) {
throw new Error(
@@ -121,10 +132,12 @@ export async function publishRelease(opts: {
let assetsPublished = false;
let binaryPublished = false;
let compilerPublished = false;
+ let supervisorPublished = false;
let complete = false;
let assetsIdentity: Stats | null = null;
let binaryIdentity: Stats | null = null;
let compilerIdentity: Stats | null = null;
+ let supervisorIdentity: Stats | null = null;
try {
await markReleaseStage(binaryStage, opts.binaryPath);
await mkdir(assetsStage, { mode: 0o700 });
@@ -154,6 +167,19 @@ export async function publishRelease(opts: {
);
await chmod(join(binaryStage, "hack-config-compiler"), 0o755);
}
+ if (oldSupervisor) {
+ await copyFile(
+ supervisorPath,
+ join(backup, "hack-lifecycle-supervisor")
+ );
+ }
+ if (hasSupervisor) {
+ await copyFile(
+ supervisorSource,
+ join(binaryStage, "hack-lifecycle-supervisor")
+ );
+ await chmod(join(binaryStage, "hack-lifecycle-supervisor"), 0o755);
+ }
const next = join(binaryStage, "hack");
await copyFile(join(opts.extractedReleaseDir, "hack"), next);
await chmod(next, 0o755);
@@ -182,6 +208,18 @@ export async function publishRelease(opts: {
previousCompilerSha256: oldCompiler
? hash(await readFile(join(backup, "hack-config-compiler")))
: null,
+ supervisor: hasSupervisor ? supervisorPath : null,
+ installedSupervisorSha256: hasSupervisor
+ ? hash(
+ await readFile(join(binaryStage, "hack-lifecycle-supervisor"))
+ )
+ : null,
+ previousSupervisor: oldSupervisor
+ ? join(backup, "hack-lifecycle-supervisor")
+ : null,
+ previousSupervisorSha256: oldSupervisor
+ ? hash(await readFile(join(backup, "hack-lifecycle-supervisor")))
+ : null,
};
await writeFile(
join(backup, "manifest.json"),
@@ -199,6 +237,11 @@ export async function publishRelease(opts: {
join(binaryStage, "hack-config-compiler")
);
}
+ if (hasSupervisor) {
+ supervisorIdentity = await lstat(
+ join(binaryStage, "hack-lifecycle-supervisor")
+ );
+ }
await assertIdentity(opts.assetsDir, oldAssets);
if (oldAssets) {
await rename(opts.assetsDir, oldAssetsPath);
@@ -212,7 +255,16 @@ export async function publishRelease(opts: {
await rename(join(binaryStage, "hack-config-compiler"), compilerPath);
compilerPublished = true;
}
+ if (hasSupervisor) {
+ await assertIdentity(supervisorPath, oldSupervisor);
+ await rename(
+ join(binaryStage, "hack-lifecycle-supervisor"),
+ supervisorPath
+ );
+ supervisorPublished = true;
+ }
opts.checkpoint?.("compiler-published");
+ opts.checkpoint?.("supervisor-published");
await assertIdentity(opts.binaryPath, oldBinary);
await rename(next, opts.binaryPath);
binaryPublished = true;
@@ -245,6 +297,20 @@ export async function publishRelease(opts: {
await rm(compilerPath);
}
}
+ if (supervisorPublished) {
+ await assertIdentity(supervisorPath, supervisorIdentity);
+ if (oldSupervisor) {
+ const rollback = join(binaryStage, "rollback-supervisor");
+ await copyFile(
+ join(backup, "hack-lifecycle-supervisor"),
+ rollback
+ );
+ await chmod(rollback, oldSupervisor.mode & 0o777);
+ await rename(rollback, supervisorPath);
+ } else {
+ await rm(supervisorPath);
+ }
+ }
if (assetsPublished) {
await assertIdentity(opts.assetsDir, assetsIdentity);
await rm(opts.assetsDir, { recursive: true });
diff --git a/src/lib/release-staging.ts b/src/lib/release-staging.ts
index 67fe87689..baa58da3c 100644
--- a/src/lib/release-staging.ts
+++ b/src/lib/release-staging.ts
@@ -14,6 +14,8 @@ const ENTRIES = new Set([
"stage.json",
"hack",
"hack-config-compiler",
+ "hack-lifecycle-supervisor",
+ "rollback-supervisor",
"rollback",
"rollback-compiler",
]);
diff --git a/tests/prerelease-plan.test.ts b/tests/prerelease-plan.test.ts
index 2245e89a0..adb8f4787 100644
--- a/tests/prerelease-plan.test.ts
+++ b/tests/prerelease-plan.test.ts
@@ -1040,6 +1040,51 @@ test("packages the compiler and both schemas beside the CLI", async () => {
}
});
+test("packages and extracts the supervisor extension with executable mode", async () => {
+ const root = await mkdtemp(join(tmpdir(), "hack-supervisor-package-"));
+ try {
+ const bundle = await fixture(root);
+ await compilerFixture(bundle);
+ await writeFile(
+ join(bundle, "hack-lifecycle-supervisor"),
+ "synthetic supervisor\n",
+ { mode: 0o755 }
+ );
+ await Bun.write(
+ join(bundle, "SHA256SUMS"),
+ await renderChecksums({
+ root: bundle,
+ names: [
+ ...PRERELEASE_PAYLOAD,
+ "hack-lifecycle-supervisor",
+ ...CONFIG_COMPILER_PAYLOAD,
+ ],
+ })
+ );
+ const output = join(root, "assets");
+ await packagePrerelease({
+ plan: createPrereleasePlan(input),
+ bundle,
+ output,
+ });
+ expect(
+ await Bun.file(
+ join(`${output}-installer-fixture`, "hack-lifecycle-supervisor")
+ ).text()
+ ).toBe("synthetic supervisor\n");
+ await chmod(join(bundle, "hack-lifecycle-supervisor"), 0o644);
+ await expect(
+ packagePrerelease({
+ plan: createPrereleasePlan(input),
+ bundle,
+ output: join(root, "bad"),
+ })
+ ).rejects.toThrow("unsafe permissions");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
test("preserves the older complete compiler/project-schema pair", async () => {
const root = await mkdtemp(join(tmpdir(), "hack-compiler-legacy-pair-"));
try {
@@ -1763,6 +1808,7 @@ test("packager payload is exactly the versioned bundle the builder checksums, in
// Packaging compares SHA256SUMS byte for byte, so order is part of the contract.
expect(versioned.filter((name) => !name.startsWith("mcp/"))).toEqual([
...PRERELEASE_PAYLOAD,
+ "hack-lifecycle-supervisor",
...CONFIG_COMPILER_PAYLOAD,
]);
// nativeCandidateMcpPayload owns this group and returns it in this order.
@@ -1821,7 +1867,7 @@ test("installer accepts exactly the versioned bundle contract with the packaged
" return False",
"print(json.dumps({",
" 'accepted': sorted(m.payload_inventory(names | {'SHA256SUMS'})),",
- " 'top_level': sorted(m.PAYLOAD | m.COMPILER_PAYLOAD | m.STORAGE_TOOL_PAYLOAD),",
+ " 'top_level': sorted(m.PAYLOAD | m.COMPILER_PAYLOAD | m.STORAGE_TOOL_PAYLOAD | m.SUPERVISOR_PAYLOAD),",
" 'member_ceiling': m.MAX_ARCHIVE_MEMBERS,",
" 'modes': {name: m.payload_mode(name) for name in sorted(names)},",
" 'foreign_refused': refused(names | {'SHA256SUMS', 'native-storage-tool.sig'}),",
diff --git a/tests/python/test_prerelease_install.py b/tests/python/test_prerelease_install.py
index 1282e7de6..810d2978d 100644
--- a/tests/python/test_prerelease_install.py
+++ b/tests/python/test_prerelease_install.py
@@ -479,6 +479,25 @@ def fail_compiler(arguments, **options):
self.channel.install("5.0.0-next.2", archive, checksum, True)
self.assertEqual(self.selection(), before)
+ def test_supervisor_extension_installs_with_checksums_modes_and_signature_inventory(self):
+ def payload(entries):
+ files = {name: value for name, value, _ in with_current_payload(entries)
+ if name != "SHA256SUMS"}
+ files["hack-lifecycle-supervisor"] = b"synthetic supervisor"
+ files["SHA256SUMS"] = "".join(sha(value) + " " + name + "\n"
+ for name, value in sorted(files.items())).encode()
+ return [(name, value, tarfile.REGTYPE) for name, value in files.items()]
+ archive, checksum = self.archive(mutate=payload)
+ with self.channel.lock():
+ self.channel.install("5.0.0-next.1", archive, checksum)
+ bundle = self.channel.root / "versions/5.0.0-next.1/bundle"
+ supervisor = bundle / "hack-lifecycle-supervisor"
+ self.assertEqual(supervisor.read_bytes(), b"synthetic supervisor")
+ self.assertEqual(supervisor.stat().st_mode & 0o777, 0o755)
+ self.calls.clear()
+ installer.verify_signatures(bundle)
+ self.assertIn(str(supervisor), self.calls[0][0])
+
def test_signature_batch_covers_all_executable_assets_in_one_strict_call(self):
archive, checksum = self.archive(mutate=with_current_payload)
with self.channel.lock():
diff --git a/tests/release-download-installer.test.ts b/tests/release-download-installer.test.ts
index 88033a1e7..6f2a06857 100644
--- a/tests/release-download-installer.test.ts
+++ b/tests/release-download-installer.test.ts
@@ -156,11 +156,12 @@ for (const [kind, render] of [
// Linux, after the compiler was already replaced, leaving a mixed install.
for (const [kind, render, running] of [
["standard", renderInstallScript, "hack"],
+ ["standard", renderInstallScript, "hack-lifecycle-supervisor"],
["slim", renderCodexSlimInstallScript, "hack-real"],
// A slim install over a standard one replaces the running `hack` with the wrapper.
["slim over standard", renderCodexSlimInstallScript, "hack"],
] as const) {
- test(`bundled ${kind} installer replaces an executable that is still running`, async () => {
+ test(`bundled ${kind} installer replaces running ${running}`, async () => {
const root = await mkdtemp(join(tmpdir(), "hack-bundled-installer-"));
// bash keeps working under another file name. A system `sleep` may be a
// multi-call binary that picks its applet from argv[0] and exits at once.
@@ -201,6 +202,10 @@ for (const [kind, render, running] of [
join(bundle, "hack-config-compiler"),
"#!/bin/sh\necho new compiler\n"
);
+ await Bun.write(
+ join(bundle, "hack-lifecycle-supervisor"),
+ "synthetic supervisor\n"
+ );
const installer = join(bundle, "install.sh");
await Bun.write(installer, render());
const child = Bun.spawn(["bash", installer], {
@@ -236,6 +241,12 @@ for (const [kind, render, running] of [
expect(await Bun.file(join(bin, "hack-config-compiler")).text()).toBe(
"#!/bin/sh\necho new compiler\n"
);
+ expect(
+ await Bun.file(join(bin, "hack-lifecycle-supervisor")).text()
+ ).toBe("synthetic supervisor\n");
+ expect(
+ (await stat(join(bin, "hack-lifecycle-supervisor"))).mode & 0o777
+ ).toBe(0o755);
// Replaced by rename, not rewritten in place.
expect((await stat(previous)).ino).not.toBe(before.ino);
for (const name of await readdir(bin)) {
@@ -244,8 +255,13 @@ for (const [kind, render, running] of [
// Nothing staged is left behind.
expect((await readdir(bin)).sort()).toEqual(
kind === "standard"
- ? ["hack", "hack-config-compiler"]
- : ["hack", "hack-config-compiler", "hack-real"]
+ ? ["hack", "hack-config-compiler", "hack-lifecycle-supervisor"]
+ : [
+ "hack",
+ "hack-config-compiler",
+ "hack-lifecycle-supervisor",
+ "hack-real",
+ ]
);
if (kind !== "standard") {
expect(await Bun.file(join(bin, "hack")).text()).toContain(
diff --git a/tests/release-host-smoke.test.ts b/tests/release-host-smoke.test.ts
index 9d22e56f5..1e7eb787c 100644
--- a/tests/release-host-smoke.test.ts
+++ b/tests/release-host-smoke.test.ts
@@ -87,6 +87,9 @@ test("CI and the release build use the same pins, build and installed-archive ac
expect(runs(release.steps)).toContain(
'bun run build:release "--version=$RELEASE_VERSION" --skip-tests'
);
+ expect(runs(ci.steps)).toContain(
+ "bun scripts/check-release-lifecycle-supervisor.ts dist/config-compiler-release\n"
+ );
// The release accepts the installed archive before it uploads anything.
const names = release.steps.map((step) => step.run ?? step.name ?? "");
const accepted = names.indexOf(
@@ -97,6 +100,11 @@ test("CI and the release build use the same pins, build and installed-archive ac
);
expect(accepted).toBeGreaterThan(-1);
expect(uploaded).toBeGreaterThan(accepted);
+ const supervisorAccepted = names.indexOf(
+ "bun scripts/check-release-lifecycle-supervisor.ts dist/release"
+ );
+ expect(supervisorAccepted).toBeGreaterThan(-1);
+ expect(uploaded).toBeGreaterThan(supervisorAccepted);
// The CI smoke cannot publish: its token is read-only.
expect(ci.permissions).toEqual({ contents: "read" });
});
diff --git a/tests/update-homebrew-tap.test.ts b/tests/update-homebrew-tap.test.ts
index f317a8687..7d5991e1a 100644
--- a/tests/update-homebrew-tap.test.ts
+++ b/tests/update-homebrew-tap.test.ts
@@ -50,6 +50,7 @@ describe("Homebrew formula", () => {
test("installs the config compiler beside the executable the wrapper runs", () => {
const install = block("def install");
expect(install).toContain('libexec.install "hack"');
+ expect(install).toContain('libexec.install "hack-lifecycle-supervisor"');
expect(install).toContain(`libexec.install "${FORMULA_CONFIG_COMPILER}"`);
// The env script execs libexec/hack, so the CLI's execPath is libexec/hack;
// the compiler must be exactly where the CLI resolves it, never on PATH.
diff --git a/tests/upgrade-installer.test.ts b/tests/upgrade-installer.test.ts
index 264437793..510c8aa63 100644
--- a/tests/upgrade-installer.test.ts
+++ b/tests/upgrade-installer.test.ts
@@ -7,6 +7,7 @@ import {
readFile,
realpath,
rm,
+ stat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -75,6 +76,7 @@ async function releaseFixture(
const files: Record = {
hack: `#!/bin/sh\ncase "$0" in */.hack-update-stage-*/hack) ;; *) exit 93 ;; esac\nprintf '%s\\n' 'hack v${fault === "version" ? "4.3.0" : version}'\n`,
"hack-config-compiler": "#!/bin/sh\nexit 0\n",
+ "hack-lifecycle-supervisor": "#!/bin/sh\nexit 0\n",
"assets/schema.json": "new schema\n",
"binaries/tool": "bundled executable\n",
};
@@ -328,6 +330,7 @@ for (const phase of [
"staged",
"assets-published",
"compiler-published",
+ "supervisor-published",
"binary-published",
]) {
test(`stable publication rolls back at ${phase}; a rerun installs matching binary/compiler/assets`, async () => {
@@ -341,6 +344,8 @@ for (const phase of [
const compiler = join(bin, "hack-config-compiler");
await writeFile(binary, "old CLI");
await writeFile(compiler, "old compiler");
+ const supervisor = join(bin, "hack-lifecycle-supervisor");
+ await writeFile(supervisor, "old supervisor", { mode: 0o700 });
await writeFile(join(assets, "schema.json"), "old schema");
await writeFile(join(assets, "mutagen"), "user managed extension");
const opts = {
@@ -361,6 +366,8 @@ for (const phase of [
).rejects.toThrow("fixture publication fault");
expect(await readFile(binary, "utf8")).toBe("old CLI");
expect(await readFile(compiler, "utf8")).toBe("old compiler");
+ expect(await readFile(supervisor, "utf8")).toBe("old supervisor");
+ expect((await stat(supervisor)).mode & 0o777).toBe(0o700);
expect(await readFile(join(assets, "schema.json"), "utf8")).toBe(
"old schema"
);
@@ -381,7 +388,15 @@ for (const phase of [
expect(await readFile(join(result.backupManifest, "../hack"), "utf8")).toBe(
"old CLI"
);
+ expect(await readFile(supervisor, "utf8")).toBe("#!/bin/sh\nexit 0\n");
+ expect((await stat(supervisor)).mode & 0o777).toBe(0o755);
const manifest = await readFile(result.backupManifest, "utf8");
+ expect(JSON.parse(manifest).previousSupervisorSha256).toBe(
+ sha("old supervisor")
+ );
+ expect(JSON.parse(manifest).installedSupervisorSha256).toBe(
+ sha("#!/bin/sh\nexit 0\n")
+ );
for (const value of ["old CLI", "old schema", "user managed extension"]) {
expect(manifest.includes(value)).toBe(false);
}
@@ -396,6 +411,26 @@ for (const phase of [
).toBe(false);
});
}
+test("failed first supervisor publication removes the new sibling", async () => {
+ const root = await scratch();
+ const f = await releaseFixture(root);
+ const binary = join(root, "bin/hack");
+ await expect(
+ publishRelease({
+ version: "4.3.1",
+ extractedReleaseDir: f.release,
+ binaryPath: binary,
+ assetsDir: join(root, "assets"),
+ checkpoint: (phase) => {
+ if (phase === "supervisor-published") {
+ throw new Error("fixture fault");
+ }
+ },
+ })
+ ).rejects.toThrow("fixture fault");
+ expect(await readdir(join(root, "bin"))).toEqual([]);
+});
+
test("stable uninstall can be repeated and preserves all user state", async () => {
const root = await scratch();
const home = join(root, "home");
From ff1e481d7a2ac4ec07aa46feecc74a3e74ebe755 Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 20:55:16 -0500
Subject: [PATCH 10/55] fix(lifecycle): place protocol regressions after
implementation
---
packages/lifecycle-supervisor/src/protocol.rs | 40 +++++++++----------
1 file changed, 20 insertions(+), 20 deletions(-)
diff --git a/packages/lifecycle-supervisor/src/protocol.rs b/packages/lifecycle-supervisor/src/protocol.rs
index 1781fa52a..f12c63fbd 100644
--- a/packages/lifecycle-supervisor/src/protocol.rs
+++ b/packages/lifecycle-supervisor/src/protocol.rs
@@ -86,26 +86,6 @@ impl Process {
}
}
-#[cfg(test)]
-mod tests {
- #[test]
- fn inherited_environment_uses_exec_constraints_not_shell_identifiers() {
- let mut process: super::Process = serde_json::from_value(serde_json::json!({
- "name": "worker", "argv": ["/usr/bin/true"], "cwd": "/",
- "env": {"BASH_FUNC_fixture%%": "() { :; }", "1.name": "synthetic"}
- }))
- .unwrap();
- assert!(process.validate().is_ok());
- for key in ["", "bad=name", "bad\0name"] {
- process.env.insert(key.into(), "synthetic".into());
- assert!(process.validate().is_err());
- process.env.remove(key);
- }
- process.env.insert("VALID".into(), "bad\0value".into());
- assert!(process.validate().is_err());
- }
-}
-
/// Length and deadline, never descriptor closure, delimit a handoff. Polling
/// handles Darwin's brief inheritable-descriptor window without waiting on EOF.
pub fn read(input: &mut (impl Read + AsRawFd)) -> Result {
@@ -248,3 +228,23 @@ fn send(fd: i32, bytes: &[u8], deadline: Instant) -> Result<()> {
}
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn inherited_environment_uses_exec_constraints_not_shell_identifiers() {
+ let mut process: super::Process = serde_json::from_value(serde_json::json!({
+ "name": "worker", "argv": ["/usr/bin/true"], "cwd": "/",
+ "env": {"BASH_FUNC_fixture%%": "() { :; }", "1.name": "synthetic"}
+ }))
+ .unwrap();
+ assert!(process.validate().is_ok());
+ for key in ["", "bad=name", "bad\0name"] {
+ process.env.insert(key.into(), "synthetic".into());
+ assert!(process.validate().is_err());
+ process.env.remove(key);
+ }
+ process.env.insert("VALID".into(), "bad\0value".into());
+ assert!(process.validate().is_err());
+ }
+}
From a5481c77d5a6f2e252005c5ebbdcc772cf04eb17 Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 22:30:50 -0500
Subject: [PATCH 11/55] test(lifecycle): budget native controller cases for
macOS
---
tests/lifecycle-supervisor-controller.test.ts | 32 +++++++++++++------
1 file changed, 22 insertions(+), 10 deletions(-)
diff --git a/tests/lifecycle-supervisor-controller.test.ts b/tests/lifecycle-supervisor-controller.test.ts
index e56d74fe9..c04834533 100644
--- a/tests/lifecycle-supervisor-controller.test.ts
+++ b/tests/lifecycle-supervisor-controller.test.ts
@@ -38,8 +38,11 @@ async function fixture(
run: (project: { projectRoot: string; projectDir: string }) => Promise
) {
const root = await mkdtemp(join(tmpdir(), "supervisor-controller-"));
- const project = { projectRoot: root, projectDir: join(root, ".hack") };
- await mkdir(project.projectDir);
+ const projectRoot = join(root, "checkout");
+ const project = { projectRoot, projectDir: join(projectRoot, ".hack") };
+ await mkdir(project.projectDir, { recursive: true });
+ const savedHackHome = process.env.HACK_HOME;
+ process.env.HACK_HOME = join(root, "host-home");
try {
await run(project);
} finally {
@@ -50,6 +53,7 @@ async function fixture(
branch: null,
definitionHash: "synthetic",
}).catch(() => undefined);
+ restoreEnv("HACK_HOME", savedHackHome);
await rm(root, { recursive: true, force: true });
}
}
@@ -110,7 +114,8 @@ nativeTest(
);
expect(await owner.abort()).toBeUndefined();
expect(await readLifecycleState(project)).toHaveLength(0);
- })
+ }),
+ 30_000
);
nativeTest(
@@ -132,7 +137,8 @@ nativeTest(
expect((await readLifecycleState(project))[0]?.ownershipToken).toBe(
original?.ownershipToken
);
- })
+ }),
+ 30_000
);
nativeTest(
@@ -164,7 +170,8 @@ nativeTest(
},
});
expect(await readLifecycleState(project)).toHaveLength(0);
- })
+ }),
+ 30_000
);
nativeTest(
@@ -188,7 +195,8 @@ nativeTest(
ownershipToken: entry.ownershipToken,
});
expect(status.liveProcessNames).toEqual(["worker"]);
- })
+ }),
+ 30_000
);
nativeTest(
@@ -225,7 +233,8 @@ nativeTest(
} finally {
restoreEnv("HACK_LIFECYCLE_SUPERVISOR_BINARY", binary);
}
- })
+ }),
+ 30_000
);
nativeTest(
@@ -252,7 +261,8 @@ nativeTest(
});
expect([...observed.liveProcessNames].sort()).toEqual([...names].sort());
expect(await owner.abort()).toBeUndefined();
- })
+ }),
+ 30_000
);
nativeTest(
@@ -309,7 +319,8 @@ if (action !== "serve") process.exit(await child.exited);
} finally {
restoreEnv("HACK_LIFECYCLE_SUPERVISOR_BINARY", saved);
}
- })
+ }),
+ 30_000
);
nativeTest(
@@ -354,5 +365,6 @@ nativeTest(
.map((p) => p.name)
.sort()
).toEqual(["first", "second"]);
- })
+ }),
+ 30_000
);
From b8d89d02dfe0d4cc1fb28b212e79b09259c5a8b9 Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 22:52:33 -0500
Subject: [PATCH 12/55] fix(lifecycle): preflight supervisor and retain
uncertain ownership
---
.github/workflows/ci.yml | 16 ++
.github/workflows/lifecycle-supervisor.yml | 28 ++-
docs/lifecycle-supervisor.md | 21 ++
docs/lifecycle.md | 17 +-
.../lifecycle-supervisor/src/incremental.rs | 2 +-
packages/lifecycle-supervisor/src/main.rs | 4 +
src/commands/doctor.ts | 57 +++++-
src/commands/project.ts | 11 +
src/lib/lifecycle-runtime.ts | 60 +++++-
src/lib/lifecycle-supervisor.ts | 115 +++++++++--
src/lib/project-lifecycle-supervisor.ts | 20 +-
tests/e2e/scenarios/lifecycle-host-process.ts | 17 +-
.../scenarios/lifecycle-session-recovery.ts | 35 +++-
tests/lifecycle-state-publication.test.ts | 36 +++-
tests/lifecycle-supervisor-controller.test.ts | 191 +++++++++++++++++-
tests/lifecycle-supervisor-observers.test.ts | 21 +-
.../tla/lifecycle-controller/COVERAGE.md | 18 ++
17 files changed, 614 insertions(+), 55 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9579d8a32..c37afdda3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -691,6 +691,22 @@ jobs:
HACK_E2E_COMPOSE_PLUGIN_SHA256="$(sha256sum "$HACK_E2E_COMPOSE_PLUGIN_PATH" | cut -d ' ' -f1)"
export HACK_E2E_DOCKER_HOST HACK_E2E_NETWORK_SOURCE_REVISION HACK_E2E_NETWORK_CLI_SHA256 HACK_E2E_NETWORK_COMPILER_SHA256 HACK_E2E_COMPOSE_PLUGIN_PATH HACK_E2E_COMPOSE_PLUGIN_SHA256
HACK_E2E_REQUIRE_DOCKER=1 HACK_E2E_REQUIRE_TMUX=1 bun run test:e2e:local:docker --shard="$HACK_E2E_SHARD"
+ - name: Setup supervisor toolchain
+ if: matrix.shard == 4
+ uses: dtolnay/rust-toolchain@1.97.1
+ - name: Qualify lifecycle scenarios with the supervisor runner
+ if: matrix.shard == 4
+ env:
+ HACK_DAEMON_AUTOSTART_DISABLE: '1'
+ HACK_LIFECYCLE_RUNNER: supervisor
+ HACK_LIFECYCLE_SUPERVISOR_BINARY: ${{ github.workspace }}/dist/hack-lifecycle-supervisor
+ HACK_E2E_REQUIRE_DOCKER: '1'
+ HACK_E2E_DOCKER: '1'
+ HACK_E2E_CLI_BIN: ${{ github.workspace }}/dist/hack
+ run: |
+ cargo +1.97.1 build --locked --release --manifest-path packages/lifecycle-supervisor/Cargo.toml --target-dir .hack-local/target/lifecycle-supervisor
+ cp .hack-local/target/lifecycle-supervisor/release/hack-lifecycle-supervisor dist/hack-lifecycle-supervisor
+ bun tests/e2e/run.ts --only=lifecycle-host-process,lifecycle-session-recovery
- name: Preserve process-policy capsule and startup diagnostic
# Closed fixed-field JSON only (counts, enums, booleans); the gh log viewer truncates it inline.
if: failure()
diff --git a/.github/workflows/lifecycle-supervisor.yml b/.github/workflows/lifecycle-supervisor.yml
index d11a61299..1f2c3c185 100644
--- a/.github/workflows/lifecycle-supervisor.yml
+++ b/.github/workflows/lifecycle-supervisor.yml
@@ -9,6 +9,19 @@ on:
- src/lib/project-lifecycle-controller.ts
- src/lib/lifecycle-runtime.ts
- tests/lifecycle-supervisor-controller.test.ts
+ - tests/lifecycle-supervisor-observers.test.ts
+ - tests/lifecycle-state-publication.test.ts
+ - tests/lifecycle-runner-config.test.ts
+ - tests/e2e/scenarios/lifecycle-*.ts
+ - src/lib/project-lifecycle-hygiene.ts
+ - src/lib/lifecycle-runner.ts
+ - src/lib/runtime-projects.ts
+ - src/lib/private-file.ts
+ - src/lib/projects-registry-lock.ts
+ - src/lib/child-environment.ts
+ - src/commands/host-ps.ts
+ - src/commands/project.ts
+ - src/commands/doctor.ts
- .github/workflows/lifecycle-supervisor.yml
push:
branches: [next, main]
@@ -19,6 +32,19 @@ on:
- src/lib/project-lifecycle-controller.ts
- src/lib/lifecycle-runtime.ts
- tests/lifecycle-supervisor-controller.test.ts
+ - tests/lifecycle-supervisor-observers.test.ts
+ - tests/lifecycle-state-publication.test.ts
+ - tests/lifecycle-runner-config.test.ts
+ - tests/e2e/scenarios/lifecycle-*.ts
+ - src/lib/project-lifecycle-hygiene.ts
+ - src/lib/lifecycle-runner.ts
+ - src/lib/runtime-projects.ts
+ - src/lib/private-file.ts
+ - src/lib/projects-registry-lock.ts
+ - src/lib/child-environment.ts
+ - src/commands/host-ps.ts
+ - src/commands/project.ts
+ - src/commands/doctor.ts
- .github/workflows/lifecycle-supervisor.yml
workflow_dispatch:
@@ -62,5 +88,5 @@ jobs:
- name: Test current-source CLI controller against native supervisor
env:
HACK_TEST_REQUIRE_SUPERVISOR: '1'
- HACK_TEST_SUPERVISOR_BINARY: ${{ env.CARGO_TARGET_DIR }}/debug/hack-lifecycle-supervisor
+ HACK_TEST_SUPERVISOR_BINARY: ${{ env.CARGO_TARGET_DIR }}/release/hack-lifecycle-supervisor
run: bun test tests/lifecycle-supervisor-controller.test.ts tests/lifecycle-runner-config.test.ts tests/lifecycle-state-publication.test.ts tests/lifecycle-supervisor-observers.test.ts
diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md
index 544507e94..5496dc6f1 100644
--- a/docs/lifecycle-supervisor.md
+++ b/docs/lifecycle-supervisor.md
@@ -239,3 +239,24 @@ leftover native roots are never treated as proof of absence.
Doctor holds the same instance controller lock as `up` throughout final
inspection, generation validation, native stop and conditional state removal.
Adoption cannot interleave between validation and the stop effect.
+
+Before creating ownership or running hooks, Hack checks the sibling executable's
+permissions and exact version/capability handshake. Compiled installations ignore
+`HACK_LIFECYCLE_SUPERVISOR_BINARY`; that override is for source development and
+tests only. `hack doctor` reports the resolved executable and its version.
+A missing or incompatible executable fails before publication. An entry durably
+marked as never having attempted launch, with no recorded PID or process, can be
+retired by `down` or doctor without that executable. An interrupted launch with
+an uncertain result retains ownership.
+
+The native root is keyed on the checkout's real path and its absolute location
+is recorded with ownership. Changing HOME or HACK_HOME does not relocate a
+running instance. Losing that recorded directory after a launch attempt is an
+unconfirmed outcome, never proof of stopped processes; down and doctor retain
+ownership. Restore access to the recorded host state before retrying retirement.
+
+Run `hack down` before downgrading or switching CLI builds. Older CLIs cannot
+preserve supervisor entries when rewriting shared project state and may start
+mux duplicates. After the project owner is killed, existing guards keep working
+and `down` can recover them; adding a new persistent hook may refuse until `down`
+retires the old owner. Stop and retry rather than removing its socket or state.
diff --git a/docs/lifecycle.md b/docs/lifecycle.md
index 0bc1200c4..9d3771ca1 100644
--- a/docs/lifecycle.md
+++ b/docs/lifecycle.md
@@ -643,15 +643,22 @@ commands themselves keep their existing ownership contract in this unit.
### Refused ownership state
-Hack refuses malformed or unsafe `.hack/.internal/lifecycle/state.json` rather
-than treating it as an empty owner list. This also applies to the default mux
-runner. Global project listings continue and mark the affected project's
+For projects that have never published supervisor ownership, an empty or
+unparsable legacy `.hack/.internal/lifecycle/state.json` is moved to a private
+`state.json.legacy--` sibling, with one explanation, and treated as
+empty. This recovers interrupted non-atomic writes from older mux versions.
+After a project has used the supervisor, the durable `supervisor-state` marker
+disables that fallback: losing ownership may hide running native commands.
+Unsafe files (including symlinks, hard links and foreign ownership) and
+semantically invalid records still refuse under both runners. State mutations
+now serialize on `state.lock` and publish atomically with mode 0600.
+Global project listings continue and mark the affected project's
lifecycle as `state_unreadable`; this is unknown, not stopped.
To recover, inspect that project's state file and the processes or sessions it
records. Confirm identity before stopping any leftover work; a PID alone may have
been reused. Preserve the file for diagnosis. Once the recorded work is confirmed
-stopped, move the file aside within the private lifecycle directory and retry
-`hack up`. Do not discard ownership records while commands may still run. A
+stopped, seek an ownership-state repair; do not hand-edit or discard ownership
+records while commands may still run. A
symlink, foreign owner, or unreadable directory needs its owner to repair the path
before Hack can safely proceed. `hack doctor --fix` does not bypass these checks.
diff --git a/packages/lifecycle-supervisor/src/incremental.rs b/packages/lifecycle-supervisor/src/incremental.rs
index 4b9040f8b..4d08ee724 100644
--- a/packages/lifecycle-supervisor/src/incremental.rs
+++ b/packages/lifecycle-supervisor/src/incremental.rs
@@ -218,7 +218,7 @@ fn connect() -> Result {
pub(crate) fn serve(request: Launch) -> Result<()> {
supervisor::validate_launch(&request)?;
let root = Root::open(&request.root)?;
- let _instance_lock = root.lock(".instance.lock")?;
+ let _instance_lock = root.lock_wait(".instance.lock")?;
let launch_lock = root.lock_wait(".stop.lock")?;
for state in root.states()? {
if os::recorded_live(&state, false)? {
diff --git a/packages/lifecycle-supervisor/src/main.rs b/packages/lifecycle-supervisor/src/main.rs
index d8481ef63..cfec51642 100644
--- a/packages/lifecycle-supervisor/src/main.rs
+++ b/packages/lifecycle-supervisor/src/main.rs
@@ -29,6 +29,10 @@ mod storage;
mod supervisor;
fn main() {
+ if std::env::args().nth(1).as_deref() == Some("--version") {
+ println!("hack-lifecycle-supervisor 0.1.0 protocol=1 incremental=1");
+ return;
+ }
if supervisor::main().is_err() {
// Inputs can contain credentials. Never include parser or OS error text.
eprintln!("Lifecycle supervisor refused or failed; values omitted.");
diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts
index bffe2f92c..51f7b1e0e 100644
--- a/src/commands/doctor.ts
+++ b/src/commands/doctor.ts
@@ -2434,21 +2434,34 @@ async function checkProjectLifecycleHygiene({
};
}
+ const native = await describeLifecycleExecutable(
+ await readProjectConfig(ctx)
+ );
+ if (native.failure) {
+ return native.failure;
+ }
+ const executable = native.description;
const inspection = await inspectProjectLifecycleHygiene({
projectDir: ctx.projectDir,
projectRoot: ctx.projectRoot,
});
if (
- inspection.staleEntries.length === 0 &&
- inspection.orphanedProcessGroups.length === 0 &&
- inspection.orphanedSessions.length === 0 &&
- inspection.unverifiedSessions.length === 0
+ [
+ inspection.staleEntries,
+ inspection.orphanedProcessGroups,
+ inspection.orphanedSessions,
+ inspection.unverifiedSessions,
+ ].every((entries) => entries.length === 0)
) {
return {
name: "lifecycle hygiene",
status: "ok",
- message:
+ message: [
"No stale lifecycle state entries or orphaned lifecycle processes",
+ executable,
+ ]
+ .filter(Boolean)
+ .join("; "),
};
}
@@ -2492,10 +2505,36 @@ async function checkProjectLifecycleHygiene({
return {
name: "lifecycle hygiene",
status: "warn",
- message: `${details.join("; ")} (${nextStep})`,
+ message: [`${details.join("; ")} (${nextStep})`, executable]
+ .filter(Boolean)
+ .join("; "),
};
}
+async function describeLifecycleExecutable(
+ config: Awaited>
+): Promise<{ description: string; failure?: CheckResult }> {
+ if (
+ resolveLifecycleRunner({ runner: config.lifecycle?.runner }) !==
+ "supervisor"
+ ) {
+ return { description: "" };
+ }
+ try {
+ const identity = await preflightLifecycleSupervisor();
+ return { description: `${identity.path} (${identity.version})` };
+ } catch {
+ return {
+ description: "",
+ failure: {
+ name: "lifecycle supervisor",
+ status: "warn",
+ message: `${resolveLifecycleSupervisorBinary()}: missing or incompatible executable`,
+ },
+ };
+ }
+}
+
async function checkProjectEnvMode({
startDir,
}: {
@@ -6040,3 +6079,9 @@ async function checkProxyNetwork(): Promise {
].join("\n"),
};
}
+
+import { resolveLifecycleRunner } from "../lib/lifecycle-runner.ts";
+import {
+ preflightLifecycleSupervisor,
+ resolveLifecycleSupervisorBinary,
+} from "../lib/lifecycle-supervisor.ts";
diff --git a/src/commands/project.ts b/src/commands/project.ts
index 4b4a43f77..96e67e2e2 100644
--- a/src/commands/project.ts
+++ b/src/commands/project.ts
@@ -1,5 +1,6 @@
import { reportLooseEnvLayerModes } from "../lib/env-layer-modes.ts";
import { resolveLifecycleRunner } from "../lib/lifecycle-runner.ts";
+import { preflightLifecycleSupervisor } from "../lib/lifecycle-supervisor.ts";
import {
createLifecycleProcessController,
installLifecycleSignalCleanup,
@@ -6581,6 +6582,11 @@ async function runUpCommand({
if (sharedHelperRefusal !== null) {
return sharedHelperRefusal;
}
+ if (
+ resolveLifecycleRunner({ runner: cfg.lifecycle?.runner }) === "supervisor"
+ ) {
+ await preflightLifecycleSupervisor();
+ }
const baseProjectName = await resolveComposeProjectName({ project, cfg });
const composeProjectName = branch ? `${baseProjectName}--${branch}` : null;
@@ -8517,6 +8523,11 @@ async function runRestartCommand({
}
const baseProjectName = await resolveComposeProjectName({ project, cfg });
+ if (
+ resolveLifecycleRunner({ runner: cfg.lifecycle?.runner }) === "supervisor"
+ ) {
+ await preflightLifecycleSupervisor();
+ }
const { composeProjectName, lifecycleComposeProject } = resolveRestartTarget({
baseProjectName,
branch,
diff --git a/src/lib/lifecycle-runtime.ts b/src/lib/lifecycle-runtime.ts
index 5290702de..4b0d50e14 100644
--- a/src/lib/lifecycle-runtime.ts
+++ b/src/lib/lifecycle-runtime.ts
@@ -1,4 +1,4 @@
-import { lstat } from "node:fs/promises";
+import { lstat, rename } from "node:fs/promises";
import { resolve } from "node:path";
import { readLinesFromStream } from "../ui/lines.ts";
import { getString, isRecord } from "./guards.ts";
@@ -39,6 +39,8 @@ export type LifecycleStateEntry = {
readonly processes: readonly LifecycleStateProcess[];
readonly updatedAt: string;
readonly supervisorPid?: number;
+ readonly supervisorRoot?: string;
+ readonly supervisorLaunchAttempted?: boolean;
readonly cleanupEpoch?: string;
};
@@ -160,6 +162,7 @@ export async function ensurePrivateLifecycleLog(opts: {
export async function readLifecycleState(opts: {
readonly projectDir: string;
+ readonly stateLocked?: boolean;
}): Promise {
const root = resolveLifecycleRootDir(opts);
const path = resolveLifecycleStatePath(opts);
@@ -191,7 +194,27 @@ export async function readLifecycleState(opts: {
try {
parsed = JSON.parse(text);
} catch {
- throw lifecycleStateRefused();
+ if (
+ await lstat(resolve(root, "supervisor-state")).catch((error: unknown) => {
+ if (isRecord(error) && error.code === "ENOENT") {
+ return null;
+ }
+ throw error;
+ })
+ ) {
+ throw lifecycleStateRefused();
+ }
+ if (!opts.stateLocked) {
+ return await mutateLifecycleState(opts.projectDir, () =>
+ readLifecycleState({ ...opts, stateLocked: true })
+ );
+ }
+ await verifyLifecycleStateTarget(path);
+ await rename(path, `${path}.legacy-${Date.now()}-${crypto.randomUUID()}`);
+ console.error(
+ "Moved unreadable legacy lifecycle JSON aside; continuing without lifecycle state."
+ );
+ return [];
}
if (!isRecord(parsed)) {
throw lifecycleStateRefused();
@@ -221,7 +244,16 @@ export async function upsertLifecycleStateEntry(opts: {
readonly entry: LifecycleStateEntry;
}): Promise {
return await mutateLifecycleState(opts.projectDir, async () => {
- const state = await readLifecycleState({ projectDir: opts.projectDir });
+ if (opts.entry.backend === "supervisor") {
+ await appendPrivateFile({
+ path: resolve(resolveLifecycleRootDir(opts), "supervisor-state"),
+ data: "",
+ });
+ }
+ const state = await readLifecycleState({
+ projectDir: opts.projectDir,
+ stateLocked: true,
+ });
const withoutCurrent = state.filter(
(entry) => entry.composeProject !== opts.entry.composeProject
);
@@ -242,7 +274,10 @@ export async function removeLifecycleStateEntry(opts: {
readonly composeProject: string;
}): Promise {
return await mutateLifecycleState(opts.projectDir, async () => {
- const state = await readLifecycleState({ projectDir: opts.projectDir });
+ const state = await readLifecycleState({
+ projectDir: opts.projectDir,
+ stateLocked: true,
+ });
const nextEntries = state.filter(
(entry) => entry.composeProject !== opts.composeProject
);
@@ -262,7 +297,10 @@ export async function removeLifecycleStateEntryIfOwned(opts: {
readonly ownershipToken: string;
}): Promise {
return await mutateLifecycleState(opts.projectDir, async () => {
- const state = await readLifecycleState({ projectDir: opts.projectDir });
+ const state = await readLifecycleState({
+ projectDir: opts.projectDir,
+ stateLocked: true,
+ });
const current = state.find(
(entry) => entry.composeProject === opts.composeProject
);
@@ -286,7 +324,10 @@ export async function removeLifecycleStateEntryIfMatching(opts: {
readonly expectedEntry: LifecycleStateEntry;
}): Promise {
return await mutateLifecycleState(opts.projectDir, async () => {
- const state = await readLifecycleState({ projectDir: opts.projectDir });
+ const state = await readLifecycleState({
+ projectDir: opts.projectDir,
+ stateLocked: true,
+ });
const current = state.find(
(entry) => entry.composeProject === opts.expectedEntry.composeProject
);
@@ -369,6 +410,13 @@ function parseLifecycleStateEntry(value: unknown): LifecycleStateEntry | null {
processes,
updatedAt,
...(supervisorPid ? { supervisorPid } : {}),
+ ...(typeof value.supervisorRoot === "string" &&
+ value.supervisorRoot.startsWith("/")
+ ? { supervisorRoot: value.supervisorRoot }
+ : {}),
+ ...(typeof value.supervisorLaunchAttempted === "boolean"
+ ? { supervisorLaunchAttempted: value.supervisorLaunchAttempted }
+ : {}),
...(cleanupEpoch ? { cleanupEpoch } : {}),
};
}
diff --git a/src/lib/lifecycle-supervisor.ts b/src/lib/lifecycle-supervisor.ts
index 053fffbb9..0a9e751da 100644
--- a/src/lib/lifecycle-supervisor.ts
+++ b/src/lib/lifecycle-supervisor.ts
@@ -1,9 +1,13 @@
import { createHash } from "node:crypto";
-import { lstat } from "node:fs/promises";
-import { dirname, isAbsolute, relative, resolve } from "node:path";
+import { constants, realpathSync } from "node:fs";
+import { access, lstat } from "node:fs/promises";
+import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
import { resolveGlobalHackDir } from "./config-paths.ts";
import { isRecord } from "./guards.ts";
-import { resolveLifecycleLogPath } from "./lifecycle-runtime.ts";
+import {
+ readLifecycleState,
+ resolveLifecycleLogPath,
+} from "./lifecycle-runtime.ts";
import { ensurePrivateDirectory } from "./private-file.ts";
import { withProjectsRegistryLock } from "./projects-registry-lock.ts";
@@ -52,23 +56,41 @@ export function resolveLifecycleSupervisorRoot(opts: {
readonly projectDir: string;
readonly composeProject: string;
}): string {
- const hostRoot = resolve(resolveGlobalHackDir(), "lifecycle-supervisors");
- const inside = relative(dirname(resolve(opts.projectDir)), hostRoot);
+ const hostRoot = canonicalPath(
+ resolve(resolveGlobalHackDir(), "lifecycle-supervisors")
+ );
+ const inside = relative(dirname(realpathSync(opts.projectDir)), hostRoot);
if (inside === "" || !(inside.startsWith("../") || isAbsolute(inside))) {
throw new Error(
"Lifecycle supervisor state must be outside the project checkout."
);
}
const key = createHash("sha256")
- .update(JSON.stringify([resolve(opts.projectDir), opts.composeProject]))
+ .update(
+ JSON.stringify([realpathSync(opts.projectDir), opts.composeProject])
+ )
.digest("hex");
return resolve(hostRoot, key);
}
+function canonicalPath(path: string): string {
+ try {
+ return realpathSync(path);
+ } catch (error) {
+ if (
+ !(isRecord(error) && error.code === "ENOENT") ||
+ dirname(path) === path
+ ) {
+ throw error;
+ }
+ return resolve(canonicalPath(dirname(path)), basename(path));
+ }
+}
+
/** Resolve only the current installation or an explicit test executable. */
export function resolveLifecycleSupervisorBinary(): string {
const selected = process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY;
- if (selected !== undefined) {
+ if (selected !== undefined && !Bun.main.startsWith("/$bunfs/")) {
if (
!(selected.trim() && selected.startsWith("/")) ||
selected.includes("\0")
@@ -84,11 +106,71 @@ export function resolveLifecycleSupervisorBinary(): string {
: resolve(import.meta.dir, "../../dist/hack-lifecycle-supervisor");
}
+export const LIFECYCLE_SUPERVISOR_VERSION =
+ "hack-lifecycle-supervisor 0.1.0 protocol=1 incremental=1";
+
+/** Validate the public, value-free handshake before publishing any ownership. */
+export async function preflightLifecycleSupervisor(): Promise<{
+ path: string;
+ version: string;
+}> {
+ const path = resolveLifecycleSupervisorBinary();
+ try {
+ await access(path, constants.X_OK);
+ if (!(await lstat(realpathSync(path))).isFile()) {
+ throw failure();
+ }
+ const child = Bun.spawn([path, "--version"], {
+ env: {},
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "ignore",
+ timeout: 2000,
+ });
+ const version = (await new Response(child.stdout).text()).trim();
+ if (
+ (await child.exited) !== 0 ||
+ version !== LIFECYCLE_SUPERVISOR_VERSION
+ ) {
+ throw failure();
+ }
+ return { path, version };
+ } catch {
+ throw new Error(
+ "Lifecycle supervisor executable is missing, incompatible or not executable; install the matching companion beside this CLI before opting in."
+ );
+ }
+}
+
+async function recordedRoot(
+ opts: Instance
+): Promise<{ root: string; neverStarted: boolean }> {
+ const entry = (await readLifecycleState(opts)).find(
+ (entry) => entry.composeProject === opts.composeProject
+ );
+ const root = entry?.supervisorRoot ?? resolveLifecycleSupervisorRoot(opts);
+ const inside = relative(
+ dirname(realpathSync(opts.projectDir)),
+ canonicalPath(root)
+ );
+ if (inside === "" || !(inside.startsWith("../") || isAbsolute(inside))) {
+ throw failure();
+ }
+ return {
+ root,
+ neverStarted:
+ !entry ||
+ (entry.supervisorLaunchAttempted === false &&
+ !entry.supervisorPid &&
+ entry.processes.length === 0),
+ };
+}
+
/** Native observations, never a persisted `running` phase, establish liveness. */
export async function inspectLifecycleSupervisor(
opts: Instance
): Promise {
- if (await lifecycleSupervisorRootAbsent(opts)) {
+ if (await lifecycleSupervisorProvenUnstarted(opts)) {
return {
version: 1,
supervisorPid: 0,
@@ -105,7 +187,7 @@ export async function inspectLifecycleSupervisor(
export async function stopLifecycleSupervisor(
opts: Instance
): Promise {
- if (await lifecycleSupervisorRootAbsent(opts)) {
+ if (await lifecycleSupervisorProvenUnstarted(opts)) {
return {
version: 1,
supervisorPid: 0,
@@ -118,9 +200,15 @@ export async function stopLifecycleSupervisor(
return await request({ ...opts, action: "stop" });
}
-async function lifecycleSupervisorRootAbsent(opts: Instance): Promise {
+async function lifecycleSupervisorProvenUnstarted(
+ opts: Instance
+): Promise {
+ const recorded = await recordedRoot(opts);
+ if (recorded.neverStarted) {
+ return true;
+ }
try {
- await lstat(resolveLifecycleSupervisorRoot(opts));
+ await lstat(recorded.root);
return false;
} catch (error) {
if (isRecord(error) && error.code === "ENOENT") {
@@ -129,7 +217,7 @@ async function lifecycleSupervisorRootAbsent(opts: Instance): Promise {
await lstat(`${resolveLifecycleLogPath(opts)}.supervisor`);
} catch (legacyError) {
if (isRecord(legacyError) && legacyError.code === "ENOENT") {
- return true;
+ throw failure();
}
throw failure();
}
@@ -157,10 +245,11 @@ async function request(
readonly processes?: readonly LifecycleSupervisorProcess[];
}
): Promise {
+ await preflightLifecycleSupervisor();
const payload = new TextEncoder().encode(
JSON.stringify({
version: 1,
- root: resolveLifecycleSupervisorRoot(opts),
+ root: (await recordedRoot(opts)).root,
ownerToken: opts.ownershipToken,
...(opts.processes
? {
diff --git a/src/lib/project-lifecycle-supervisor.ts b/src/lib/project-lifecycle-supervisor.ts
index af4e15272..d6e9f848a 100644
--- a/src/lib/project-lifecycle-supervisor.ts
+++ b/src/lib/project-lifecycle-supervisor.ts
@@ -14,6 +14,7 @@ import {
inspectLifecycleSupervisor,
type LifecycleSupervisorReceipt,
launchLifecycleSupervisorProcess,
+ preflightLifecycleSupervisor,
resolveLifecycleSupervisorRoot,
stopLifecycleSupervisor,
withLifecycleSupervisorControllerLock,
@@ -51,6 +52,8 @@ export function createSupervisorLifecycleController(
let created = false;
let decided = false;
let ownerPid: number | undefined;
+ let launchAttempted = false;
+ let nativeRoot: string | undefined;
let cleanupEpoch: string | undefined;
let processes: readonly LifecycleStateProcess[] = [];
let signals: ReturnType | null = null;
@@ -104,6 +107,8 @@ export function createSupervisorLifecycleController(
processes,
updatedAt: new Date().toISOString(),
...(ownerPid ? { supervisorPid: ownerPid } : {}),
+ ...(nativeRoot ? { supervisorRoot: nativeRoot } : {}),
+ supervisorLaunchAttempted: launchAttempted,
...(cleanupEpoch ? { cleanupEpoch } : {}),
};
}
@@ -143,6 +148,9 @@ export function createSupervisorLifecycleController(
token = existing.ownershipToken;
processes = existing.processes;
ownerPid = existing.supervisorPid;
+ nativeRoot =
+ existing.supervisorRoot ?? resolveLifecycleSupervisorRoot(instance);
+ launchAttempted = true;
cleanupEpoch = createLifecycleOwnershipToken();
await upsertLifecycleStateEntry({
projectDir: instance.projectDir,
@@ -175,7 +183,9 @@ export function createSupervisorLifecycleController(
throw new LifecycleSessionDisplacedError(sessionName);
}
await ensurePrivateLifecycleLog({ root: dirname(logPath), logPath });
- await ensurePrivateDirectory(resolveLifecycleSupervisorRoot(instance));
+ await preflightLifecycleSupervisor();
+ nativeRoot = resolveLifecycleSupervisorRoot(instance);
+ await ensurePrivateDirectory(nativeRoot);
token = createLifecycleOwnershipToken();
cleanupEpoch = createLifecycleOwnershipToken();
created = true;
@@ -206,6 +216,7 @@ export function createSupervisorLifecycleController(
}));
}
async function start(process: ProjectLifecycleProcess): Promise {
+ await preflightLifecycleSupervisor();
await decide();
if (processes.some((started) => started.name === process.name)) {
await underLock(assertOwned);
@@ -261,14 +272,13 @@ export function createSupervisorLifecycleController(
env: childEnvironment(undefined, opts.env),
},
};
+ launchAttempted = true;
+ await publish();
// An owner can naturally retire between hooks. Retry serve only after
// inspecting native receipts; never infer absence from a socket error.
let receipt: LifecycleSupervisorReceipt;
if (processes.length === 0) {
- receipt = await launchLifecycleSupervisorProcess({
- ...launch,
- action: "serve",
- });
+ receipt = await serveAfterAbsence(launch);
} else {
try {
receipt = await launchLifecycleSupervisorProcess({
diff --git a/tests/e2e/scenarios/lifecycle-host-process.ts b/tests/e2e/scenarios/lifecycle-host-process.ts
index 82a4b7e85..4b624a5f7 100644
--- a/tests/e2e/scenarios/lifecycle-host-process.ts
+++ b/tests/e2e/scenarios/lifecycle-host-process.ts
@@ -1,6 +1,7 @@
import { readdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { isRecord } from "../../../src/lib/guards.ts";
+import { resolveLifecycleSupervisorRoot } from "../../../src/lib/lifecycle-supervisor.ts";
import { restoreEnv } from "../../helpers/env.ts";
import { createMonorepoFixture } from "../fixture.ts";
@@ -483,11 +484,17 @@ async function runSupervisorHostProcess(opts: {
message: "real Compose failure left native lifecycle ownership",
result: failedUp,
});
- const nativeRoot = join(
- failing.hackDir,
- ".internal/lifecycle",
- `${failing.name}.log.supervisor`
- );
+ const priorHome = process.env.HACK_HOME;
+ let nativeRoot: string;
+ try {
+ process.env.HACK_HOME = opts.ctx.hackHome;
+ nativeRoot = resolveLifecycleSupervisorRoot({
+ projectDir: failing.hackDir,
+ composeProject: failing.name,
+ });
+ } finally {
+ restoreEnv("HACK_HOME", priorHome);
+ }
const receipts = (await readdir(nativeRoot)).filter((name) =>
name.endsWith(".json")
);
diff --git a/tests/e2e/scenarios/lifecycle-session-recovery.ts b/tests/e2e/scenarios/lifecycle-session-recovery.ts
index 48f25d585..70db47f35 100644
--- a/tests/e2e/scenarios/lifecycle-session-recovery.ts
+++ b/tests/e2e/scenarios/lifecycle-session-recovery.ts
@@ -1,4 +1,4 @@
-import { chmod, mkdir } from "node:fs/promises";
+import { chmod, copyFile, mkdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { restoreEnv } from "../../helpers/env.ts";
@@ -622,6 +622,39 @@ async function runSupervisorRecovery(opts: {
);
const branches = ["feat-supervisor-handoff"];
try {
+ const originalCli = process.env.HACK_E2E_CLI_BIN;
+ const missingBinary = join(ctx.tempRoot, "missing-supervisor");
+ if (originalCli) {
+ const isolatedInstall = join(ctx.tempRoot, "cli-without-companion");
+ await mkdir(isolatedInstall);
+ const isolatedCli = join(isolatedInstall, "hack");
+ await copyFile(originalCli, isolatedCli);
+ await chmod(isolatedCli, 0o700);
+ process.env.HACK_E2E_CLI_BIN = isolatedCli;
+ }
+ try {
+ expectExit({
+ result: await cli(["up", "--detach", "--json"], {
+ ...cliEnv,
+ HACK_LIFECYCLE_SUPERVISOR_BINARY: originalCli
+ ? binary
+ : missingBinary,
+ }),
+ codes: [1],
+ message:
+ "missing companion must fail before ownership even with a production override",
+ });
+ expect({
+ that: !(await readEntry()),
+ message: "failed preflight published ownership",
+ });
+ assertOk(
+ await cli(["down", "--json"]),
+ "missing companion must not wedge down before any launch"
+ );
+ } finally {
+ restoreEnv("HACK_E2E_CLI_BIN", originalCli);
+ }
assertOk(
await cli(["up", "--detach", "--json"]),
"supervisor up should start persistent hook and process"
diff --git a/tests/lifecycle-state-publication.test.ts b/tests/lifecycle-state-publication.test.ts
index b63847d8c..0efbde7c5 100644
--- a/tests/lifecycle-state-publication.test.ts
+++ b/tests/lifecycle-state-publication.test.ts
@@ -159,8 +159,6 @@ test("atomic readers observe a complete old or new snapshot", async () => {
});
test.each([
- "",
- "{",
"null",
'{"entries":{}}',
'{"entries":[{}]}',
@@ -253,3 +251,37 @@ test("oversized existing and proposed state are refused without replacement", as
expect(await readFile(path, "utf8")).toBe(text);
});
});
+
+test.each([
+ "",
+ "{",
+])("legacy non-atomic mux JSON is preserved and recovered (%p)", async (text) => {
+ await fixture(async ({ projectDir, path, root }) => {
+ const { readdir } = await import("node:fs/promises");
+ await writeFile(path, text, { mode: 0o600 });
+ expect(await readLifecycleState({ projectDir })).toEqual([]);
+ const archived = (await readdir(root)).find((name) =>
+ name.startsWith("state.json.legacy-")
+ );
+ expect(archived).toBeDefined();
+ expect(await readFile(join(root, archived ?? "missing"), "utf8")).toBe(
+ text
+ );
+ await upsertLifecycleStateEntry({
+ projectDir,
+ entry: { ...entry("mux"), backend: "tmux" },
+ });
+ expect((await readLifecycleState({ projectDir }))[0]?.backend).toBe("tmux");
+ });
+});
+
+test("supervisor ownership disables legacy JSON recovery", async () => {
+ await fixture(async ({ projectDir, path }) => {
+ await upsertLifecycleStateEntry({ projectDir, entry: entry("native") });
+ await writeFile(path, "{", { mode: 0o600 });
+ await expect(readLifecycleState({ projectDir })).rejects.toThrow(
+ "Lifecycle ownership state"
+ );
+ expect(await readFile(path, "utf8")).toBe("{");
+ });
+});
diff --git a/tests/lifecycle-supervisor-controller.test.ts b/tests/lifecycle-supervisor-controller.test.ts
index c04834533..bff446761 100644
--- a/tests/lifecycle-supervisor-controller.test.ts
+++ b/tests/lifecycle-supervisor-controller.test.ts
@@ -7,7 +7,10 @@ import {
readLifecycleState,
resolveLifecycleLogPath,
} from "../src/lib/lifecycle-runtime.ts";
-import { inspectLifecycleSupervisor } from "../src/lib/lifecycle-supervisor.ts";
+import {
+ inspectLifecycleSupervisor,
+ resolveLifecycleSupervisorRoot,
+} from "../src/lib/lifecycle-supervisor.ts";
import {
createLifecycleProcessController,
stopLifecycleProcessController,
@@ -43,6 +46,13 @@ async function fixture(
await mkdir(project.projectDir, { recursive: true });
const savedHackHome = process.env.HACK_HOME;
process.env.HACK_HOME = join(root, "host-home");
+ // Fail before any native effect if path resolution reaches the real home.
+ expect(
+ resolveLifecycleSupervisorRoot({
+ ...project,
+ composeProject: "fixture",
+ }).startsWith(`${process.env.HACK_HOME}/`)
+ ).toBe(true);
try {
await run(project);
} finally {
@@ -226,7 +236,7 @@ nativeTest(
composeProject: "fixture",
ownershipToken: entry.ownershipToken,
})
- ).rejects.toThrow("unavailable");
+ ).rejects.toThrow("executable");
expect((await readLifecycleState(project))[0]?.ownershipToken).toBe(
entry.ownershipToken
);
@@ -273,6 +283,10 @@ nativeTest(
await Bun.write(
wrapper,
`#!${process.execPath} --no-env-file
+if (process.argv.at(-1) === "--version") {
+ const child = Bun.spawn([${JSON.stringify(supervisorBinary)}, "--version"], {env:{},stdin:"ignore",stdout:"inherit",stderr:"ignore"});
+ process.exit(await child.exited);
+}
const reader = Bun.stdin.stream().getReader();
let frame = Buffer.alloc(0);
while (true) {
@@ -368,3 +382,176 @@ nativeTest(
}),
30_000
);
+
+nativeTest(
+ "missing companion fails before ownership and down remains usable",
+ () =>
+ fixture(async (project) => {
+ const saved = process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY;
+ process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY = join(
+ project.projectRoot,
+ "missing-companion"
+ );
+ try {
+ await expect(
+ controller(project).startMany({
+ processes: [{ name: "worker", command }],
+ })
+ ).rejects.toThrow("executable");
+ expect(await readLifecycleState(project)).toEqual([]);
+ await stopLifecycleProcessController({
+ project,
+ projectName: "fixture",
+ composeProject: "fixture",
+ branch: null,
+ definitionHash: "synthetic",
+ });
+ expect(await readLifecycleState(project)).toEqual([]);
+ } finally {
+ restoreEnv("HACK_LIFECYCLE_SUPERVISOR_BINARY", saved);
+ }
+ }),
+ 30_000
+);
+
+nativeTest(
+ "recorded native root survives a changed home and a symlinked checkout",
+ () =>
+ fixture(async (project) => {
+ const { symlink } = await import("node:fs/promises");
+ const owner = controller(project);
+ await owner.startMany({ processes: [{ name: "worker", command }] });
+ await owner.finalize();
+ const entry = (await readLifecycleState(project))[0];
+ if (!(entry?.ownershipToken && entry.supervisorRoot)) {
+ throw new Error("missing fixture identity");
+ }
+ const savedHome = process.env.HACK_HOME;
+ const alias = join(project.projectRoot, "alias");
+ await symlink(project.projectDir, alias);
+ process.env.HACK_HOME = join(project.projectRoot, "unused-home");
+ try {
+ const observed = await inspectLifecycleSupervisor({
+ projectDir: alias,
+ composeProject: "fixture",
+ ownershipToken: entry.ownershipToken,
+ });
+ expect(observed.liveProcessNames).toEqual(["worker"]);
+ await stopLifecycleProcessController({
+ project: { ...project, projectDir: alias },
+ projectName: "fixture",
+ composeProject: "fixture",
+ branch: null,
+ definitionHash: "synthetic",
+ });
+ expect(await readLifecycleState(project)).toEqual([]);
+ } finally {
+ restoreEnv("HACK_HOME", savedHome);
+ }
+ }),
+ 30_000
+);
+
+nativeTest(
+ "missing recorded root never grants retirement authority",
+ () =>
+ fixture(async (project) => {
+ const { rename } = await import("node:fs/promises");
+ const owner = controller(project);
+ await owner.startMany({ processes: [{ name: "worker", command }] });
+ await owner.finalize();
+ const entry = (await readLifecycleState(project))[0];
+ if (!(entry?.ownershipToken && entry.supervisorRoot)) {
+ throw new Error("missing fixture identity");
+ }
+ const moved = `${entry.supervisorRoot}.retained`;
+ await rename(entry.supervisorRoot, moved);
+ try {
+ await expect(
+ stopLifecycleProcessController({
+ project,
+ projectName: "fixture",
+ composeProject: "fixture",
+ branch: null,
+ definitionHash: "synthetic",
+ })
+ ).rejects.toThrow();
+ expect((await readLifecycleState(project))[0]?.ownershipToken).toBe(
+ entry.ownershipToken
+ );
+ } finally {
+ await rename(moved, entry.supervisorRoot);
+ }
+ }),
+ 30_000
+);
+
+nativeTest(
+ "never-attempted ownership can be retired without the companion",
+ () =>
+ fixture(async (project) => {
+ const { upsertLifecycleStateEntry } = await import(
+ "../src/lib/lifecycle-runtime.ts"
+ );
+ await upsertLifecycleStateEntry({
+ projectDir: project.projectDir,
+ entry: {
+ composeProject: "fixture",
+ projectName: "fixture",
+ branch: null,
+ sessionName: "fixture-lifecycle",
+ backend: "supervisor",
+ ownershipToken: "synthetic-never-started",
+ cleanupEpoch: "synthetic-epoch",
+ processes: [],
+ supervisorLaunchAttempted: false,
+ updatedAt: new Date().toISOString(),
+ },
+ });
+ const saved = process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY;
+ process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY = join(
+ project.projectRoot,
+ "missing-companion"
+ );
+ try {
+ await stopLifecycleProcessController({
+ project,
+ projectName: "fixture",
+ composeProject: "fixture",
+ branch: null,
+ definitionHash: "synthetic",
+ });
+ expect(await readLifecycleState(project)).toEqual([]);
+ } finally {
+ restoreEnv("HACK_LIFECYCLE_SUPERVISOR_BINARY", saved);
+ }
+ }),
+ 30_000
+);
+
+nativeTest(
+ "incompatible companion fails before command environment delivery",
+ () =>
+ fixture(async (project) => {
+ const { chmod } = await import("node:fs/promises");
+ const wrapper = join(project.projectRoot, "wrong-version");
+ await Bun.write(
+ wrapper,
+ `#!${process.execPath} --no-env-file\nconsole.log("synthetic incompatible version");\n`
+ );
+ await chmod(wrapper, 0o700);
+ const saved = process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY;
+ process.env.HACK_LIFECYCLE_SUPERVISOR_BINARY = wrapper;
+ try {
+ await expect(
+ controller(project).startMany({
+ processes: [{ name: "worker", command }],
+ })
+ ).rejects.toThrow("incompatible");
+ expect(await readLifecycleState(project)).toEqual([]);
+ } finally {
+ restoreEnv("HACK_LIFECYCLE_SUPERVISOR_BINARY", saved);
+ }
+ }),
+ 30_000
+);
diff --git a/tests/lifecycle-supervisor-observers.test.ts b/tests/lifecycle-supervisor-observers.test.ts
index 35dd2db28..f3ce3338a 100644
--- a/tests/lifecycle-supervisor-observers.test.ts
+++ b/tests/lifecycle-supervisor-observers.test.ts
@@ -352,7 +352,7 @@ test("one malformed project stays unknown without aborting global observers", as
await mkdir(good, { recursive: true });
await writeFile(
join(bad, ".internal", "lifecycle", "state.json"),
- "{truncated"
+ '{"entries":[{}]}'
);
const common = {
projectDirName: ".hack" as const,
@@ -454,13 +454,18 @@ test("one malformed project stays unknown without aborting global observers", as
}
});
-test("supervisor control state is outside the checkout", () => {
- const root = resolveLifecycleSupervisorRoot({
- projectDir: "/synthetic-checkout/.hack",
- composeProject: "sample",
- });
- expect(root.startsWith("/synthetic-checkout/")).toBe(false);
- expect(root).toContain("/lifecycle-supervisors/");
+test("supervisor control state is outside the checkout", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "supervisor-root-"));
+ try {
+ const root = resolveLifecycleSupervisorRoot({
+ projectDir: directory,
+ composeProject: "sample",
+ });
+ expect(root.startsWith("/synthetic-checkout/")).toBe(false);
+ expect(root).toContain("/lifecycle-supervisors/");
+ } finally {
+ await rm(directory, { recursive: true, force: true });
+ }
});
test("doctor holds the controller lock through native stop and state removal", async () => {
diff --git a/tests/models/tla/lifecycle-controller/COVERAGE.md b/tests/models/tla/lifecycle-controller/COVERAGE.md
index f09f06b49..756ef1034 100644
--- a/tests/models/tla/lifecycle-controller/COVERAGE.md
+++ b/tests/models/tla/lifecycle-controller/COVERAGE.md
@@ -47,3 +47,21 @@ multiple processes' independent liveness, multiple instances, command output and
default-runner migration. Physical-process ownership remains covered separately
by `LifecycleSupervisor.tla` and Rust tests. This small controller model is not
whole-product, macOS runtime, packaging or installed-artifact qualification.
+
+The finite model assumes `Begin` is admitted only after the executable preflight.
+It does not prove executable selection, version compatibility, canonical paths,
+legacy JSON recovery, or the meaning of a missing native directory. Those are
+implementation boundaries, covered by these real-controller regressions:
+
+| Boundary / transition | Required outcome | Regression |
+| --- | --- | --- |
+| Absent → preflight fails | No ownership entry; down remains usable | `missing companion fails before ownership and down remains usable` |
+| Pending, no launch attempted → down | Retire without requiring an executable | `never-attempted ownership can be retired without the companion` |
+| Pending → launch attempted | Publish the attempt before native dispatch; an uncertain result retains ownership | Lost incremental reply regression and atomic state-publication tests |
+| Running → HOME or path spelling changes | Use the persisted native root, never create a replacement owner | `recorded native root survives a changed home and a symlinked checkout` |
+| Running → recorded root missing | Unconfirmed; retain ownership | `missing recorded root never grants retirement authority` |
+
+`supervisorLaunchAttempted = false` is an explicit durable observation, not an
+inference from an empty process array. Older entries without that field remain
+conservative. An absent PID alone cannot rule out a lost launch reply. Native
+root loss is outside the model's abstract successful-stop action.
From 93266198b85dba85d0adeb0c41c3cfee9ddb6023 Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 22:53:39 -0500
Subject: [PATCH 13/55] test(lifecycle): cover immediate supervisor definition
replacement
---
tests/lifecycle-supervisor-controller.test.ts | 20 +++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/tests/lifecycle-supervisor-controller.test.ts b/tests/lifecycle-supervisor-controller.test.ts
index bff446761..9d9ba849c 100644
--- a/tests/lifecycle-supervisor-controller.test.ts
+++ b/tests/lifecycle-supervisor-controller.test.ts
@@ -555,3 +555,23 @@ nativeTest(
}),
30_000
);
+
+nativeTest(
+ "definition replacement waits for the previous project owner to retire",
+ () =>
+ fixture(async (project) => {
+ for (const hash of [
+ "synthetic-first",
+ "synthetic-second",
+ "synthetic-third",
+ ]) {
+ const owner = controller(project, hash);
+ await owner.startMany({ processes: [{ name: "worker", command }] });
+ await owner.finalize();
+ expect((await readLifecycleState(project))[0]?.definitionHash).toBe(
+ hash
+ );
+ }
+ }),
+ 30_000
+);
From 766a3afd5f0b76d689d2c5bcd0cf9a35ea62f7e2 Mon Sep 17 00:00:00 2001
From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Date: Fri, 13 Nov 2026 22:55:57 -0500
Subject: [PATCH 14/55] fix(lifecycle): recover native orphan sessions and
bound idle work
---
packages/lifecycle-supervisor/README.md | 50 ++-
packages/lifecycle-supervisor/src/os.rs | 336 +++++++++++++++-
.../lifecycle-supervisor/src/supervisor.rs | 221 ++++++++---
.../lifecycle-supervisor/tests/fake_caller.rs | 371 +++++++++++++++++-
.../tla/lifecycle-supervisor/COVERAGE.md | 27 +-
5 files changed, 920 insertions(+), 85 deletions(-)
diff --git a/packages/lifecycle-supervisor/README.md b/packages/lifecycle-supervisor/README.md
index 3fc78f6d8..03b909e49 100644
--- a/packages/lifecycle-supervisor/README.md
+++ b/packages/lifecycle-supervisor/README.md
@@ -28,10 +28,54 @@ leader. The supervisor calls `setsid` itself and detaches its standard descripto
after the framed ready receipt. `stop` and `status` are finite framed requests.
Frames, not pipe closure, delimit delivery. The per-command guard publishes kernel
identity before executing authored argv and retains ownership after the project
-supervisor dies. A killed guard with surviving session members is an unknown
-ownership state: retry and unsafe stop are refused. A command that creates another
-session with `setsid` leaves this ownership boundary and is unsupported.
+supervisor dies. A killed guard with surviving session members prevents a fresh
+launch. Stop can recover that session after validating its recorded boot, leader
+birth, UID and SID: TERM, five seconds of grace, then KILL if necessary, with
+individual incarnation checks before delivery. A different boot or reused leader
+proves the old session absent and never authorizes signaling the replacement.
+A command that creates another session with `setsid` leaves this ownership boundary and is unsupported.
The caller supplies a fresh ownership token and an existing private instance root.
No command/environment is persisted. Log output is unredacted and mode 0600.
There is no PTY: stdin is null and stdout/stderr are pipes.
+
+Status returns `liveProcessNames` and `unknownProcessNames` separately from the
+persisted `processes[].phase`. A missing/zombie guard with live descendants is
+unknown until recovery confirms absence; persisted `running` alone proves nothing.
+These are additive version-1 receipt fields; they do not alter state files.
+
+Idle guards poll stdout/stderr and react to SIGCHLD/stop signals. A live direct
+child proves work remains without a machine-wide census. After its exit, a 500 ms
+fallback session census detects surviving descendants. EOF descriptors leave the poll set. A slow census still
+finds descendants whose exit does not generate SIGCHLD for this guard. Stop uses
+20 ms confirmation intervals and keeps its five-second TERM grace. Immediate
+restart retries the instance lock for at most one second. Command-lock admission
+waits at most ten seconds for a previous owner to finish releasing its lock.
+
+The project supervisor passes its launch/stop flock lease to each guard on startup
+fd 2. The guard retains it through durable publication and receipt delivery, then
+replaces stdio with `/dev/null`. Supervisor death therefore cannot let stop pass an
+unpublished guard that might later spawn. Authored children get their own stderr
+pipe and never inherit that lease. Startup closes inherited descriptors with Linux
+`close_range` or enumeration of actual `/proc/self/fd` / `/dev/fd` entries; its cost
+is independent of the descriptor limit. Log flock acquisition and release retry EINTR.
+
+A usable process census is required before authored spawn. Linux preflight rejects
+known `hidepid` mounts and requires a readable self identity in the census. An
+unreadable process is skipped only after proving its UID is foreign; inaccessible
+same-user identities fail closed. Darwin uses the short BSD identity query to
+prove a protected process has a foreign UID; failure to establish that also refuses.
+A post-launch census error retains the guard and command lock during cleanup.
+This is not qualification of hidepid, PID namespaces, sandboxed/filtered process
+views, or a process view that silently omits same-user members. Those views remain
+unsupported. Same-user hostile receipt mutation and commands escaping their SID
+remain outside the contract. Darwin lacks pidfd, so identity recheck and numeric
+signal delivery still have a kernel observation/effect interval.
+
+The ignored `idle_guard_cpu_and_wakeup_measurement` fixture samples one guard and
+its supervisor for three five-second idle intervals. Linux CPU ticks and voluntary
+context switches are process accounting, not hardware wakeup telemetry. Run it
+alone with `--ignored --exact idle_guard_cpu_and_wakeup_measurement --nocapture`.
+The ignored `million_descriptor_limit_qualification` fixture requires a host hard
+RLIMIT_NOFILE of at least 1048576; the ordinary raised-limit fixture uses the host's
+available limit. A host refusing that raise does not qualify the million-fd case.
diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs
index 126ae4a43..df436fef5 100644
--- a/packages/lifecycle-supervisor/src/os.rs
+++ b/packages/lifecycle-supervisor/src/os.rs
@@ -1,5 +1,4 @@
use crate::{Result, refused};
-#[cfg(target_os = "linux")]
use std::fs;
use std::{
io,
@@ -7,6 +6,12 @@ use std::{
sync::atomic::{AtomicBool, Ordering},
};
+pub static CHILD_CHANGED: AtomicBool = AtomicBool::new(false);
+
+extern "C" fn child_signal(_: i32) {
+ CHILD_CHANGED.store(true, Ordering::Relaxed);
+}
+
pub static STOP: AtomicBool = AtomicBool::new(false);
extern "C" fn stop_signal(_: i32) {
@@ -18,7 +23,7 @@ pub fn signals() -> Result<()> {
// atomic flag; the kernel copies the action and retains no Rust pointer.
unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
- action.sa_sigaction = libc::SIG_DFL;
+ action.sa_sigaction = child_signal as *const () as usize;
libc::sigemptyset(&mut action.sa_mask);
if libc::sigaction(libc::SIGCHLD, &action, std::ptr::null_mut()) != 0 {
return Err(io::Error::last_os_error());
@@ -112,12 +117,26 @@ pub fn census() -> Result> {
let mut result = Vec::new();
for entry in fs::read_dir("/proc")? {
let entry = entry?;
- if let Some(pid) = entry.file_name().to_str().and_then(|v| v.parse().ok())
- && let Some(identity) = inspect(pid)?
- {
- result.push(identity);
+ if let Some(pid) = entry.file_name().to_str().and_then(|v| v.parse().ok()) {
+ match inspect(pid) {
+ Ok(Some(identity)) => result.push(identity),
+ Ok(None) => (),
+ Err(error)
+ if matches!(error.raw_os_error(), Some(libc::EPERM) | Some(libc::EACCES)) =>
+ {
+ use std::os::unix::fs::MetadataExt;
+ match entry.metadata() {
+ // SAFETY: geteuid is an observation with no preconditions.
+ Ok(metadata) if metadata.uid() != unsafe { libc::geteuid() } => (),
+ Err(error) if absent_error(&error) => (),
+ _ => return Err(error),
+ }
+ }
+ Err(error) => return Err(error),
+ }
}
}
+ readable_self(&result)?;
Ok(result)
}
@@ -208,13 +227,38 @@ pub fn census() -> Result> {
Ok(None) => (),
// Unrelated protected processes cannot belong to this user's
// authored session. Exact guard reads remain strict in inspect.
- Err(error) if error.raw_os_error() == Some(libc::EPERM) => (),
+ Err(error) if error.raw_os_error() == Some(libc::EPERM) && foreign_uid(pid)? => (),
Err(error) => return Err(error),
}
}
+ readable_self(&result)?;
Ok(result)
}
+#[cfg(target_os = "macos")]
+fn foreign_uid(pid: i32) -> Result {
+ let mut info = std::mem::MaybeUninit::::zeroed();
+ // SAFETY: bounded writable struct buffer, retained only for this call.
+ let size = unsafe {
+ libc::proc_pidinfo(
+ pid,
+ libc::PROC_PIDT_SHORTBSDINFO,
+ 0,
+ info.as_mut_ptr().cast(),
+ std::mem::size_of::() as i32,
+ )
+ };
+ if size as usize != std::mem::size_of::() {
+ let error = io::Error::last_os_error();
+ if absent_error(&error) {
+ return Ok(true);
+ }
+ return Err(refused());
+ }
+ // SAFETY: exact struct size was initialized; geteuid only observes identity.
+ Ok(unsafe { info.assume_init().pbsi_uid != libc::geteuid() })
+}
+
#[cfg(target_os = "macos")]
pub fn boot() -> Result {
let mut bytes = [0u8; 64];
@@ -282,6 +326,41 @@ pub fn recorded_live(state: &crate::storage::State, excluding_guard: bool) -> Re
live_group(state.guard_pid, excluding_guard.then_some(state.guard_pid))
}
+pub enum Observation {
+ Absent,
+ Live,
+ Unknown,
+}
+
+/// Health observation is stricter than a persisted running phase. A lost guard
+/// with remaining session members is unknown, never healthy or absent.
+pub fn observe_state(state: &crate::storage::State) -> Observation {
+ let observed = (|| -> Result {
+ if state.boot_id != boot()? {
+ return Ok(Observation::Absent);
+ }
+ let leader = inspect(state.guard_pid)?;
+ if let Some(identity) = &leader
+ && (identity.start != state.guard_start_time
+ || identity.uid != state.uid
+ || identity.session != state.guard_pid)
+ {
+ return Ok(Observation::Absent);
+ }
+ if !live_group(state.guard_pid, Some(state.guard_pid))? {
+ return Ok(Observation::Absent);
+ }
+ Ok(
+ if leader.is_some_and(|p| !p.zombie && p.group == state.guard_pid) {
+ Observation::Live
+ } else {
+ Observation::Unknown
+ },
+ )
+ })();
+ observed.unwrap_or(Observation::Unknown)
+}
+
/// The guard is never a member of an authored process group. It retains the
/// command lock while KILL is confirmed, including uninterruptible processes.
pub fn session_signal(session: i32, signal: i32) -> Result<()> {
@@ -446,18 +525,24 @@ pub fn close_inherited() -> Result<()> {
return Ok(());
}
}
- // Portable fallback is bounded by the process's descriptor limit and does
- // not allocate or retain descriptor references while closing them.
- let mut limit: libc::rlimit = unsafe { std::mem::zeroed() };
- // SAFETY: getrlimit initializes one live rlimit value.
- if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) } != 0 {
- return Err(io::Error::last_os_error());
- }
- if limit.rlim_cur > 1_048_576 {
- return Err(refused());
- }
- for fd in 3..limit.rlim_cur as i32 {
- // SAFETY: startup owns no fd >=3; failed closes are harmless gaps.
+ // Collect first, so the directory iterator's descriptor is closed before
+ // the sweep. Work is proportional to open descriptors, not RLIMIT_NOFILE.
+ #[cfg(target_os = "linux")]
+ let directory = "/proc/self/fd";
+ #[cfg(target_os = "macos")]
+ let directory = "/dev/fd";
+ let descriptors: Vec = fs::read_dir(directory)?
+ .map(|entry| {
+ entry?
+ .file_name()
+ .to_str()
+ .and_then(|v| v.parse().ok())
+ .ok_or_else(refused)
+ })
+ .collect::>()?;
+ for fd in descriptors.into_iter().filter(|fd| *fd >= 3) {
+ // SAFETY: startup owns no application descriptors >=3. The iterator's
+ // former descriptor may already be closed; that gap is harmless.
unsafe {
libc::close(fd);
}
@@ -509,13 +594,134 @@ pub fn group_signal(group: i32, signal: i32) -> Result<()> {
// SAFETY: validated positive group number is negated for group delivery.
if unsafe { libc::kill(-group, signal) } != 0 {
let error = io::Error::last_os_error();
- if error.raw_os_error() != Some(libc::ESRCH) {
+ group_signal_result(group, error, census)?;
+ }
+ Ok(())
+}
+
+/// EPERM may race the last live member becoming an unreaped zombie. Only a
+/// fresh, successful group census proving no non-zombie member makes it benign.
+fn group_signal_result(
+ group: i32,
+ error: io::Error,
+ census: impl FnOnce() -> Result>,
+) -> Result<()> {
+ if error.raw_os_error() == Some(libc::ESRCH) {
+ return Ok(());
+ }
+ if error.raw_os_error() == Some(libc::EPERM)
+ && !census()?.iter().any(|p| p.group == group && !p.zombie)
+ {
+ return Ok(());
+ }
+ Err(error)
+}
+
+/// Poll only pipe readiness between slow session checks. SIGCHLD and stop
+/// signals interrupt poll; flags also cover signals arriving before this call.
+pub fn wait_activity(fds: &[i32], timeout: std::time::Duration) -> Result<()> {
+ if STOP.load(Ordering::Relaxed) || CHILD_CHANGED.swap(false, Ordering::Relaxed) {
+ return Ok(());
+ }
+ let mut polls: Vec<_> = fds
+ .iter()
+ .map(|fd| libc::pollfd {
+ fd: *fd,
+ events: libc::POLLIN,
+ revents: 0,
+ })
+ .collect();
+ // SAFETY: initialized pollfd slice is live for its supplied length.
+ if unsafe {
+ libc::poll(
+ polls.as_mut_ptr(),
+ polls.len() as _,
+ timeout.as_millis() as i32,
+ )
+ } < 0
+ {
+ let error = io::Error::last_os_error();
+ if error.kind() != io::ErrorKind::Interrupted {
return Err(error);
}
}
Ok(())
}
+/// A dead leader's SID cannot be reused while any old session member remains.
+/// Validate the receipt and each incarnation again before individual delivery.
+/// Never use group delivery here: a group number can outlive its observed members.
+pub fn signal_recorded_session(state: &crate::storage::State, signal: i32) -> Result<()> {
+ if !recorded_live(state, false)? {
+ return Ok(());
+ }
+ for member in census()?
+ .into_iter()
+ .filter(|p| p.session == state.guard_pid && !p.zombie)
+ {
+ if member.uid != state.uid || member.start < state.guard_start_time {
+ return Err(refused());
+ }
+ if !recorded_live(state, false)? {
+ return Ok(());
+ }
+ signal_member(&member, signal)?;
+ }
+ Ok(())
+}
+
+/// A successful census must contain our own readable identity. Known restricted
+/// proc mounts are unsupported: hidden same-user members defeat absence proofs.
+pub fn census_preflight() -> Result<()> {
+ #[cfg(target_os = "linux")]
+ {
+ let mounts = fs::read_to_string("/proc/self/mountinfo")?;
+ if restricted_mount(&mounts) {
+ return Err(refused());
+ }
+ }
+ readable_self(&census()?)
+}
+
+#[cfg(target_os = "linux")]
+fn restricted_mount(mounts: &str) -> bool {
+ mounts.lines().any(|line| {
+ line.split_whitespace().any(|field| {
+ field
+ .split(',')
+ .any(|option| option.starts_with("hidepid=") && option != "hidepid=0")
+ })
+ })
+}
+
+fn readable_self(members: &[Identity]) -> Result<()> {
+ let pid = std::process::id() as i32;
+ if !members.iter().any(|p| p.pid == pid && !p.zombie) {
+ return Err(refused());
+ }
+ Ok(())
+}
+
+pub fn flock_retry(fd: i32, operation: i32) -> Result<()> {
+ retry_interrupted(|| {
+ // SAFETY: caller retains ownership of the live descriptor across retries.
+ if unsafe { libc::flock(fd, operation) } == 0 {
+ Ok(())
+ } else {
+ Err(io::Error::last_os_error())
+ }
+ })
+}
+
+fn retry_interrupted(mut operation: impl FnMut() -> Result<()>) -> Result<()> {
+ loop {
+ match operation() {
+ Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
+ result => return result,
+ }
+ }
+}
+
pub fn nonblocking(fd: &impl AsRawFd) -> Result<()> {
// SAFETY: fcntl borrows a live descriptor and supplies integer flags only.
let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFL) };
@@ -554,6 +760,96 @@ pub fn timestamp() -> Result {
#[cfg(test)]
mod tests {
+ #[test]
+ fn flock_interruption_retries_but_other_errors_do_not() {
+ let mut calls = 0;
+ super::retry_interrupted(|| {
+ calls += 1;
+ if calls < 3 {
+ Err(std::io::Error::from_raw_os_error(libc::EINTR))
+ } else {
+ Ok(())
+ }
+ })
+ .unwrap();
+ assert_eq!(calls, 3);
+ let error =
+ super::retry_interrupted(|| Err(std::io::Error::from_raw_os_error(libc::EPERM)))
+ .unwrap_err();
+ assert_eq!(error.raw_os_error(), Some(libc::EPERM));
+ }
+
+ #[test]
+ fn unusable_census_refuses_before_launch() {
+ assert!(super::readable_self(&[]).is_err());
+ assert!(super::readable_self(&super::census().unwrap()).is_ok());
+ #[cfg(target_os = "linux")]
+ {
+ assert!(super::restricted_mount(
+ "1 2 0:1 / /proc rw - proc proc rw,hidepid=2"
+ ));
+ assert!(super::restricted_mount(
+ "1 2 0:1 / /proc rw - proc proc rw,hidepid=invisible"
+ ));
+ assert!(!super::restricted_mount(
+ "1 2 0:1 / /proc rw - proc proc rw,hidepid=0"
+ ));
+ }
+ }
+
+ #[test]
+ fn eperm_requires_fresh_zombie_only_group_proof() {
+ use super::*;
+ let member = Identity {
+ pid: 123,
+ group: 123,
+ session: 123,
+ start: 1,
+ uid: 0,
+ zombie: true,
+ };
+ let denied = || io::Error::from_raw_os_error(libc::EPERM);
+ assert!(group_signal_result(123, denied(), || Ok(vec![member.clone()])).is_ok());
+ let mut live = member.clone();
+ live.zombie = false;
+ assert!(group_signal_result(123, denied(), || Ok(vec![member.clone(), live])).is_err());
+ assert!(group_signal_result(123, denied(), || Err(refused())).is_err());
+ assert!(
+ group_signal_result(123, io::Error::from_raw_os_error(libc::EIO), || Ok(vec![]))
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn real_unreaped_group_is_not_live() {
+ use super::*;
+ // SAFETY: child uses only async-signal-safe calls and _exit, never Rust.
+ let pid = unsafe { libc::fork() };
+ assert!(pid >= 0);
+ if pid == 0 {
+ unsafe {
+ libc::setsid();
+ libc::_exit(0);
+ }
+ }
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
+ loop {
+ if inspect(pid).unwrap().is_some_and(|p| p.zombie) {
+ break;
+ }
+ assert!(std::time::Instant::now() < deadline);
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ assert!(!live_group(pid, None).unwrap());
+ assert!(
+ group_signal_result(pid, io::Error::from_raw_os_error(libc::EPERM), census).is_ok()
+ );
+ group_signal(pid, libc::SIGTERM).unwrap();
+ let mut status = 0;
+ // SAFETY: reap only our own still-unreaped child, with a valid status pointer.
+ assert_eq!(unsafe { libc::waitpid(pid, &mut status, 0) }, pid);
+ }
+
#[cfg(target_os = "linux")]
#[test]
fn non_utf8_process_name_does_not_invalidate_identity_fields() {
diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs
index d52babf29..b8d1a3b8f 100644
--- a/packages/lifecycle-supervisor/src/supervisor.rs
+++ b/packages/lifecycle-supervisor/src/supervisor.rs
@@ -19,6 +19,7 @@ use std::{
};
const GRACE: Duration = Duration::from_secs(5);
+const IDLE_TICK: Duration = Duration::from_millis(500);
const TICK: Duration = Duration::from_millis(20);
const LINE_LIMIT: usize = 64 * 1024;
@@ -29,6 +30,30 @@ struct Receipt {
supervisor_pid: u32,
outcome: &'static str,
processes: Vec,
+ live_process_names: Vec