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 recoveryReserve [locks acquired]Spawn [identity durable]StartFailureSupervisorDeathRetry [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]AbsentRunningExitedRecoverableStoppingKillPendingStopUnknownStartingOrphanedINV identity precedes commandUnknown identity refuses stopLive 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` | + +![Supervisor state transitions](./diagrams/lifecycle-supervisor.svg) + +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 adoptionBeginLaunch [pending published]AcceptLoseReplyPublishLate [native live]FailUnknownAdopt [native live]Adopt [native live]Add [not stopped]Abort [epoch matches]Abort [epoch matches]Stop [absence confirmed]Stop [absence confirmed]Stop [absence confirmed]IdleAwaitReplyReadyInspectLateLatePublishedAbortedStoppedStartingFailedAdoptedINV ownership precedes launchUnknown is not absenceVerified late effect retainedINV old cleanup cannot stopToken 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 + +![Launch receipt and adoption state model](./diagrams/lifecycle-controller.svg) + +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, + unknown_process_names: Vec, +} + +impl Receipt { + 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.name.clone()), + os::Observation::Absent => (), + os::Observation::Unknown => unknown_process_names.push(state.name.clone()), + } + } + Self { + version: 1, + supervisor_pid, + outcome, + processes, + live_process_names, + unknown_process_names, + } + } } pub fn main() -> Result<()> { @@ -67,22 +92,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 } @@ -103,7 +126,8 @@ fn run(request: Launch) -> Result<()> { } } let root = Root::open(&request.root)?; - let _instance_lock = root.lock(".instance.lock")?; + let _instance_lock = instance_lock(&root)?; + os::census_preflight()?; 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. @@ -127,7 +151,10 @@ fn run(request: Launch) -> Result<()> { .env_clear() .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()); + // Keep stop admission leased even if this supervisor dies + // before the guard publishes its durable identity. fd 2 is + // reserved until guard receipt delivery/detach (or exit). + .stderr(Stdio::from(launch_lock.try_clone()?)); // SAFETY: setsid is async-signal-safe, and this closure allocates no // application state. It runs in the freshly forked child only. unsafe { @@ -154,12 +181,7 @@ fn run(request: Launch) -> Result<()> { } 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()?; @@ -182,7 +204,7 @@ fn run(request: Launch) -> Result<()> { if !running { return Ok(()); } - thread::sleep(TICK); + os::wait_activity(&[], IDLE_TICK)?; } })(); if outcome.is_err() { @@ -193,6 +215,31 @@ fn run(request: Launch) -> Result<()> { outcome } +fn instance_lock(root: &Root) -> Result { + // Preserve the uncontended fast path; a bounded retry below distinguishes + // contention from persistent file/OS failures with the actual errno. + if let Ok(file) = root.lock(".instance.lock") { + return Ok(file); + } + let file = root.file(".instance.lock", libc::O_RDWR | libc::O_CREAT)?; + let deadline = Instant::now() + Duration::from_secs(1); + loop { + // SAFETY: file remains owned throughout this bounded retry. + 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 + ) || Instant::now() >= deadline + { + return Err(error); + } + thread::sleep(TICK); + } +} + fn rollback(children: &mut [Child]) { for child in children.iter_mut() { if child.try_wait().ok().flatten().is_some() { @@ -223,7 +270,7 @@ 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))?; + let _command_lock = root.lock_wait(&format!("{}.lock", request.process.name))?; if let Some(previous) = root.load(&request.process.name)? && os::recorded_live(&previous, false)? { @@ -236,6 +283,7 @@ fn guard(request: GuardLaunch) -> Result<()> { } os::signals()?; os::subreaper()?; + os::census_preflight()?; let mut state = State { version: 1, name: request.process.name.clone(), @@ -316,10 +364,19 @@ fn guard_command( let mut killed = false; let mut direct_exited = false; let mut drained_after_exit = None; + let mut last_census = Instant::now() - IDLE_TICK; + let mut live = true; + let mut stdout_open = true; + let mut stderr_open = true; 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 stdout_open { + stdout_open = out.drain(&mut stdout, log, &state.name, "stdout")?; + } + if stderr_open { + stderr_open = err.drain(&mut stderr, log, &state.name, "stderr")?; + } + let was_exited = direct_exited; if !direct_exited && let Some(status) = child.try_wait()? { direct_exited = true; state.exit_code = status.code(); @@ -335,7 +392,14 @@ fn guard_command( state.signal = status.signal(); } } - let live = os::live_group(state.process_group_id, Some(state.guard_pid))?; + // A live direct child already proves work remains. Census is needed + // only after that child exits (descendants may remain), or during stop. + if (direct_exited && (last_census.elapsed() >= IDLE_TICK || !was_exited)) + || stopping.is_some() + { + live = os::live_group(state.process_group_id, Some(state.guard_pid))?; + last_census = Instant::now(); + } if os::STOP.swap(false, Ordering::Relaxed) && stopping.is_none() { state.phase = "stopping".into(); root.save(state)?; @@ -366,43 +430,108 @@ fn guard_command( } os::session_signal(state.guard_pid, libc::SIGKILL)?; } - thread::sleep(TICK); + let fds: Vec<_> = [ + (stdout_open, stdout.as_raw_fd()), + (stderr_open, stderr.as_raw_fd()), + ] + .into_iter() + .filter_map(|(open, fd)| open.then_some(fd)) + .collect(); + os::wait_activity( + &fds, + if stopping.is_some() || drained_after_exit.is_some() { + TICK + } else { + IDLE_TICK + }, + )?; } } fn stop(states: &[State]) -> Result<()> { + struct Target { + state: State, + guard_live: bool, + orphan_term: Option, + } let mut active = Vec::new(); - // Validate the full set before taking the first effect. + // Validate every receipt before the first effect. Absence may race a guard's + // natural exit; a dead leader still leaves a recoverable recorded session. 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()); + let guard_live = match os::inspect(state.guard_pid)? { + Some(identity) if !identity.zombie => { + os::same(state, &identity)?; + true + } + _ => false, + }; + active.push(Target { + state: state.clone(), + guard_live, + orphan_term: 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); + for target in &mut active { + let state = &target.state; + let result = if target.guard_live { + os::signal_guard(state) + } else { + let result = os::signal_recorded_session(state, libc::SIGTERM); + target.orphan_term = Some(Instant::now()); + result + }; + if let Err(error) = result { + if !os::recorded_live(state, false)? { + continue; + } + // Guard death during admission switches to verified session recovery. + match os::inspect(state.guard_pid)? { + None => { + os::signal_recorded_session(state, libc::SIGTERM)?; + target.orphan_term = Some(Instant::now()); + } + Some(identity) if identity.zombie => { + os::signal_recorded_session(state, libc::SIGTERM)?; + target.orphan_term = Some(Instant::now()); + } + _ => return Err(error), } } } - let deadline = Instant::now() + GRACE + Duration::from_secs(2); - while Instant::now() < deadline { + let start = Instant::now(); + while start.elapsed() < GRACE + Duration::from_secs(2) { let mut live = false; - for state in &active { - live |= os::recorded_live(state, false)?; + for target in &mut active { + let state = &target.state; + if !os::recorded_live(state, false)? { + continue; + } + live = true; + // Guard death after TERM admission must not skip authored TERM or + // borrow the dead guard's grace interval. Late loss may time out as + // unknown; it never shortens this recovered session's own grace. + let orphan = os::inspect(state.guard_pid)?.is_none_or(|p| p.zombie); + if orphan { + if target.orphan_term.is_none() { + os::signal_recorded_session(state, libc::SIGTERM)?; + target.orphan_term = Some(Instant::now()); + } + if target + .orphan_term + .is_some_and(|since| since.elapsed() >= GRACE) + { + os::signal_recorded_session(state, libc::SIGKILL)?; + } + } } 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()) } @@ -433,11 +562,11 @@ impl Lines { log: &mut File, name: &str, stream: &str, - ) -> Result<()> { + ) -> Result { let mut buffer = [0u8; 8192]; for _ in 0..32 { match input.read(&mut buffer) { - Ok(0) => break, + Ok(0) => return Ok(false), Ok(count) => { for byte in &buffer[..count] { if *byte == b'\n' { @@ -458,7 +587,7 @@ impl Lines { Err(error) => return Err(error), } } - Ok(()) + Ok(true) } fn flush(&mut self, log: &mut File, name: &str, stream: &str, trim_cr: bool) -> Result<()> { @@ -471,17 +600,11 @@ impl Lines { 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()); - } + os::flock_retry(log.as_raw_fd(), libc::LOCK_EX)?; 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) }; + let unlocked = os::flock_retry(log.as_raw_fd(), libc::LOCK_UN); result?; - if unlocked != 0 { - return Err(std::io::Error::last_os_error()); - } + unlocked?; self.pending.clear(); Ok(()) } diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index dd3a3aae7..b6fd702e0 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -461,6 +461,7 @@ fn zombies_do_not_hold_liveness() { wait(&mut caller, TIMEOUT).success(), "zombie-only session remained alive" ); + assert!(f.log().contains("unreaped-zombie-confirmed")); } #[test] @@ -556,6 +557,161 @@ fn child_descriptor_allowlist() { assert!(!f.log().contains("descriptor-leak")); } +#[test] +fn repeated_immediate_restart_waits_for_instance_lock_release() { + let f = Fixture::new(); + let mut previous = None; + for _ in 0..8 { + let (caller, _) = f.start(vec![f.process("worker", "hold")]); + if let Some(mut old) = previous.take() { + assert!(wait(&mut old, TIMEOUT).success()); + } + f.stop(); + previous = Some(caller); + } + assert!(wait(previous.as_mut().unwrap(), TIMEOUT).success()); +} + +#[cfg(target_os = "linux")] +#[test] +fn raised_descriptor_limit_does_not_delay_startup() { + // Exercise the largest supported soft limit without changing this host's + // hard limit. The exact million-fd qualification remains separate below. + let mut limit: libc::rlimit = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) }, + 0 + ); + descriptor_limit_startup(limit.rlim_max.min(1_048_576)); +} + +#[cfg(target_os = "linux")] +#[test] +#[ignore = "qualification requires a hard RLIMIT_NOFILE of at least 1048576"] +fn million_descriptor_limit_qualification() { + descriptor_limit_startup(1_048_576); +} + +#[cfg(target_os = "linux")] +fn descriptor_limit_startup(descriptors: libc::rlim_t) { + use std::os::unix::process::CommandExt; + let f = Fixture::new(); + let mut cmd = command(&f.root, "run"); + // SAFETY: adjusts only this freshly forked fixture child's descriptor limit. + unsafe { + cmd.pre_exec(move || { + let limit = libc::rlimit { + rlim_cur: descriptors, + rlim_max: descriptors, + }; + if libc::setrlimit(libc::RLIMIT_NOFILE, &limit) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut caller = cmd.spawn().unwrap(); + let start = Instant::now(); + send( + caller.stdin.as_mut().unwrap(), + &f.launch(vec![f.process("worker", "descriptors")]), + ); + let receipt = receive(caller.stdout.as_mut().unwrap()); + f.remember(&receipt); + assert_eq!(receipt["outcome"], "ready"); + assert!(wait(&mut caller, TIMEOUT).success()); + assert!(start.elapsed() < Duration::from_secs(3)); + assert!(f.log().contains("descriptor-allowlist-ok")); +} + +#[cfg(target_os = "linux")] +#[test] +#[ignore = "bounded idle CPU/context-switch measurement; run alone with --nocapture"] +fn idle_guard_cpu_and_wakeup_measurement() { + fn sample(pid: i32) -> (u64, u64) { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).unwrap(); + let fields: Vec<_> = stat + .rsplit_once(") ") + .unwrap() + .1 + .split_whitespace() + .collect(); + let ticks = fields[11].parse::().unwrap() + fields[12].parse::().unwrap(); + let status = fs::read_to_string(format!("/proc/{pid}/status")).unwrap(); + let switches = status + .lines() + .find_map(|line| { + line.strip_prefix("voluntary_ctxt_switches:") + .map(|v| v.trim().parse::().unwrap()) + }) + .unwrap(); + (ticks, switches) + } + let f = Fixture::new(); + let (mut caller, _) = f.start(vec![f.process("worker", "hold")]); + f.await_log("fixture-ready"); + let guard = pid(&f.state("worker"), "guardPid"); + let hz = unsafe { libc::sysconf(libc::_SC_CLK_TCK) } as f64; + for trial in 1..=3 { + let before = sample(guard); + let before_parent = sample(caller.id() as i32); + let start = Instant::now(); + thread::sleep(Duration::from_secs(5)); + let wall = start.elapsed().as_secs_f64(); + let after = sample(guard); + let after_parent = sample(caller.id() as i32); + let cpu = (after.0 - before.0) as f64 / hz; + let switches = after.1 - before.1; + eprintln!( + "trial={trial} wall_seconds={wall:.3} guard_cpu_seconds={cpu:.3} guard_voluntary_switches={switches} supervisor_cpu_seconds={:.3} supervisor_voluntary_switches={}", + (after_parent.0 - before_parent.0) as f64 / hz, + after_parent.1 - before_parent.1 + ); + assert!(cpu / wall < 0.01, "idle guard exceeded 1% of one CPU"); + assert!( + (switches as f64) / wall < 5.0, + "idle guard exceeded 5 voluntary switches/s" + ); + } + f.stop(); + assert!(wait(&mut caller, TIMEOUT).success()); +} + +#[test] +fn natural_exit_during_stop_admission_is_successful_absence() { + let f = Fixture::new(); + for _ in 0..8 { + let (mut caller, _) = f.start(vec![f.process("worker", "unicode")]); + let (mut stop, receipt) = f.request("stop", &f.control(TOKEN)); + assert!(wait(&mut stop, TIMEOUT).success()); + assert_eq!(receipt["outcome"], "stopped"); + assert!(wait(&mut caller, TIMEOUT).success()); + } +} + +#[test] +fn status_reports_kernel_liveness_separately_from_persisted_phase() { + let f = Fixture::new(); + let (mut caller, _) = f.start(vec![f.process("worker", "hold")]); + let (mut status, receipt) = f.request("status", &f.control(TOKEN)); + assert!(wait(&mut status, TIMEOUT).success()); + assert_eq!(receipt["liveProcessNames"], json!(["worker"])); + f.stop(); + assert!(wait(&mut caller, TIMEOUT).success()); + let mut state = f.state("worker"); + state["phase"] = json!("running"); + fs::write( + f.root.join("worker.json"), + serde_json::to_vec(&state).unwrap(), + ) + .unwrap(); + let (mut status, receipt) = f.request("status", &f.control(TOKEN)); + assert!(wait(&mut status, TIMEOUT).success()); + assert_eq!(receipt["processes"][0]["phase"], "running"); + assert_eq!(receipt["liveProcessNames"], json!([])); + assert_eq!(receipt["unknownProcessNames"], json!([])); +} + #[test] fn concurrent_stop_requests_settle() { let f = Fixture::new(); @@ -612,6 +768,148 @@ fn inherited_ignored_signal_is_reset() { assert!(wait(&mut caller, TIMEOUT).success()); } +#[cfg(target_os = "linux")] +#[test] +fn supervisor_and_guard_prepublication_crashes_keep_stop_admission_leased() { + use std::os::unix::fs::OpenOptionsExt; + let f = Fixture::new(); + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(f.root.join("worker.lock")) + .unwrap(); + assert_eq!(unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) }, 0); + let mut caller = command(&f.root, "run").spawn().unwrap(); + send( + caller.stdin.as_mut().unwrap(), + &f.launch(vec![ + f.process("first", "hold"), + f.process("worker", "hold"), + ]), + ); + f.await_log("fixture-ready"); + let first = f.state("first"); + f.remember(&json!({"processes":[first.clone()]})); + let guard = eventually(|| { + fs::read_to_string(format!( + "/proc/{}/task/{}/children", + caller.id(), + caller.id() + )) + .ok() + .and_then(|s| { + s.split_whitespace() + .filter_map(|v| v.parse::().ok()) + .find(|p| *p != pid(&first, "guardPid")) + }) + }); + // Wait for the guard to exec and close all inherited descriptors except + // stdio; fd 2 must still carry the stop lease while command lock is held. + eventually(|| { + fs::read_link(format!("/proc/{guard}/exe")) + .ok() + .filter(|p| { + p.file_name() + .is_some_and(|n| n == "hack-lifecycle-supervisor") + }) + }); + let lease_inode = fs::metadata(f.root.join(".stop.lock")).unwrap().ino(); + eventually(|| { + fs::metadata(format!("/proc/{guard}/fd/2")) + .ok() + .filter(|metadata| metadata.ino() == lease_inode) + }); + let guard_identity = identity(guard).unwrap(); + f.owned.borrow_mut().push(guard_identity.clone()); + caller.kill().unwrap(); + caller.wait().unwrap(); + let mut stop = command(&f.root, "stop").spawn().unwrap(); + send(stop.stdin.as_mut().unwrap(), &f.control(TOKEN)); + thread::sleep(Duration::from_millis(150)); + assert!( + stop.try_wait().unwrap().is_none(), + "stop passed an unpublished guard" + ); + assert!(!f.root.join("worker.json").exists()); + // SAFETY: captured direct guard is still blocked on our fixture lock. + assert!(identity(guard).as_ref() == Some(&guard_identity)); + assert_eq!(unsafe { libc::kill(guard, libc::SIGKILL) }, 0); + assert!(wait(&mut stop, TIMEOUT).success()); + drop(lock); + let (mut next, _) = f.start(vec![f.process("worker", "hold")]); + f.stop(); + assert!(wait(&mut next, TIMEOUT).success()); +} + +#[test] +fn supervisor_killed_while_ready_receipt_is_blocked_recovers() { + use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd}; + let f = Fixture::new(); + let (_reader, mut writer) = std::os::unix::net::UnixStream::pair().unwrap(); + writer.set_nonblocking(true).unwrap(); + // Saturate the very descriptor used for the caller's receipt. Authored + // launch can finish, but the supervisor cannot commit a ready frame. + let buffer = [0u8; 8192]; + loop { + match writer.write(&buffer) { + Ok(_) => (), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(e) => panic!("fixture receipt saturation: {e}"), + } + } + // SAFETY: ownership moves from UnixStream into OwnedFd exactly once. + let fd = unsafe { OwnedFd::from_raw_fd(writer.into_raw_fd()) }; + let mut caller = command(&f.root, "run") + .stdout(Stdio::from(fd)) + .spawn() + .unwrap(); + send( + caller.stdin.as_mut().unwrap(), + &f.launch(vec![f.process("worker", "hold")]), + ); + f.await_log("fixture-ready"); + let state = f.state("worker"); + f.remember(&json!({"processes":[state]})); + assert!(caller.try_wait().unwrap().is_none()); + caller.kill().unwrap(); + caller.wait().unwrap(); + f.stop(); +} + +#[test] +fn guard_crash_before_complete_launch_frame_starts_nothing() { + use std::os::unix::process::CommandExt; + let f = Fixture::new(); + let mut cmd = command(&f.root, "guard"); + // SAFETY: changes only the fresh child into its own session. + unsafe { + cmd.pre_exec(|| { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut guard = cmd.spawn().unwrap(); + // The frame header authorizes no launch; kill at the deterministic input + // admission boundary rather than relying on scheduler timing. + guard + .stdin + .as_mut() + .unwrap() + .write_all(&100u32.to_be_bytes()) + .unwrap(); + guard.kill().unwrap(); + guard.wait().unwrap(); + assert!(!f.root.join("worker.json").exists()); + assert!(!f.root.join("lifecycle.log").exists()); + let (mut caller, _) = f.start(vec![f.process("worker", "hold")]); + f.stop(); + assert!(wait(&mut caller, TIMEOUT).success()); +} + #[test] fn supervisor_killed_during_output_burst_is_recoverable() { let f = Fixture::new(); @@ -623,24 +921,49 @@ fn supervisor_killed_during_output_burst_is_recoverable() { } #[test] -fn dead_guard_with_live_session_refuses_duplicate_and_stop() { +fn dead_guard_with_live_session_recovers_term_then_kill() { let f = Fixture::new(); - let (mut caller, _) = f.start(vec![f.process("worker", "hold")]); + let (mut caller, _) = f.start(vec![f.process("worker", "ignore-term")]); f.await_log("fixture-ready"); let state = f.state("worker"); - // SAFETY: these PIDs came from this fixture's ready receipt only. + // SAFETY: this fixture owns the captured live guard incarnation. 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); - } + let (mut status, observed) = f.request("status", &f.control(TOKEN)); + assert!(wait(&mut status, TIMEOUT).success()); + assert_eq!(observed["liveProcessNames"], json!([])); + assert_eq!(observed["unknownProcessNames"], json!(["worker"])); + let start = Instant::now(); + let (mut stop, receipt) = f.request("stop", &f.control(TOKEN)); + assert!(wait(&mut stop, TIMEOUT).success()); + assert_eq!(receipt["outcome"], "stopped"); + assert!(start.elapsed() >= Duration::from_secs(5)); + assert_eq!(receipt["liveProcessNames"], json!([])); +} + +#[test] +fn guard_dying_during_stop_gets_a_fresh_authored_term_grace() { + let f = Fixture::new(); + let (mut caller, _) = f.start(vec![f.process("worker", "ignore-term")]); + f.await_log("fixture-ready"); + let guard = pid(&f.state("worker"), "guardPid"); + let birth = identity(guard).unwrap(); + // Suspend this owned guard so its pending TERM cannot reach the command. + assert_eq!(unsafe { libc::kill(guard, libc::SIGSTOP) }, 0); + let mut stop = command(&f.root, "stop").spawn().unwrap(); + send(stop.stdin.as_mut().unwrap(), &f.control(TOKEN)); + thread::sleep(Duration::from_millis(150)); + assert!(identity(guard).as_ref() == Some(&birth)); + let killed_at = Instant::now(); + assert_eq!(unsafe { libc::kill(guard, libc::SIGKILL) }, 0); + let receipt = receive(stop.stdout.as_mut().unwrap()); + assert!(wait(&mut stop, TIMEOUT).success()); + assert_eq!(receipt["outcome"], "stopped"); + assert!(killed_at.elapsed() >= Duration::from_secs(5)); + assert!(wait(&mut caller, TIMEOUT).success()); } #[test] @@ -995,7 +1318,33 @@ fn fixture_process() { libc::_exit(0); } } - thread::sleep(Duration::from_millis(100)); + eventually(|| { + #[cfg(target_os = "linux")] + let zombie = fs::read_to_string(format!("/proc/{child}/stat")) + .ok() + .is_some_and(|s| { + s.rsplit_once(") ") + .is_some_and(|(_, tail)| tail.starts_with("Z ")) + }); + #[cfg(target_os = "macos")] + let zombie = { + let mut info = std::mem::MaybeUninit::::zeroed(); + let size = unsafe { + libc::proc_pidinfo( + child, + libc::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + std::mem::size_of::() as i32, + ) + }; + size as usize == std::mem::size_of::() + && unsafe { info.assume_init().pbi_status == libc::SZOMB } + }; + zombie.then_some(()) + }); + println!("unreaped-zombie-confirmed {child}"); + std::io::stdout().flush().unwrap(); std::process::exit(0); } #[cfg(target_os = "linux")] diff --git a/tests/models/tla/lifecycle-supervisor/COVERAGE.md b/tests/models/tla/lifecycle-supervisor/COVERAGE.md index 5416ba319..cdb52237d 100644 --- a/tests/models/tla/lifecycle-supervisor/COVERAGE.md +++ b/tests/models/tla/lifecycle-supervisor/COVERAGE.md @@ -1,7 +1,7 @@ # 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 +start attempts. This is a finite control-flow abstraction, not a model of kernel observations or lock admission. The first artifact is a standalone Rust binary exercised by a fake caller; the CLI remains unchanged. | Action/property | Source transition | Regression boundary | @@ -9,7 +9,7 @@ standalone Rust binary exercised by a fake caller; the CLI remains unchanged. | `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 | +| `Stop`, `Kill`; `NoForeignSignal` | Model assumes identity remains valid across signaling; source performs native rechecks | 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 | @@ -23,6 +23,15 @@ 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. +`NoForeignSignal` is true by construction: both signal actions require +`identity = "match"` and preserve that value in their assignment to `unsafeSignal`. +It does not independently validate observation freshness, PID/session reuse, or the +source's effect boundary. `GuardRetainsLock` similarly checks the abstract action +assignments, not descriptor inheritance or actual flock ownership. `NoDuplicate` +checks the selected live fence and its negative control in this finite abstraction; +it does not prove lock/census correctness. The positive state count is retained +because no model actions, variables or checker registry have changed in this slice. + 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 @@ -36,3 +45,17 @@ 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. + +Native recovery added after this abstraction is deliberately not claimed as model +coverage: dead-guard TERM/grace/KILL recovery, supervisor death before guard +publication, inherited launch-admission lease, stop/exit inspection races, census +errors and restricted views, high descriptor limits, EINTR retries, and idle +poll scheduling. `GuardDeath` can reach `Orphaned`, but `Stop` requires `guard` and +cannot recover it in this model. Native regressions include +`dead_guard_with_live_session_recovers_term_then_kill`, +`supervisor_and_guard_prepublication_crashes_keep_stop_admission_leased`, +`supervisor_killed_while_ready_receipt_is_blocked_recovers`, and +`guard_crash_before_complete_launch_frame_starts_nothing`. +They qualify only their exercised boundaries on the host running them. +No observation/lock/census refinement or liveness proof is asserted here; the +shared registry and model-coverage map remain owned by the integration task. From 30b3453ff42678d0377a3fd5f470fc3cf8b80710 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:03:12 -0500 Subject: [PATCH 15/55] fix(lifecycle): finish retirement before adopting commands --- docs/lifecycle-supervisor.md | 7 +++ packages/lifecycle-supervisor/src/storage.rs | 13 ++++++ .../lifecycle-supervisor/src/supervisor.rs | 2 +- .../lifecycle-supervisor/tests/fake_caller.rs | 21 +++++++++ src/lib/project-lifecycle-supervisor.ts | 19 +++++++- tests/lifecycle-supervisor-controller.test.ts | 46 +++++++++++++++++++ .../tla/lifecycle-controller/COVERAGE.md | 6 +++ 7 files changed, 112 insertions(+), 2 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 5496dc6f1..2f23604b0 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -260,3 +260,10 @@ 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. + +Adoption requires a verified live command in the `running` phase. Commands in +`stopping` or `kill-pending` finish retirement before replacement; they are never +adopted as healthy. Native status waits for an existing launch/stop admission +lock before reading receipts, so an in-flight stop cannot expose a pre-TERM +snapshot as adoptable work. This observation creates no lock file in an unused +root and times out conservatively if admission cannot settle. diff --git a/packages/lifecycle-supervisor/src/storage.rs b/packages/lifecycle-supervisor/src/storage.rs index f2d607474..ae00c4297 100644 --- a/packages/lifecycle-supervisor/src/storage.rs +++ b/packages/lifecycle-supervisor/src/storage.rs @@ -107,6 +107,19 @@ impl Root { pub fn lock_wait(&self, name: &str) -> Result { let file = self.file(name, libc::O_RDWR | libc::O_CREAT)?; + Self::wait_for_lock(file) + } + + pub fn existing_lock_wait(&self, name: &str) -> Result> { + let file = match self.file(name, libc::O_RDWR) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + Self::wait_for_lock(file).map(Some) + } + + fn wait_for_lock(file: File) -> Result { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); loop { // SAFETY: flock only borrows the live owned descriptor. diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index acfae8647..4f52971bb 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -87,7 +87,7 @@ fn dispatch() -> Result<()> { let _stop_lock = if args[0] == "stop" { Some(root.lock_wait(".stop.lock")?) } else { - None + root.existing_lock_wait(".stop.lock")? }; let mut states = Vec::new(); for state in root.states()? { diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index b6fd702e0..5e19c27cd 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1446,3 +1446,24 @@ fn fixture_process() { } use std::os::unix::fs::DirBuilderExt; + +#[test] +fn status_waits_for_inflight_stop_before_reporting_adoptable_liveness() { + let f = Fixture::new(); + let (mut caller, _) = f.start(vec![f.process("worker", "ignore-term")]); + f.await_log("fixture-ready"); + let mut stopping = command(&f.root, "stop").spawn().unwrap(); + send(stopping.stdin.as_mut().unwrap(), &f.control(TOKEN)); + eventually(|| (f.state("worker")["phase"] == "stopping").then_some(())); + let (mut observer, receipt) = f.request("status", &f.control(TOKEN)); + assert!(receipt["liveProcessNames"].as_array().unwrap().is_empty()); + assert!( + receipt["unknownProcessNames"] + .as_array() + .unwrap() + .is_empty() + ); + assert!(wait(&mut observer, TIMEOUT).success()); + assert!(wait(&mut stopping, TIMEOUT).success()); + assert!(wait(&mut caller, TIMEOUT).success()); +} diff --git a/src/lib/project-lifecycle-supervisor.ts b/src/lib/project-lifecycle-supervisor.ts index d6e9f848a..881f87217 100644 --- a/src/lib/project-lifecycle-supervisor.ts +++ b/src/lib/project-lifecycle-supervisor.ts @@ -35,6 +35,18 @@ import { createLifecycleOwnershipToken } from "./project-lifecycle-sessions.ts"; type Options = Parameters[0]; type Controller = ReturnType; +function hasRunningCommand( + receipt: LifecycleSupervisorReceipt, + name: string +): boolean { + return ( + receipt.liveProcessNames.includes(name) && + receipt.processes.some( + (state) => state.name === name && state.phase === "running" + ) + ); +} + /** The native owner preserves the mux controller's adoption and rollback fences. */ export function createSupervisorLifecycleController( opts: Options & { readonly stopExisting: () => Promise } @@ -169,7 +181,7 @@ export function createSupervisorLifecycleController( existing.definitionHash === opts.definitionHash && existing.processes.length > 0 && existing.processes.every((process) => - status.liveProcessNames.includes(process.name) + hasRunningCommand(status, process.name) ) ); } @@ -250,6 +262,11 @@ export function createSupervisorLifecycleController( ownershipToken: token, }); if (observed.liveProcessNames.includes(process.name)) { + if (!hasRunningCommand(observed, process.name)) { + throw new Error( + "Lifecycle command is retiring; retry after shutdown completes." + ); + } accept(observed); await publish(); return; diff --git a/tests/lifecycle-supervisor-controller.test.ts b/tests/lifecycle-supervisor-controller.test.ts index 9d9ba849c..3057c59f9 100644 --- a/tests/lifecycle-supervisor-controller.test.ts +++ b/tests/lifecycle-supervisor-controller.test.ts @@ -575,3 +575,49 @@ nativeTest( }), 30_000 ); + +nativeTest( + "a command already retiring cannot be adopted as healthy", + () => + fixture(async (project) => { + const owner = controller(project); + await owner.startMany({ + processes: [ + { + name: "worker", + command: + "trap '' TERM; printf 'retirement-ready\\n'; exec sleep 60", + }, + ], + }); + await owner.finalize(); + const path = resolveLifecycleLogPath({ + ...project, + composeProject: "fixture", + }); + await eventually(async () => + (await readFile(path, "utf8")).includes("retirement-ready") + ); + const previous = (await readLifecycleState(project))[0]; + const guard = previous?.processes[0]?.guardPid; + if (!(guard && previous?.ownershipToken)) { + throw new Error("missing owned guard identity"); + } + process.kill(guard, "SIGTERM"); + await eventually(async () => { + const status = await inspectLifecycleSupervisor({ + ...project, + composeProject: "fixture", + ownershipToken: previous.ownershipToken ?? "", + }); + return status.processes.some((process) => process.phase === "stopping"); + }); + const replacement = controller(project); + await replacement.startMany({ processes: [{ name: "worker", command }] }); + await replacement.finalize(); + expect((await readLifecycleState(project))[0]?.ownershipToken).not.toBe( + previous.ownershipToken + ); + }), + 30_000 +); diff --git a/tests/models/tla/lifecycle-controller/COVERAGE.md b/tests/models/tla/lifecycle-controller/COVERAGE.md index 756ef1034..f3a3b4e09 100644 --- a/tests/models/tla/lifecycle-controller/COVERAGE.md +++ b/tests/models/tla/lifecycle-controller/COVERAGE.md @@ -65,3 +65,9 @@ implementation boundaries, covered by these real-controller regressions: 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. + +The native observation assumption includes serialization with in-flight stop +admission and a `running` phase for adoption. Real regressions are +`status_waits_for_inflight_stop_before_reporting_adoptable_liveness` and +`a command already retiring cannot be adopted as healthy`. The finite model's +atomic lock region does not simulate the phase-publication interval itself. From 1a8d027706caf22aec3c6cf2dd5d4aabc444a206 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:12:53 -0500 Subject: [PATCH 16/55] docs(lifecycle): describe verified orphan recovery and idle bounds --- docs/diagrams/lifecycle-supervisor.mmd | 5 ++- docs/diagrams/lifecycle-supervisor.svg | 4 +-- docs/lifecycle-supervisor.md | 40 +++++++++++++++++++---- packages/lifecycle-supervisor/src/main.rs | 6 ++-- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/docs/diagrams/lifecycle-supervisor.mmd b/docs/diagrams/lifecycle-supervisor.mmd index 06cb2bd80..5f7ffd0a9 100644 --- a/docs/diagrams/lifecycle-supervisor.mmd +++ b/docs/diagrams/lifecycle-supervisor.mmd @@ -12,6 +12,8 @@ stateDiagram-v2 Running --> Orphaned : GuardDeath [members live] Recoverable --> Orphaned : GuardDeath [members live] Recoverable --> Stopping : Stop [identity matches] + Orphaned --> Stopping : RecoverStop [boot and session members verified] + Orphaned --> StopUnknown : RecoverStop [identity unconfirmed] Running --> Stopping : Stop [identity matches] Running --> Exited : Reap [no live members] Stopping --> Exited : Reap [no live members] @@ -22,6 +24,7 @@ stateDiagram-v2 INV identity precedes command end note note right of Orphaned - Unknown identity refuses stop + Unknown health retains ownership + Unverified identity refuses signals Live session refuses duplicate end note diff --git a/docs/diagrams/lifecycle-supervisor.svg b/docs/diagrams/lifecycle-supervisor.svg index 32561f1ad..674e5047f 100644 --- a/docs/diagrams/lifecycle-supervisor.svg +++ b/docs/diagrams/lifecycle-supervisor.svg @@ -1,4 +1,4 @@ -Lifecycle supervisor · ownership and recoveryReserve [locks acquired]Spawn [identity durable]StartFailureSupervisorDeathRetry [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]AbsentRunningExitedRecoverableStoppingKillPendingStopUnknownStartingOrphanedINV identity precedes commandUnknown identity refuses stopLive session refuses duplicate \ No newline at end of file +]]>Lifecycle supervisor · ownership and recoveryReserve [locks acquired]Spawn [identity durable]StartFailureSupervisorDeathRetry [duplicate refused]GuardDeath [members live]GuardDeath [members live]Stop [identity matches]RecoverStop [boot andsession members verified]RecoverStop [identityunconfirmed]Stop [identity matches]Reap [no live members]Reap [no live members]Kill [grace elapsed]Reap [no live members]StopUnknown [members live]AbsentRunningExitedRecoverableStoppingStopUnknownKillPendingStartingOrphanedINV identity precedes commandUnknown health retains ownershipUnverified identity refuses signalsLive session refuses duplicate \ No newline at end of file diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index f9158d940..709620979 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -35,7 +35,8 @@ directly. A shell-authored command can explicitly use `sh -c`; the supervisor ad 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, +returns saved phases plus kernel-verified `liveProcessNames` and +`unknownProcessNames`; a saved phase alone 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 @@ -97,7 +98,7 @@ not claims that a dead or paused guard rewrote its receipt. | 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` | +| Running/recoverable | Guard itself is separately killed with live session members | Health unknown; refuse duplicate, permit verified session recovery stop | Orphaned | `dead_guard_with_live_session_recovers_term_then_kill` | | 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` | @@ -108,11 +109,13 @@ 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. +while its command survives, health becomes unknown and duplicate launch is +refused. Stop can recover the recorded session after verifying the boot, +leader identity and each remaining member's UID, birth and session. Recovery +delivers TERM individually, grants a fresh five-second grace, then KILL and +confirms absence. Unverified identity never authorizes signals or a successful +stop. Launch and stop share a bounded lock; a startup descriptor lease keeps +admission locked through guard publication even if the project owner dies. The table models process death and retry, not filesystem hardware failure, hostile same-user mutation, commands that deliberately create new sessions, or power-loss @@ -167,3 +170,26 @@ 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. + +## Resource and qualification boundaries + +A live direct child proves its guard is needed without a full-system census. +Guards wait for output or SIGCHLD; once only descendants remain, a 500 ms +fallback checks the session. Stop uses the short retirement tick. Startup closes +actual open descriptors rather than iterating the open-file limit. A known +restricted process view is refused before authored code; silently hidden +same-user processes remain unsupported. + +Linux debug-build measurements with one quiet guard and owner recorded less than +10 ms CPU accounting resolution per five-second sample and two voluntary context +switches per second, across three samples. These are neither hardware wakeup +counts nor macOS measurements. The host's hard descriptor limit was 524,288; +the exact 1,048,576 test requires a host that permits that limit. +Darwin compilation does not qualify execution, group-signal EPERM recovery or +idle cost on a real Mac. + +The 129-state TLA+ artifact is an abstract phase/ownership exploration. It does +not model kernel census observations, real flock behavior, orphan recovery, +startup descriptor leases or signal errors. Some safety predicates hold by +construction. Native regressions, rather than that model, are the evidence for +those boundaries; see the model coverage document for the reduced claims. diff --git a/packages/lifecycle-supervisor/src/main.rs b/packages/lifecycle-supervisor/src/main.rs index 41a8850f9..8799f6ed9 100644 --- a/packages/lifecycle-supervisor/src/main.rs +++ b/packages/lifecycle-supervisor/src/main.rs @@ -13,14 +13,14 @@ //! | 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 | +//! | any | guard dead and authored session live | orphaned | duplicate refused; verified session stop permitted | //! //! 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. +//! leader. Killing that guard separately makes health unknown; stop can recover +//! the recorded session only after boot, identity and member-incarnation checks. mod os; mod protocol; From 30cd31db68f6df50cc6ea8f0fd745db7169e90bf Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:26:55 -0500 Subject: [PATCH 17/55] fix(lifecycle): fence retired generations and survive helper replacement --- docs/lifecycle-supervisor.md | 12 +++++ .../lifecycle-supervisor/src/incremental.rs | 2 + packages/lifecycle-supervisor/src/storage.rs | 22 ++++++++ .../lifecycle-supervisor/src/supervisor.rs | 14 +++++- .../lifecycle-supervisor/tests/fake_caller.rs | 46 ++++++++++++----- .../lifecycle-supervisor/tests/incremental.rs | 50 ++++++++++++++++++- .../tla/lifecycle-controller/COVERAGE.md | 6 +++ 7 files changed, 138 insertions(+), 14 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index a83e6cb44..6aee48528 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -292,3 +292,15 @@ not model kernel census observations, real flock behavior, orphan recovery, startup descriptor leases or signal errors. Some safety predicates hold by construction. Native regressions, rather than that model, are the evidence for those boundaries; see the model coverage document for the reduced claims. + +Stop durably retires the owner generation before confirming teardown, even when +no command receipt exists yet. A delayed `run`, `serve` or `add` checks that fence +under the same admission lock before spawning. A new run after a completed stop +must use a fresh owner token; adoption keeps its existing token. Retirement +markers persist in the private native root and contain no command environment. +Status of retired-but-live work is unconfirmed, so it cannot be adopted. + +On Linux, guard spawning uses the kernel's executable reference under +`/proc/self/exe`, which remains usable when an installer atomically replaces the +running helper. A rebuild between the first command and a later add therefore +does not depend on the deleted executable pathname. diff --git a/packages/lifecycle-supervisor/src/incremental.rs b/packages/lifecycle-supervisor/src/incremental.rs index b7f7138d5..bca694c63 100644 --- a/packages/lifecycle-supervisor/src/incremental.rs +++ b/packages/lifecycle-supervisor/src/incremental.rs @@ -221,6 +221,7 @@ pub(crate) fn serve(request: Launch) -> Result<()> { let _instance_lock = root.lock_wait(".instance.lock")?; os::census_preflight()?; let launch_lock = root.lock_wait(".stop.lock")?; + root.assert_not_retired(&request.owner_token)?; for state in root.states()? { if os::recorded_live(&state, false)? { return Err(refused()); @@ -322,6 +323,7 @@ fn handle_add( return Err(refused()); } let _stop_lock = root.lock_wait(".stop.lock")?; + root.assert_not_retired(owner)?; let process = request.processes.into_iter().next().ok_or_else(refused)?; let states = root.states()?; let mut live = 0; diff --git a/packages/lifecycle-supervisor/src/storage.rs b/packages/lifecycle-supervisor/src/storage.rs index ae00c4297..d44854f74 100644 --- a/packages/lifecycle-supervisor/src/storage.rs +++ b/packages/lifecycle-supervisor/src/storage.rs @@ -39,6 +39,28 @@ pub struct Root { } impl Root { + fn retirement_key(token: &str) -> String { + // Stable non-authentication index. A hash collision only refuses a + // launch; it can never permit a retired generation to run again. + let hash = token.bytes().fold(0xcbf29ce484222325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3) + }); + format!(".retired-{hash:016x}") + } + + pub fn retire(&self, token: &str) -> Result<()> { + self.file(&Self::retirement_key(token), libc::O_WRONLY | libc::O_CREAT)? + .sync_all()?; + self.directory.sync_all() + } + + pub fn assert_not_retired(&self, token: &str) -> Result<()> { + match self.file(&Self::retirement_key(token), libc::O_RDONLY) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + _ => Err(refused()), + } + } + /// 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<()> { diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index 4f52971bb..d58215a68 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -101,7 +101,14 @@ fn dispatch() -> Result<()> { } } let result = if args[0] == "stop" { + root.retire(&request.owner_token)?; stop(&states) + } else if root.assert_not_retired(&request.owner_token).is_err() + && states + .iter() + .any(|state| !matches!(os::observe_state(state), os::Observation::Absent)) + { + Err(refused()) } else { Ok(()) }; @@ -159,6 +166,7 @@ fn run(request: Launch) -> Result<()> { }; os::census_preflight()?; let launch_lock = root.lock_wait(".stop.lock")?; + root.assert_not_retired(&request.owner_token)?; // 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()? { @@ -232,7 +240,11 @@ pub(crate) fn start_guard( if os::STOP.load(Ordering::Relaxed) { return Err(refused()); } - let mut command = Command::new(std::env::current_exe()?); + #[cfg(target_os = "linux")] + let executable = std::path::PathBuf::from("/proc/self/exe"); + #[cfg(not(target_os = "linux"))] + let executable = std::env::current_exe()?; + let mut command = Command::new(executable); command .arg("guard") .env_clear() diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index 5e19c27cd..e1ba3e386 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -78,6 +78,17 @@ impl Fixture { self.request("run", &self.launch(processes)) } + fn start_generation(&self, processes: Vec, token: &str) -> (Child, Value) { + let mut plan = self.launch(processes); + plan["ownerToken"] = json!(token); + self.request("run", &plan) + } + + fn stop_generation(&self, token: &str) { + let (mut stop, _) = self.request("stop", &self.control(token)); + assert!(wait(&mut stop, TIMEOUT).success()); + } + fn stop(&self) { let mut child = command(&self.root, "stop").spawn().unwrap(); send(child.stdin.as_mut().unwrap(), &self.control(TOKEN)); @@ -561,12 +572,13 @@ fn child_descriptor_allowlist() { fn repeated_immediate_restart_waits_for_instance_lock_release() { let f = Fixture::new(); let mut previous = None; - for _ in 0..8 { - let (caller, _) = f.start(vec![f.process("worker", "hold")]); + for generation in 0..8 { + let token = format!("synthetic-generation-{generation}"); + let (caller, _) = f.start_generation(vec![f.process("worker", "hold")], &token); if let Some(mut old) = previous.take() { assert!(wait(&mut old, TIMEOUT).success()); } - f.stop(); + f.stop_generation(&token); previous = Some(caller); } assert!(wait(previous.as_mut().unwrap(), TIMEOUT).success()); @@ -680,9 +692,10 @@ fn idle_guard_cpu_and_wakeup_measurement() { #[test] fn natural_exit_during_stop_admission_is_successful_absence() { let f = Fixture::new(); - for _ in 0..8 { - let (mut caller, _) = f.start(vec![f.process("worker", "unicode")]); - let (mut stop, receipt) = f.request("stop", &f.control(TOKEN)); + for generation in 0..8 { + let token = format!("synthetic-generation-{generation}"); + let (mut caller, _) = f.start_generation(vec![f.process("worker", "unicode")], &token); + let (mut stop, receipt) = f.request("stop", &f.control(&token)); assert!(wait(&mut stop, TIMEOUT).success()); assert_eq!(receipt["outcome"], "stopped"); assert!(wait(&mut caller, TIMEOUT).success()); @@ -838,8 +851,11 @@ fn supervisor_and_guard_prepublication_crashes_keep_stop_admission_leased() { assert_eq!(unsafe { libc::kill(guard, libc::SIGKILL) }, 0); assert!(wait(&mut stop, TIMEOUT).success()); drop(lock); - let (mut next, _) = f.start(vec![f.process("worker", "hold")]); - f.stop(); + let (mut next, _) = f.start_generation( + vec![f.process("worker", "hold")], + "synthetic-next-generation", + ); + f.stop_generation("synthetic-next-generation"); assert!(wait(&mut next, TIMEOUT).success()); } @@ -1044,8 +1060,11 @@ fn stale_reused_session_leader_does_not_block_fresh_launch() { serde_json::to_vec(&state).unwrap(), ) .unwrap(); - let (mut next, _) = f.start(vec![f.process("worker", "hold")]); - f.stop(); + let (mut next, _) = f.start_generation( + vec![f.process("worker", "hold")], + "synthetic-next-generation", + ); + f.stop_generation("synthetic-next-generation"); assert!(wait(&mut next, TIMEOUT).success()); assert!( foreign.0.try_wait().unwrap().is_none(), @@ -1137,8 +1156,11 @@ fn case_only_rename_accepts_terminal_receipt_with_previous_spelling() { // 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(); + let (mut next, _) = f.start_generation( + vec![f.process("worker", "hold")], + "synthetic-next-generation", + ); + f.stop_generation("synthetic-next-generation"); assert!(wait(&mut next, TIMEOUT).success()); } diff --git a/packages/lifecycle-supervisor/tests/incremental.rs b/packages/lifecycle-supervisor/tests/incremental.rs index a3baf5508..269eb457f 100644 --- a/packages/lifecycle-supervisor/tests/incremental.rs +++ b/packages/lifecycle-supervisor/tests/incremental.rs @@ -44,7 +44,13 @@ impl Fixture { } } fn command(&self, operation: &str) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_hack-lifecycle-supervisor")); + self.command_binary( + operation, + std::path::Path::new(env!("CARGO_BIN_EXE_hack-lifecycle-supervisor")), + ) + } + fn command_binary(&self, operation: &str, executable: &std::path::Path) -> Command { + let mut command = Command::new(executable); command .arg(operation) .env_clear() @@ -509,3 +515,45 @@ fn escaped_display_name_cannot_publish_unreadable_state() { assert!(!wait(&mut owner).success()); assert!(!fixture.root.join("safe-key.json").exists()); } + +#[test] +fn atomic_executable_replacement_between_serve_and_add_keeps_guard_spawn_usable() { + let mut fixture = Fixture::new(); + let installed = fixture.base.join("installed-supervisor"); + let replacement = fixture.base.join("replacement-supervisor"); + fs::copy(env!("CARGO_BIN_EXE_hack-lifecycle-supervisor"), &installed).unwrap(); + let mut owner = fixture.command_binary("serve", &installed).spawn().unwrap(); + send( + owner.stdin.as_mut().unwrap(), + &fixture.launch(fixture.process("first", "hold")), + ); + assert_eq!(receive(owner.stdout.as_mut().unwrap())["outcome"], "ready"); + fixture.owners.push(owner); + fs::copy( + env!("CARGO_BIN_EXE_hack-lifecycle-supervisor"), + &replacement, + ) + .unwrap(); + fs::rename(replacement, installed).unwrap(); + let receipt = fixture.add(fixture.process("second", "hold")); + assert_eq!(receipt["liveProcessNames"].as_array().unwrap().len(), 2); + fixture.control("stop"); +} + +#[test] +fn completed_stop_retires_a_delayed_initial_launch_before_any_authored_effect() { + let fixture = Fixture::new(); + let mut delayed = fixture.command("serve").spawn().unwrap(); + // Child exists but its launch frame has not arrived: stop must fence this + // generation even though no command receipt or guard exists yet. + let receipt = fixture.control("stop"); + assert_eq!(receipt["outcome"], "stopped"); + send( + delayed.stdin.as_mut().unwrap(), + &fixture.launch(fixture.process("late", "hold")), + ); + let refused = receive(delayed.stdout.as_mut().unwrap()); + assert_eq!(refused["outcome"], "refused"); + assert!(!wait(&mut delayed).success()); + assert!(!fixture.root.join("late.json").exists()); +} diff --git a/tests/models/tla/lifecycle-controller/COVERAGE.md b/tests/models/tla/lifecycle-controller/COVERAGE.md index f3a3b4e09..46fa76951 100644 --- a/tests/models/tla/lifecycle-controller/COVERAGE.md +++ b/tests/models/tla/lifecycle-controller/COVERAGE.md @@ -71,3 +71,9 @@ admission and a `running` phase for adoption. Real regressions are `status_waits_for_inflight_stop_before_reporting_adoptable_liveness` and `a command already retiring cannot be adopted as healthy`. The finite model's atomic lock region does not simulate the phase-publication interval itself. + +Generation retirement fences delayed initial dispatch before native admission; +this is outside the model's atomic native-launch step. The implementation test +`completed_stop_retires_a_delayed_initial_launch_before_any_authored_effect` +stops before delivering the launch frame, then proves no command receipt was +created. New generations require fresh tokens; retired markers are not pruned. From e8511bd925dc4918885b2a54bed034b67b7e4dcc Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:28:27 -0500 Subject: [PATCH 18/55] fix(lifecycle): cancel delayed launches after generation retirement --- docs/lifecycle-supervisor.md | 7 +++ packages/lifecycle-supervisor/src/storage.rs | 21 +++++++ .../lifecycle-supervisor/src/supervisor.rs | 14 ++++- .../lifecycle-supervisor/tests/fake_caller.rs | 59 +++++++++++++++---- .../tla/lifecycle-supervisor/COVERAGE.md | 8 +++ 5 files changed, 96 insertions(+), 13 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 709620979..56ea68d7f 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -193,3 +193,10 @@ not model kernel census observations, real flock behavior, orphan recovery, startup descriptor leases or signal errors. Some safety predicates hold by construction. Native regressions, rather than that model, are the evidence for those boundaries; see the model coverage document for the reduced claims. + +Stop durably retires its owner generation before confirming teardown, even if no +command receipt exists. A delayed run checks that fence under the admission lock +before spawning. New generations must use fresh owner tokens; markers persist in +the private root and contain no command environment. Retired-but-live work remains +unconfirmed. Linux guard spawning uses `/proc/self/exe`, preserving an executable +reference even after atomic helper replacement. diff --git a/packages/lifecycle-supervisor/src/storage.rs b/packages/lifecycle-supervisor/src/storage.rs index 6c28444fa..1fb23da23 100644 --- a/packages/lifecycle-supervisor/src/storage.rs +++ b/packages/lifecycle-supervisor/src/storage.rs @@ -37,6 +37,27 @@ pub struct Root { } impl Root { + fn retirement_key(token: &str) -> String { + // Stable non-authentication index. A collision can only refuse launch. + let hash = token.bytes().fold(0xcbf29ce484222325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3) + }); + format!(".retired-{hash:016x}") + } + + pub fn retire(&self, token: &str) -> Result<()> { + self.file(&Self::retirement_key(token), libc::O_WRONLY | libc::O_CREAT)? + .sync_all()?; + self.directory.sync_all() + } + + pub fn assert_not_retired(&self, token: &str) -> Result<()> { + match self.file(&Self::retirement_key(token), libc::O_RDONLY) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + _ => Err(refused()), + } + } + pub fn open(path: &Path) -> Result { let directory = OpenOptions::new() .read(true) diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index b8d1a3b8f..837f3ba68 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -86,7 +86,14 @@ pub fn main() -> Result<()> { } } let result = if args[0] == "stop" { + root.retire(&request.owner_token)?; stop(&states) + } else if root.assert_not_retired(&request.owner_token).is_err() + && states + .iter() + .any(|state| !matches!(os::observe_state(state), os::Observation::Absent)) + { + Err(refused()) } else { Ok(()) }; @@ -129,6 +136,7 @@ fn run(request: Launch) -> Result<()> { let _instance_lock = instance_lock(&root)?; os::census_preflight()?; let launch_lock = root.lock_wait(".stop.lock")?; + root.assert_not_retired(&request.owner_token)?; // 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()? { @@ -145,7 +153,11 @@ fn run(request: Launch) -> Result<()> { if os::STOP.load(Ordering::Relaxed) { return Err(refused()); } - let mut command = Command::new(std::env::current_exe()?); + #[cfg(target_os = "linux")] + let executable = std::path::PathBuf::from("/proc/self/exe"); + #[cfg(not(target_os = "linux"))] + let executable = std::env::current_exe()?; + let mut command = Command::new(executable); command .arg("guard") .env_clear() diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index b6fd702e0..0a1353a52 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -78,6 +78,17 @@ impl Fixture { self.request("run", &self.launch(processes)) } + fn start_generation(&self, processes: Vec, token: &str) -> (Child, Value) { + let mut plan = self.launch(processes); + plan["ownerToken"] = json!(token); + self.request("run", &plan) + } + + fn stop_generation(&self, token: &str) { + let (mut stop, _) = self.request("stop", &self.control(token)); + assert!(wait(&mut stop, TIMEOUT).success()); + } + fn stop(&self) { let mut child = command(&self.root, "stop").spawn().unwrap(); send(child.stdin.as_mut().unwrap(), &self.control(TOKEN)); @@ -561,12 +572,13 @@ fn child_descriptor_allowlist() { fn repeated_immediate_restart_waits_for_instance_lock_release() { let f = Fixture::new(); let mut previous = None; - for _ in 0..8 { - let (caller, _) = f.start(vec![f.process("worker", "hold")]); + for generation in 0..8 { + let token = format!("synthetic-generation-{generation}"); + let (caller, _) = f.start_generation(vec![f.process("worker", "hold")], &token); if let Some(mut old) = previous.take() { assert!(wait(&mut old, TIMEOUT).success()); } - f.stop(); + f.stop_generation(&token); previous = Some(caller); } assert!(wait(previous.as_mut().unwrap(), TIMEOUT).success()); @@ -680,9 +692,10 @@ fn idle_guard_cpu_and_wakeup_measurement() { #[test] fn natural_exit_during_stop_admission_is_successful_absence() { let f = Fixture::new(); - for _ in 0..8 { - let (mut caller, _) = f.start(vec![f.process("worker", "unicode")]); - let (mut stop, receipt) = f.request("stop", &f.control(TOKEN)); + for generation in 0..8 { + let token = format!("synthetic-generation-{generation}"); + let (mut caller, _) = f.start_generation(vec![f.process("worker", "unicode")], &token); + let (mut stop, receipt) = f.request("stop", &f.control(&token)); assert!(wait(&mut stop, TIMEOUT).success()); assert_eq!(receipt["outcome"], "stopped"); assert!(wait(&mut caller, TIMEOUT).success()); @@ -838,8 +851,11 @@ fn supervisor_and_guard_prepublication_crashes_keep_stop_admission_leased() { assert_eq!(unsafe { libc::kill(guard, libc::SIGKILL) }, 0); assert!(wait(&mut stop, TIMEOUT).success()); drop(lock); - let (mut next, _) = f.start(vec![f.process("worker", "hold")]); - f.stop(); + let (mut next, _) = f.start_generation( + vec![f.process("worker", "hold")], + "synthetic-next-generation", + ); + f.stop_generation("synthetic-next-generation"); assert!(wait(&mut next, TIMEOUT).success()); } @@ -1044,8 +1060,11 @@ fn stale_reused_session_leader_does_not_block_fresh_launch() { serde_json::to_vec(&state).unwrap(), ) .unwrap(); - let (mut next, _) = f.start(vec![f.process("worker", "hold")]); - f.stop(); + let (mut next, _) = f.start_generation( + vec![f.process("worker", "hold")], + "synthetic-next-generation", + ); + f.stop_generation("synthetic-next-generation"); assert!(wait(&mut next, TIMEOUT).success()); assert!( foreign.0.try_wait().unwrap().is_none(), @@ -1137,8 +1156,11 @@ fn case_only_rename_accepts_terminal_receipt_with_previous_spelling() { // 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(); + let (mut next, _) = f.start_generation( + vec![f.process("worker", "hold")], + "synthetic-next-generation", + ); + f.stop_generation("synthetic-next-generation"); assert!(wait(&mut next, TIMEOUT).success()); } @@ -1446,3 +1468,16 @@ fn fixture_process() { } use std::os::unix::fs::DirBuilderExt; + +#[test] +fn completed_stop_fences_a_run_whose_frame_arrives_after_retirement() { + let f = Fixture::new(); + let mut delayed = command(&f.root, "run").spawn().unwrap(); + f.stop(); + send( + delayed.stdin.as_mut().unwrap(), + &f.launch(vec![f.process("late", "hold")]), + ); + assert!(!wait(&mut delayed, TIMEOUT).success()); + assert!(!f.root.join("late.json").exists()); +} diff --git a/tests/models/tla/lifecycle-supervisor/COVERAGE.md b/tests/models/tla/lifecycle-supervisor/COVERAGE.md index cdb52237d..217adea6e 100644 --- a/tests/models/tla/lifecycle-supervisor/COVERAGE.md +++ b/tests/models/tla/lifecycle-supervisor/COVERAGE.md @@ -59,3 +59,11 @@ cannot recover it in this model. Native regressions include They qualify only their exercised boundaries on the host running them. No observation/lock/census refinement or liveness proof is asserted here; the shared registry and model-coverage map remain owned by the integration task. + +Durable owner-generation retirement is an additional implementation fence, not a +transition checked by this finite model. Stop records cancellation even before a +late launch frame arrives; run checks it while holding admission. New generations +use fresh tokens. The regression +`completed_stop_fences_a_run_whose_frame_arrives_after_retirement` proves the +late frame creates no command receipt. Linux executable replacement is qualified +by the incremental integration test in the opt-in slice, not by this model. From 047175a6c55e2f330bbe0a9dd87edfc19c905333 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:34:09 -0500 Subject: [PATCH 19/55] test(lifecycle): observe unreaped exit without Darwin session identity --- packages/lifecycle-supervisor/src/os.rs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index df436fef5..5bba81ea2 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -832,14 +832,23 @@ mod tests { 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)); - } + // Darwin getsid may return ESRCH for zombies, so inspect intentionally + // reports absence. Observe exit without reaping through the wait API + // instead of requiring a live-process identity for an exited child. + let mut event = std::mem::MaybeUninit::::zeroed(); + // SAFETY: this is our direct child, event is a writable kernel buffer, + // and WNOWAIT deliberately retains the child for the assertions below. + assert_eq!( + unsafe { + libc::waitid( + libc::P_PID, + pid as libc::id_t, + event.as_mut_ptr(), + libc::WEXITED | libc::WNOWAIT, + ) + }, + 0 + ); assert!(!live_group(pid, None).unwrap()); assert!( group_signal_result(pid, io::Error::from_raw_os_error(libc::EPERM), census).is_ok() From e2130cfd870dc7e43ee0c669feb3a634033bc568 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:39:14 -0500 Subject: [PATCH 20/55] fix(lifecycle): verify Linux process UID independently of proc ownership --- docs/lifecycle-supervisor.md | 4 ++ packages/lifecycle-supervisor/src/os.rs | 37 +++++++++++++----- .../lifecycle-supervisor/tests/fake_caller.rs | 38 +++++++++++++++++++ 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 56ea68d7f..05d3a7b83 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -200,3 +200,7 @@ before spawning. New generations must use fresh owner tokens; markers persist in the private root and contain no command environment. Retired-but-live work remains unconfirmed. Linux guard spawning uses `/proc/self/exe`, preserving an executable reference even after atomic helper replacement. + +Linux ownership checks read the process's effective UID from `/proc//status`. +They do not infer UID from proc-file ownership, which changes when a command +disables dumpability. Such a command remains recoverable after guard loss. diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index 5bba81ea2..e58393e21 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -65,7 +65,6 @@ pub struct Identity { #[cfg(target_os = "linux")] pub fn inspect(pid: i32) -> Result> { - use std::os::unix::fs::MetadataExt; let path = format!("/proc/{pid}/stat"); let bytes = match fs::read(&path) { Ok(value) => value, @@ -86,21 +85,40 @@ pub fn inspect(pid: i32) -> Result> { .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), + let uid = match linux_uid(pid)? { + Some(uid) => uid, + None => return Ok(None), }; Ok(Some(Identity { pid, group, session, start, - uid: metadata.uid(), + uid, zombie: fields.first() == Some(&"Z") || fields.first() == Some(&"X"), })) } +#[cfg(target_os = "linux")] +fn linux_uid(pid: i32) -> Result> { + let status = match fs::read(format!("/proc/{pid}/status")) { + Ok(status) => status, + Err(error) if absent_error(&error) => return Ok(None), + Err(error) => return Err(error), + }; + let line = status + .split(|byte| *byte == b'\n') + .find_map(|line| line.strip_prefix(b"Uid:")) + .ok_or_else(refused)?; + let uid = std::str::from_utf8(line) + .map_err(|_| refused())? + .split_ascii_whitespace() + .nth(1) + .and_then(|uid| uid.parse().ok()) + .ok_or_else(refused)?; + Ok(Some(uid)) +} + #[cfg(target_os = "linux")] fn proc_stat_tail(bytes: &[u8]) -> Result<&str> { // comm is arbitrary bytes and may itself contain parentheses. Only the @@ -124,11 +142,10 @@ pub fn census() -> Result> { Err(error) if matches!(error.raw_os_error(), Some(libc::EPERM) | Some(libc::EACCES)) => { - use std::os::unix::fs::MetadataExt; - match entry.metadata() { + match linux_uid(pid)? { // SAFETY: geteuid is an observation with no preconditions. - Ok(metadata) if metadata.uid() != unsafe { libc::geteuid() } => (), - Err(error) if absent_error(&error) => (), + Some(uid) if uid != unsafe { libc::geteuid() } => (), + None => (), _ => return Err(error), } } diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index 0a1353a52..aad2946f7 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1434,6 +1434,12 @@ fn fixture_process() { out.flush().unwrap(); std::process::exit(0); } + #[cfg(target_os = "linux")] + "non-dumpable" => { + // SAFETY: affects only this owned fixture's dumpability, not its UID. + assert_eq!(unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0) }, 0); + println!("non-dumpable-ready"); + } "signal-default" => { let mut disposition: libc::sigaction = unsafe { std::mem::zeroed() }; assert_eq!( @@ -1481,3 +1487,35 @@ fn completed_stop_fences_a_run_whose_frame_arrives_after_retirement() { assert!(!wait(&mut delayed, TIMEOUT).success()); assert!(!f.root.join("late.json").exists()); } + +#[cfg(target_os = "linux")] +#[test] +fn non_dumpable_command_keeps_its_process_uid_for_orphan_recovery() { + let f = Fixture::new(); + let (mut caller, _) = f.start(vec![f.process("worker", "non-dumpable")]); + f.await_log("non-dumpable-ready"); + let state = f.state("worker"); + let command = pid(&state, "commandPid"); + // On an unprivileged runner the inode owner changes to root, while the + // process UID stays ours. The recovery path must use the latter. + if unsafe { libc::geteuid() } != 0 { + assert_eq!( + fs::metadata(format!("/proc/{command}/stat")).unwrap().uid(), + 0 + ); + } + // SAFETY: this fixture owns the still-running guard recorded at launch. + assert_eq!( + unsafe { libc::kill(pid(&state, "guardPid"), libc::SIGKILL) }, + 0 + ); + f.stop(); + let _ = wait(&mut caller, TIMEOUT); + let live = fs::read_to_string(format!("/proc/{command}/stat")) + .ok() + .is_some_and(|stat| { + stat.rsplit_once(") ") + .is_some_and(|(_, fields)| !fields.starts_with("Z ") && !fields.starts_with("X ")) + }); + assert!(!live); +} From e203585a75c82362f6b4db13bf7707f7d1b4a37e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:45:16 -0500 Subject: [PATCH 21/55] test(lifecycle): confirm fixture zombies with non-reaping wait --- .../lifecycle-supervisor/tests/fake_caller.rs | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index aad2946f7..566eb2c38 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1340,31 +1340,20 @@ fn fixture_process() { libc::_exit(0); } } - eventually(|| { - #[cfg(target_os = "linux")] - let zombie = fs::read_to_string(format!("/proc/{child}/stat")) - .ok() - .is_some_and(|s| { - s.rsplit_once(") ") - .is_some_and(|(_, tail)| tail.starts_with("Z ")) - }); - #[cfg(target_os = "macos")] - let zombie = { - let mut info = std::mem::MaybeUninit::::zeroed(); - let size = unsafe { - libc::proc_pidinfo( - child, - libc::PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr().cast(), - std::mem::size_of::() as i32, - ) - }; - size as usize == std::mem::size_of::() - && unsafe { info.assume_init().pbi_status == libc::SZOMB } - }; - zombie.then_some(()) - }); + let mut event = std::mem::MaybeUninit::::zeroed(); + // SAFETY: observe only this fixture's direct child into a writable + // buffer. WNOWAIT confirms exit while deliberately retaining it. + assert_eq!( + unsafe { + libc::waitid( + libc::P_PID, + child as libc::id_t, + event.as_mut_ptr(), + libc::WEXITED | libc::WNOWAIT, + ) + }, + 0 + ); println!("unreaped-zombie-confirmed {child}"); std::io::stdout().flush().unwrap(); std::process::exit(0); From 1bccead621c19eb3a36b57fd4b094451fc2d4146 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:59:27 -0500 Subject: [PATCH 22/55] test(lifecycle): preserve frozen maintenance across supervisor builds --- tests/codex-slim-maintenance.test.ts | 30 +++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/codex-slim-maintenance.test.ts b/tests/codex-slim-maintenance.test.ts index e43d7b070..827f15177 100644 --- a/tests/codex-slim-maintenance.test.ts +++ b/tests/codex-slim-maintenance.test.ts @@ -29,6 +29,13 @@ printf '%s\\n' "$*" >> "$MAINTENANCE_TRACE" if [ "$*" = "install --frozen-lockfile" ]; then exit "$MAINTENANCE_INSTALL_EXIT" fi +if [ "$*" = "scripts/build-lifecycle-supervisor.ts" ]; then + [ "$MAINTENANCE_BUILD_EXIT" = "0" ] || exit "$MAINTENANCE_BUILD_EXIT" + mkdir -p dist + printf '#!/bin/sh\\nexit 0\\n' > dist/hack-lifecycle-supervisor + chmod 700 dist/hack-lifecycle-supervisor + exit 0 +fi printf 'unlocked install rewrote lockfile\\n' > bun.lock ` ); @@ -39,9 +46,11 @@ printf 'unlocked install rewrote lockfile\\n' > bun.lock async function maintain({ paths, installExit, + buildExit = 0, }: { readonly paths: Awaited>; readonly installExit: number; + readonly buildExit?: number; }) { const child = Bun.spawn(["/bin/bash", script, paths.project], { env: { @@ -51,6 +60,7 @@ async function maintain({ HACK_ASSETS_DIR: join(paths.root, "assets"), MAINTENANCE_TRACE: join(paths.root, "trace"), MAINTENANCE_INSTALL_EXIT: String(installExit), + MAINTENANCE_BUILD_EXIT: String(buildExit), }, stdout: "pipe", stderr: "pipe", @@ -92,7 +102,25 @@ test("successful repeat maintenance preserves the lockfile and wrapper", async ( "preserve this lockfile\n" ); expect(await readFile(join(paths.root, "trace"), "utf8")).toBe( - "install --frozen-lockfile\ninstall --frozen-lockfile\n" + "install --frozen-lockfile\nscripts/build-lifecycle-supervisor.ts\ninstall --frozen-lockfile\nscripts/build-lifecycle-supervisor.ts\n" + ); + } finally { + await rm(paths.root, { recursive: true, force: true }); + } +}); + +test("supervisor build failure preserves the frozen lock and does not install a wrapper", async () => { + const paths = await fixture(); + try { + expect( + (await maintain({ paths, installExit: 0, buildExit: 43 })).exitCode + ).toBe(43); + expect(await readFile(join(paths.project, "bun.lock"), "utf8")).toBe( + "preserve this lockfile\n" + ); + expect(await Bun.file(join(paths.installed, "hack")).exists()).toBe(false); + expect(await readFile(join(paths.root, "trace"), "utf8")).toBe( + "install --frozen-lockfile\nscripts/build-lifecycle-supervisor.ts\n" ); } finally { await rm(paths.root, { recursive: true, force: true }); From 768c3f0ab8b9a68d65ea09bc96d83f18f191fe3a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:18:26 -0500 Subject: [PATCH 23/55] test(lifecycle): canonicalize temporary homes before isolation checks --- tests/lifecycle-supervisor-controller.test.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/lifecycle-supervisor-controller.test.ts b/tests/lifecycle-supervisor-controller.test.ts index 3057c59f9..7caea59fb 100644 --- a/tests/lifecycle-supervisor-controller.test.ts +++ b/tests/lifecycle-supervisor-controller.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { @@ -40,20 +40,23 @@ 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 root = await realpath( + await mkdtemp(join(tmpdir(), "supervisor-controller-")) + ); 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"); - // 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 { + // Compare canonical fixture paths (Darwin temp paths may alias /private). + // Fail before native effects if resolution reaches the real home. + expect( + resolveLifecycleSupervisorRoot({ + ...project, + composeProject: "fixture", + }).startsWith(`${process.env.HACK_HOME}/`) + ).toBe(true); await run(project); } finally { await stopLifecycleProcessController({ From d60d4e2de23e819a92e3889692b48a4365b2e560 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:25:26 -0500 Subject: [PATCH 24/55] fix(supervisor): fence orphan recovery against session ID reuse --- docs/diagrams/lifecycle-supervisor.mmd | 4 +- docs/diagrams/lifecycle-supervisor.svg | 4 +- docs/lifecycle-supervisor.md | 37 ++- docs/reference/v5-model-coverage.md | 4 +- packages/lifecycle-supervisor/README.md | 10 + packages/lifecycle-supervisor/src/os.rs | 37 ++- packages/lifecycle-supervisor/src/storage.rs | 2 + .../lifecycle-supervisor/src/supervisor.rs | 2 + .../lifecycle-supervisor/tests/fake_caller.rs | 247 +++++++++++++++--- scripts/lib/tla-runtime-models.ts | 15 +- .../tla/lifecycle-supervisor/COVERAGE.md | 26 +- .../LifecycleSupervisor.tla | 24 +- .../tla/lifecycle-supervisor/negative.cfg | 1 + .../tla/lifecycle-supervisor/positive.cfg | 1 + .../tla/lifecycle-supervisor/sid-reuse.cfg | 5 + 15 files changed, 357 insertions(+), 62 deletions(-) create mode 100644 tests/models/tla/lifecycle-supervisor/sid-reuse.cfg diff --git a/docs/diagrams/lifecycle-supervisor.mmd b/docs/diagrams/lifecycle-supervisor.mmd index 5f7ffd0a9..1021a15f0 100644 --- a/docs/diagrams/lifecycle-supervisor.mmd +++ b/docs/diagrams/lifecycle-supervisor.mmd @@ -12,7 +12,7 @@ stateDiagram-v2 Running --> Orphaned : GuardDeath [members live] Recoverable --> Orphaned : GuardDeath [members live] Recoverable --> Stopping : Stop [identity matches] - Orphaned --> Stopping : RecoverStop [boot and session members verified] + Orphaned --> Stopping : RecoverStop [boot, old birth witness and members verified] Orphaned --> StopUnknown : RecoverStop [identity unconfirmed] Running --> Stopping : Stop [identity matches] Running --> Exited : Reap [no live members] @@ -25,6 +25,6 @@ stateDiagram-v2 end note note right of Orphaned Unknown health retains ownership - Unverified identity refuses signals + No old birth witness refuses signals Live session refuses duplicate end note diff --git a/docs/diagrams/lifecycle-supervisor.svg b/docs/diagrams/lifecycle-supervisor.svg index 674e5047f..18bff95da 100644 --- a/docs/diagrams/lifecycle-supervisor.svg +++ b/docs/diagrams/lifecycle-supervisor.svg @@ -1,4 +1,4 @@ -Lifecycle supervisor · ownership and recoveryReserve [locks acquired]Spawn [identity durable]StartFailureSupervisorDeathRetry [duplicate refused]GuardDeath [members live]GuardDeath [members live]Stop [identity matches]RecoverStop [boot andsession members verified]RecoverStop [identityunconfirmed]Stop [identity matches]Reap [no live members]Reap [no live members]Kill [grace elapsed]Reap [no live members]StopUnknown [members live]AbsentRunningExitedRecoverableStoppingStopUnknownKillPendingStartingOrphanedINV identity precedes commandUnknown health retains ownershipUnverified identity refuses signalsLive session refuses duplicate \ No newline at end of file +]]>Lifecycle supervisor · ownership and recoveryReserve [locks acquired]Spawn [identity durable]StartFailureSupervisorDeathRetry [duplicate refused]GuardDeath [members live]GuardDeath [members live]Stop [identity matches]RecoverStop [boot, old birthwitness and members verified]RecoverStop [identityunconfirmed]Stop [identity matches]Reap [no live members]Reap [no live members]Kill [grace elapsed]Reap [no live members]StopUnknown [members live]AbsentRunningExitedRecoverableStoppingStopUnknownKillPendingStartingOrphanedINV identity precedes commandUnknown health retains ownershipNo old birth witness refuses signalsLive session refuses duplicate \ No newline at end of file diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 05d3a7b83..90b5fde04 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -41,8 +41,12 @@ 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. +grants no authority over the replacement. Guard-written terminal receipts are retained as proof that the old session ended. +A missing leader plus a numeric SID is not ownership: orphan recovery requires +a same-user surviving member born no later than the recorded command birth. +Only newer members, or legacy receipts without that birth evidence, report +unknown without signals. Descendants born after the command whose birth was +recorded may therefore require manual recovery once that command exits. Replies contain `version`, `supervisorPid`, `processes`, and `outcome` (`ready`, `observed`, `stopped`, or `unknown`). A stop whose confirmation times out emits @@ -55,7 +59,7 @@ 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 +UID, command PID and start identity 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 @@ -116,6 +120,12 @@ delivers TERM individually, grants a fresh five-second grace, then KILL and confirms absence. Unverified identity never authorizes signals or a successful stop. Launch and stop share a bounded lock; a startup descriptor lease keeps admission locked through guard publication even if the project owner dies. +The guard temporarily inherits this stop-lock lease on stderr (fd 2), then closes +it by detaching stdio after publication. This descriptor is an internal ownership +lease, not a diagnostic stream; authored commands receive a separate stderr pipe. +On Darwin, processes of a different UID may be hidden by kernel inspection +permissions. Such descendants are unsupported: a readable census cannot prove +absence of a hidden different-UID member, and an ordinary owner cannot signal it. The table models process death and retry, not filesystem hardware failure, hostile same-user mutation, commands that deliberately create new sessions, or power-loss @@ -188,8 +198,8 @@ the exact 1,048,576 test requires a host that permits that limit. Darwin compilation does not qualify execution, group-signal EPERM recovery or idle cost on a real Mac. -The 129-state TLA+ artifact is an abstract phase/ownership exploration. It does -not model kernel census observations, real flock behavior, orphan recovery, +The 168-state TLA+ artifact is an abstract phase/ownership exploration. It models SID reuse and a required old-birth witness for orphan signaling. It does +not model actual kernel census observations, real flock behavior, full orphan recovery, startup descriptor leases or signal errors. Some safety predicates hold by construction. Native regressions, rather than that model, are the evidence for those boundaries; see the model coverage document for the reduced claims. @@ -204,3 +214,20 @@ reference even after atomic helper replacement. Linux ownership checks read the process's effective UID from `/proc//status`. They do not infer UID from proc-file ownership, which changes when a command disables dumpability. Such a command remains recoverable after guard loss. + +Restart policy and restart backoff belong to the CLI integration in #811, not +this binary's command ownership loop. This crate does not restart commands. + +Logs are append-only and have no automatic rotation. Stop all generations and +confirm native absence before archiving the private root/log and starting with +a fresh root. Crash-left private .tmp files can be removed only while no writer +holds the root. Retirement markers must not be pruned individually: they fence +arbitrarily delayed frames. Retire the entire quiescent root only after all old +callers and guards have exited. These files have no automatic size/count bound; +long-running installations must arrange a maintenance window and offline rotation. + +Native crash tests currently cover prepublication command-lock blocking, incomplete +guard launch frames, and blocked ready delivery. Deterministic tests at the two +smaller instruction windows (starting-state save to spawn, and spawn to running-state +save) remain unqualified; polling a receipt cannot reliably stop a process in either +window. They require a dedicated synchronization seam, not a timing-only test. diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 4649fe217..5eb379bc7 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -36,7 +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. | +| 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/reused-SID identities, arbitrary process exit and supervisor/guard loss; 168 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. | | Interactive exec write-ahead admission, crash, EOF, replacement boot and retirement | interactive-session (459 states); no-replay, EOF-retirement, failed-inspect and wrong-boot negative controls | `interactive_session.rs` atomic intent/exec records, exact stopped-boot proof, known-undispatched withdrawal, restore-before-busy-retirement and run-scoped cleanup-report tests, and lock-free observation/revalidation tests; Engine effect fences; authored owner start and SCM_RIGHTS handoff | One session, two boot identities, two possible starts. Atomic durable publication and exact identity/absence proofs are assumptions; real fsync/power loss, real owner/Engine handoff and VM loss need Mac acceptance; receipt lock timing and terminal restoration are regression-covered, not modeled. Validated exact exec exit permits retirement; it makes no descendant cleanup claim. | | Private interactive envelope, echo state and bytes still in flight on failure | interactive-envelope (15 states); restore-on-error negative control | HKEE2 launcher and host gate, Linux PTY seven-case matrix, bounded marker/ACK and coalesced-output tests | Two abstract private bytes, one handshake. PTY fixtures prove local termios ordering; Docker detach scanning, real guest wiring, same-user access and VM effects remain separate. Source/compiled Bun PTY and native-process fixtures cover lifetime/terminal boundaries, not model liveness. | | 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. | @@ -83,7 +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-supervisor](../../tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla) | 168 | 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/README.md b/packages/lifecycle-supervisor/README.md index 03b909e49..80e23a774 100644 --- a/packages/lifecycle-supervisor/README.md +++ b/packages/lifecycle-supervisor/README.md @@ -79,3 +79,13 @@ 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. + +Orphan recovery requires a surviving member whose kernel birth is no later than +the recorded command birth. A reused numeric SID alone never authorizes a signal. +Terminal receipts prove the old guard completed cleanup; uncertain legacy or +newer-member-only sessions remain unknown without effects. See +[resource and qualification boundaries](../../docs/lifecycle-supervisor.md#resource-and-qualification-boundaries) +for offline rotation, Darwin different-UID limits and remaining crash-test gaps. +The ignored idle measurement has a native Darwin variant using proc_pid_rusage +over 60 seconds; its kernel idle/interrupt wakeups differ from Linux context switches. +Descriptor-limit and functional prepublication lease tests run on both platforms. diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index e58393e21..a333cb369 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -327,20 +327,45 @@ pub fn absent_error(error: &io::Error) -> bool { || 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. +/// Terminal receipts prove the guard finished its census. With no leader, a +/// matching SID alone is insufficient: that number may belong to a later session. +/// An observed member born by the recorded command birth anchors the old session. +/// Missing legacy birth evidence or only newer members is unknown, never authority. pub fn recorded_live(state: &crate::storage::State, excluding_guard: bool) -> Result { - if state.boot_id != boot()? { + if matches!(state.phase.as_str(), "exited" | "killed" | "failed-start") + || state.boot_id != boot()? + { return Ok(false); } - if let Some(identity) = inspect(state.guard_pid)? + 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(false); } - live_group(state.guard_pid, excluding_guard.then_some(state.guard_pid)) + let members: Vec<_> = census()? + .into_iter() + .filter(|p| p.session == state.guard_pid && !p.zombie) + .collect(); + if members.is_empty() { + return Ok(false); + } + if leader.is_none_or(|p| p.zombie) + && !members.iter().any(|p| { + p.uid == state.uid + && p.start >= state.guard_start_time + && state + .command_start_time + .is_some_and(|birth| p.start <= birth) + }) + { + return Err(refused()); + } + Ok(members + .iter() + .any(|p| !excluding_guard || p.pid != state.guard_pid)) } pub enum Observation { @@ -353,7 +378,7 @@ pub enum Observation { /// 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()? { + if !recorded_live(state, true)? { return Ok(Observation::Absent); } let leader = inspect(state.guard_pid)?; diff --git a/packages/lifecycle-supervisor/src/storage.rs b/packages/lifecycle-supervisor/src/storage.rs index 1fb23da23..8a17d2514 100644 --- a/packages/lifecycle-supervisor/src/storage.rs +++ b/packages/lifecycle-supervisor/src/storage.rs @@ -22,6 +22,8 @@ pub struct State { pub boot_id: String, pub uid: u32, pub command_pid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command_start_time: Option, pub phase: String, #[serde(default)] pub exit_code: Option, diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index 837f3ba68..37859b632 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -306,6 +306,7 @@ fn guard(request: GuardLaunch) -> Result<()> { boot_id: os::boot()?, uid: identity.uid, command_pid: None, + command_start_time: None, phase: "starting".into(), exit_code: None, signal: None, @@ -362,6 +363,7 @@ fn guard_command( } }; state.command_pid = Some(child.id() as i32); + state.command_start_time = os::inspect(child.id() as i32)?.map(|identity| identity.start); state.phase = "running".into(); root.save(state)?; let mut stdout = child.stdout.take().ok_or_else(refused)?; diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index 566eb2c38..7e171cac3 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -226,6 +226,35 @@ fn census() -> Vec { .collect() } +fn parent_pid(pid: i32) -> Option { + #[cfg(target_os = "linux")] + { + let text = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + text.rsplit_once(") ")? + .1 + .split_whitespace() + .nth(1)? + .parse() + .ok() + } + #[cfg(target_os = "macos")] + { + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: live writable buffer with the exact native struct size. + let size = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + std::mem::size_of::() as i32, + ) + }; + (size as usize == std::mem::size_of::()) + .then(|| unsafe { info.assume_init() }.pbi_ppid as i32) + } +} + fn command(root: &Path, action: &str) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_hack-lifecycle-supervisor")); command @@ -413,6 +442,10 @@ fn term_ignoring_command_is_killed_after_grace() { start.elapsed() >= Duration::from_secs(5), "TERM grace was shortened" ); + assert!( + start.elapsed() < Duration::from_secs(7), + "stop exceeded its confirmation budget" + ); assert!(wait(&mut caller, TIMEOUT).success()); } @@ -584,7 +617,6 @@ fn repeated_immediate_restart_waits_for_instance_lock_release() { assert!(wait(previous.as_mut().unwrap(), TIMEOUT).success()); } -#[cfg(target_os = "linux")] #[test] fn raised_descriptor_limit_does_not_delay_startup() { // Exercise the largest supported soft limit without changing this host's @@ -597,14 +629,12 @@ fn raised_descriptor_limit_does_not_delay_startup() { descriptor_limit_startup(limit.rlim_max.min(1_048_576)); } -#[cfg(target_os = "linux")] #[test] #[ignore = "qualification requires a hard RLIMIT_NOFILE of at least 1048576"] fn million_descriptor_limit_qualification() { descriptor_limit_startup(1_048_576); } -#[cfg(target_os = "linux")] fn descriptor_limit_startup(descriptors: libc::rlim_t) { use std::os::unix::process::CommandExt; let f = Fixture::new(); @@ -751,6 +781,47 @@ fn changed_process_group_remains_in_owned_session() { assert!(wait(&mut caller, TIMEOUT).success()); } +#[cfg(target_os = "macos")] +#[test] +#[ignore = "60-second native idle CPU and kernel wakeup measurement; run alone"] +fn idle_guard_cpu_and_wakeup_measurement() { + fn sample(pid: i32) -> libc::rusage_info_v2 { + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: proc_pid_rusage's opaque output points at the complete V2 + // struct for the requested flavor, live and writable for this call. + assert_eq!( + unsafe { libc::proc_pid_rusage(pid, libc::RUSAGE_INFO_V2, info.as_mut_ptr().cast()) }, + 0 + ); + unsafe { info.assume_init() } + } + let f = Fixture::new(); + let (mut caller, _) = f.start(vec![f.process("worker", "hold")]); + f.await_log("fixture-ready"); + let guard = pid(&f.state("worker"), "guardPid"); + let before = sample(guard); + let parent_before = sample(caller.id() as i32); + let start = Instant::now(); + thread::sleep(Duration::from_secs(60)); + let after = sample(guard); + let parent_after = sample(caller.id() as i32); + for (label, before, after) in [ + ("guard", before, after), + ("owner", parent_before, parent_after), + ] { + println!( + "{label}: wall_s={:.3} cpu_ns={} idle_wakeups={} interrupt_wakeups={}", + start.elapsed().as_secs_f64(), + (after.ri_user_time - before.ri_user_time) + + (after.ri_system_time - before.ri_system_time), + after.ri_pkg_idle_wkups - before.ri_pkg_idle_wkups, + after.ri_interrupt_wkups - before.ri_interrupt_wkups + ); + } + f.stop(); + assert!(wait(&mut caller, TIMEOUT).success()); +} + #[test] fn inherited_ignored_signal_is_reset() { use std::os::unix::process::CommandExt; @@ -781,7 +852,6 @@ fn inherited_ignored_signal_is_reset() { assert!(wait(&mut caller, TIMEOUT).success()); } -#[cfg(target_os = "linux")] #[test] fn supervisor_and_guard_prepublication_crashes_keep_stop_admission_leased() { use std::os::unix::fs::OpenOptionsExt; @@ -806,34 +876,34 @@ fn supervisor_and_guard_prepublication_crashes_keep_stop_admission_leased() { let first = f.state("first"); f.remember(&json!({"processes":[first.clone()]})); let guard = eventually(|| { - fs::read_to_string(format!( - "/proc/{}/task/{}/children", - caller.id(), - caller.id() - )) - .ok() - .and_then(|s| { - s.split_whitespace() - .filter_map(|v| v.parse::().ok()) - .find(|p| *p != pid(&first, "guardPid")) - }) - }); - // Wait for the guard to exec and close all inherited descriptors except - // stdio; fd 2 must still carry the stop lease while command lock is held. - eventually(|| { - fs::read_link(format!("/proc/{guard}/exe")) - .ok() - .filter(|p| { - p.file_name() - .is_some_and(|n| n == "hack-lifecycle-supervisor") + census() + .into_iter() + .find(|p| { + p.pid != pid(&first, "guardPid") + && p.session == p.pid + && parent_pid(p.pid) == Some(caller.id() as i32) }) + .map(|p| p.pid) }); - let lease_inode = fs::metadata(f.root.join(".stop.lock")).unwrap().ino(); - eventually(|| { - fs::metadata(format!("/proc/{guard}/fd/2")) - .ok() - .filter(|metadata| metadata.ino() == lease_inode) - }); + // Linux additionally observes the exact fd-2 inode. Both platforms prove + // the lease behavior by a blocked stop after owner death below. + #[cfg(target_os = "linux")] + { + eventually(|| { + fs::read_link(format!("/proc/{guard}/exe")) + .ok() + .filter(|p| { + p.file_name() + .is_some_and(|n| n == "hack-lifecycle-supervisor") + }) + }); + let lease_inode = fs::metadata(f.root.join(".stop.lock")).unwrap().ino(); + eventually(|| { + fs::metadata(format!("/proc/{guard}/fd/2")) + .ok() + .filter(|metadata| metadata.ino() == lease_inode) + }); + } let guard_identity = identity(guard).unwrap(); f.owned.borrow_mut().push(guard_identity.clone()); caller.kill().unwrap(); @@ -1072,6 +1142,106 @@ fn stale_reused_session_leader_does_not_block_fresh_launch() { ); } +#[test] +fn dead_reused_session_cannot_authorize_signals_from_old_receipt() { + use std::os::unix::process::CommandExt; + let f = Fixture::new(); + let (mut old, _) = f.start(vec![f.process("worker", "hold")]); + f.await_log("fixture-ready"); + f.stop(); + assert!(wait(&mut old, TIMEOUT).success()); + let terminal = f.state("worker"); + // Use a real dead session leader and real surviving child. Relocate the + // old receipt's SID to that session rather than waiting for host PID wrap. + thread::sleep(Duration::from_millis(30)); + let mut launch = Command::new(std::env::current_exe().unwrap()); + launch + .args(["--exact", "fixture_process", "--nocapture"]) + .env_clear() + .env("SUPERVISOR_FIXTURE", "leave-session-child") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + unsafe { + launch.pre_exec(|| { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut leader = launch.spawn().unwrap(); + let sid = leader.id() as i32; + let mut output = String::new(); + leader + .stdout + .take() + .unwrap() + .read_to_string(&mut output) + .unwrap(); + assert!(wait(&mut leader, TIMEOUT).success()); + let child_pid: i32 = output + .lines() + .find_map(|line| { + line.strip_prefix("session-child ") + .and_then(|pid| pid.parse().ok()) + }) + .unwrap(); + let child = identity(child_pid).unwrap(); + f.owned.borrow_mut().push(child.clone()); + assert_eq!(child.session, sid); + assert!(identity(sid).is_none(), "replacement leader must be gone"); + let mut relocated = terminal.clone(); + relocated["guardPid"] = json!(sid); + relocated["processGroupId"] = json!(sid); + for phase in ["running", "starting", "exited", "killed", "failed-start"] { + relocated["phase"] = json!(phase); + fs::write( + f.root.join("worker.json"), + serde_json::to_vec(&relocated).unwrap(), + ) + .unwrap(); + let (mut status, observed) = f.request("status", &f.control(TOKEN)); + let uncertain = matches!(phase, "running" | "starting"); + assert_eq!(wait(&mut status, TIMEOUT).success(), !uncertain); + assert_eq!( + observed["unknownProcessNames"].as_array().unwrap().len(), + usize::from(uncertain) + ); + let (mut stop, receipt) = f.request("stop", &f.control(TOKEN)); + assert_eq!( + receipt["outcome"], + if uncertain { "unknown" } else { "stopped" } + ); + assert_eq!(wait(&mut stop, TIMEOUT).success(), !uncertain); + assert!( + identity(child_pid).as_ref() == Some(&child), + "unrelated session was signalled" + ); + } + // A terminal old generation must not fence an unrelated generation. + let (mut next, _) = + f.start_generation(vec![f.process("worker", "hold")], "synthetic-new-owner"); + f.stop_generation("synthetic-new-owner"); + assert!(wait(&mut next, TIMEOUT).success()); + assert!(identity(child_pid).as_ref() == Some(&child)); +} + +#[test] +fn preexisting_broad_log_is_refused_before_launch() { + use std::os::unix::fs::PermissionsExt; + let f = Fixture::new(); + fs::write(f.root.join("lifecycle.log"), b"sentinel").unwrap(); + fs::set_permissions( + f.root.join("lifecycle.log"), + fs::Permissions::from_mode(0o644), + ) + .unwrap(); + refused(&f, "run", f.launch(vec![f.process("worker", "hold")])); + assert!(!f.root.join("worker.json").exists()); + assert_eq!(fs::read(f.root.join("lifecycle.log")).unwrap(), b"sentinel"); +} + #[test] fn incomplete_frame_has_one_whole_handoff_deadline() { let f = Fixture::new(); @@ -1452,6 +1622,23 @@ fn fixture_process() { libc::signal(libc::SIGTERM, libc::SIG_IGN); } } + "leave-session-child" => { + // SAFETY: fork child uses only async-signal-safe operations and + // never returns into libtest's multithreaded runtime. + let child = unsafe { libc::fork() }; + assert!(child >= 0); + if child == 0 { + unsafe { + libc::close(1); + loop { + libc::pause(); + } + } + } + println!("session-child {child}"); + std::io::stdout().flush().unwrap(); + std::process::exit(0); + } "hold" => (), _ => std::process::exit(99), } diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 07b905dd9..56fada166 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -144,10 +144,23 @@ const contracts: readonly ModelContract[] = [ { name: "lifecycle-supervisor", module: "LifecycleSupervisor", - states: 129, + states: 168, invariant: "NoDuplicate", action: "Retry", fields: ["duplicate = TRUE", "live = TRUE", "durable = TRUE"], + additionalControls: [ + { + name: "sid-reuse", + negative: true, + invariant: "NoForeignSignal", + action: "RecoverOrphan", + fields: [ + 'identity = "sid-reused"', + "unsafeSignal = TRUE", + "guard = FALSE", + ], + }, + ], }, { name: "interactive-session", diff --git a/tests/models/tla/lifecycle-supervisor/COVERAGE.md b/tests/models/tla/lifecycle-supervisor/COVERAGE.md index 217adea6e..f4e0d382a 100644 --- a/tests/models/tla/lifecycle-supervisor/COVERAGE.md +++ b/tests/models/tla/lifecycle-supervisor/COVERAGE.md @@ -13,7 +13,7 @@ standalone Rust binary exercised by a fake caller; the CLI remains unchanged. | `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 +Positive TLC exploration: 168 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 @@ -23,14 +23,16 @@ 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. -`NoForeignSignal` is true by construction: both signal actions require -`identity = "match"` and preserve that value in their assignment to `unsafeSignal`. -It does not independently validate observation freshness, PID/session reuse, or the -source's effect boundary. `GuardRetainsLock` similarly checks the abstract action +The guard-owned Stop and Kill checks still assume matching identity. The added +orphan signal admission is exercised by a separate SID-reuse negative control. +The orphan path includes an environment SID-reuse action and requires an old-birth +witness, represented by matching identity. The sid-reuse negative control removes +that witness guard and must violate NoForeignSignal at RecoverOrphan with identity +sid-reused. Native clock units, enumeration and identity-to-effect races are omitted. `GuardRetainsLock` similarly checks the abstract action assignments, not descriptor inheritance or actual flock ownership. `NoDuplicate` checks the selected live fence and its negative control in this finite abstraction; -it does not prove lock/census correctness. The positive state count is retained -because no model actions, variables or checker registry have changed in this slice. +it does not prove lock/census correctness. The positive count now includes ReuseSessionID and RecoverOrphan. Terminal phases +have no recovery signal action. Assumptions: flock serialization, atomic/synchronized publication, reliable native identity and session census, and rechecking identity before effects. Signal delivery @@ -50,8 +52,8 @@ Native recovery added after this abstraction is deliberately not claimed as mode coverage: dead-guard TERM/grace/KILL recovery, supervisor death before guard publication, inherited launch-admission lease, stop/exit inspection races, census errors and restricted views, high descriptor limits, EINTR retries, and idle -poll scheduling. `GuardDeath` can reach `Orphaned`, but `Stop` requires `guard` and -cannot recover it in this model. Native regressions include +poll scheduling. RecoverOrphan models signal admission only, not grace, KILL timing or confirmation. +ReuseSessionID represents a later survivor after its replacement leader exits. Native regressions include `dead_guard_with_live_session_recovers_term_then_kill`, `supervisor_and_guard_prepublication_crashes_keep_stop_admission_leased`, `supervisor_killed_while_ready_receipt_is_blocked_recovers`, and @@ -67,3 +69,9 @@ use fresh tokens. The regression `completed_stop_fences_a_run_whose_frame_arrives_after_retirement` proves the late frame creates no command receipt. Linux executable replacement is qualified by the incremental integration test in the opt-in slice, not by this model. + +The real-process dead_reused_session_cannot_authorize_signals_from_old_receipt +regression creates a dead session leader and surviving child, then relocates +a stale receipt SID to reproduce reuse without forcing the host PID allocator +to wrap. It checks terminal absence, non-terminal unknown without signals, and +fresh-owner launch after a terminal receipt. diff --git a/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla b/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla index 0fc117f65..3dbcbee0f 100644 --- a/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla +++ b/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla @@ -1,6 +1,6 @@ ------------------------ MODULE LifecycleSupervisor ------------------------ EXTENDS Naturals -CONSTANT SkipLiveFence +CONSTANT SkipLiveFence, SkipOrphanFence VARIABLES phase, durable, guard, locked, live, supervisor, attempts, identity, unsafeSignal, duplicate vars == <> Kill == /\ phase = "Stopping" /\ guard /\ identity = "match" /\ phase' = "KillPending" - /\ unsafeSignal' = unsafeSignal \/ identity # "match" + /\ unsafeSignal' = (unsafeSignal \/ identity # "match") /\ UNCHANGED <> StopUnknown == /\ phase \in {"Stopping", "KillPending"} /\ live @@ -69,13 +69,27 @@ Reap == /\ ~live /\ phase \notin {"Absent", "Starting", "Exited"} /\ phase' = "Exited" /\ guard' = FALSE /\ locked' = FALSE /\ supervisor' = FALSE /\ UNCHANGED <> -Next == Reserve \/ Publish \/ Spawn \/ StartFailure \/ SupervisorDeath +\* Environment: after the old session empties, a new leader can take its +\* numeric SID, fork, and exit. The survivor has no old birth witness. +ReuseSessionID == /\ durable /\ ~guard /\ ~live + /\ identity = "match" + /\ phase \in {"Orphaned", "Exited"} + /\ identity' = "sid-reused" /\ live' = TRUE + /\ UNCHANGED <> +RecoverOrphan == /\ phase = "Orphaned" /\ ~guard /\ live /\ durable + /\ (identity = "match" \/ (SkipOrphanFence /\ identity = "sid-reused")) + /\ unsafeSignal' = (unsafeSignal \/ identity # "match") + /\ phase' = "Stopping" + /\ UNCHANGED <> +Next == ReuseSessionID \/ RecoverOrphan \/ 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"} + /\ attempts \in 0..2 /\ identity \in {"match", "foreign", "unknown", "sid-reused"} /\ \A b \in {durable, guard, locked, live, supervisor, unsafeSignal, duplicate} : b \in BOOLEAN DurableBeforeLaunch == live => durable diff --git a/tests/models/tla/lifecycle-supervisor/negative.cfg b/tests/models/tla/lifecycle-supervisor/negative.cfg index a53d6e384..1fb04f6c8 100644 --- a/tests/models/tla/lifecycle-supervisor/negative.cfg +++ b/tests/models/tla/lifecycle-supervisor/negative.cfg @@ -1,4 +1,5 @@ SPECIFICATION Spec +CONSTANT SkipOrphanFence = FALSE 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 index 58bdd8c68..b687855fe 100644 --- a/tests/models/tla/lifecycle-supervisor/positive.cfg +++ b/tests/models/tla/lifecycle-supervisor/positive.cfg @@ -1,4 +1,5 @@ SPECIFICATION Spec +CONSTANT SkipOrphanFence = FALSE CONSTANT SkipLiveFence = FALSE INVARIANTS TypeOK DurableBeforeLaunch GuardRetainsLock NoForeignSignal NoDuplicate CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/lifecycle-supervisor/sid-reuse.cfg b/tests/models/tla/lifecycle-supervisor/sid-reuse.cfg new file mode 100644 index 000000000..06c12eaa4 --- /dev/null +++ b/tests/models/tla/lifecycle-supervisor/sid-reuse.cfg @@ -0,0 +1,5 @@ +SPECIFICATION Spec +CONSTANT SkipLiveFence = FALSE +CONSTANT SkipOrphanFence = TRUE +INVARIANTS TypeOK DurableBeforeLaunch GuardRetainsLock NoForeignSignal NoDuplicate +CHECK_DEADLOCK FALSE From f13b2c85d8851a6a9d3bec5fd86418059dbec73c Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:32:38 -0500 Subject: [PATCH 25/55] test(cli): disable daemon autostart in env plan acceptance --- scripts/check-native-env-plan-cli.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index 04e189bc0..5f6e91c4c 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -298,6 +298,7 @@ async function invoke( PATH: "/usr/bin:/bin", HOME: home, HACK_LOGGER: "console", + HACK_DAEMON_AUTOSTART_DISABLE: "1", AWS_SECRET_ACCESS_KEY: sentinel, ...extraEnv, }, From 0301ffce3e77953a4f5d2980d2ce62019dab12ea Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:14:47 -0500 Subject: [PATCH 26/55] test(cli): identify unexpected env plan fixture state --- scripts/check-native-env-plan-cli.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index 5f6e91c4c..0ed1d9b9a 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -276,10 +276,14 @@ try { detachedResult.ok === false && record(detachedResult.error).code === "E_STARTUP_INCOMPLETE", "admitted detached startup returns one JSON failure envelope without readiness"); - require((await readdir(home)).length === 0 && - !(await readdir(join(checkout, ".hack"))).some( - (name) => name === ".internal" || name === ".branch" - ), "no registry or generated runtime state"); + const homeEntries = await readdir(home); + const generatedEntries = (await readdir(join(checkout, ".hack"))).filter( + (name) => name === ".internal" || name === ".branch" + ); + // Only relative names inside this synthetic fixture cross the diagnostic boundary. + require(homeEntries.length === 0 && + generatedEntries.length === + 0, `no registry or generated runtime state (fixture HOME: ${JSON.stringify(homeEntries)}; generated: ${JSON.stringify(generatedEntries)})`); process.stdout.write( "Relocated native env plan acceptance: offline validation, metadata-only output, missing overlays/refs, immutable directives, separate hashes, real linked-worktree layers, CI/slim/opt-out, unsafe-input and runtime refusal passed\n" ); From 23548e093ba9016189a0804dbe3b6ac9fa48bdb4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:07:17 -0500 Subject: [PATCH 27/55] test(supervisor): qualify both guard launch crash windows --- .github/workflows/lifecycle-supervisor.yml | 2 + docs/lifecycle-supervisor.md | 13 +-- docs/reference/v5-model-coverage.md | 2 +- packages/lifecycle-supervisor/Cargo.toml | 4 + packages/lifecycle-supervisor/README.md | 6 +- .../lifecycle-supervisor/src/supervisor.rs | 22 ++++++ .../lifecycle-supervisor/tests/fake_caller.rs | 79 +++++++++++++++++++ 7 files changed, 121 insertions(+), 7 deletions(-) diff --git a/.github/workflows/lifecycle-supervisor.yml b/.github/workflows/lifecycle-supervisor.yml index 1f2c3c185..d576c87b9 100644 --- a/.github/workflows/lifecycle-supervisor.yml +++ b/.github/workflows/lifecycle-supervisor.yml @@ -78,6 +78,8 @@ jobs: 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: Test deterministic guard crash windows (qualification build only) + run: cargo test --locked --manifest-path packages/lifecycle-supervisor/Cargo.toml --features test-hooks guard_killed_after_ -- --test-threads=1 - name: Build standalone release executable run: cargo build --locked --release --manifest-path packages/lifecycle-supervisor/Cargo.toml - uses: oven-sh/setup-bun@v1 diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 7a6c3e6b3..39b481a32 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -330,8 +330,11 @@ arbitrarily delayed frames. Retire the entire quiescent root only after all old callers and guards have exited. These files have no automatic size/count bound; long-running installations must arrange a maintenance window and offline rotation. -Native crash tests currently cover prepublication command-lock blocking, incomplete -guard launch frames, and blocked ready delivery. Deterministic tests at the two -smaller instruction windows (starting-state save to spawn, and spawn to running-state -save) remain unqualified; polling a receipt cannot reliably stop a process in either -window. They require a dedicated synchronization seam, not a timing-only test. +Native crash tests cover prepublication command-lock blocking, incomplete launch +frames, and blocked ready delivery. The opt-in integration also adds deterministic +SIGKILL tests at both smaller instruction windows: after saving starting but before +spawn, and after spawn but before saving running. Only a build with the test-hooks +feature contains these checkpoints; normal and release builds omit them. Before +spawn the session is absent; after an unrecorded spawn it is unknown and stop sends +no signal without a birth witness. CI runs the two checkpoint tests separately from +the default suite and builds the release executable without this feature. diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 060e65761..8d37d834e 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -84,7 +84,7 @@ liveness/reachability controls have their own documented assumptions. | Model | Positive distinct states | Configurations | | --- | ---: | ---: | -| [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla) | 168 | 2 | +| [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla) | 168 | 3 | | [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 | diff --git a/packages/lifecycle-supervisor/Cargo.toml b/packages/lifecycle-supervisor/Cargo.toml index 1fdb83721..fcd872576 100644 --- a/packages/lifecycle-supervisor/Cargo.toml +++ b/packages/lifecycle-supervisor/Cargo.toml @@ -5,6 +5,10 @@ edition = "2024" rust-version = "1.97.1" publish = false +[features] +# Deterministic crash checkpoints for native qualification; never enable in shipped builds. +test-hooks = [] + [dependencies] libc = "0.2" serde = { version = "1.0", features = ["derive"] } diff --git a/packages/lifecycle-supervisor/README.md b/packages/lifecycle-supervisor/README.md index 8557407d4..d10a82572 100644 --- a/packages/lifecycle-supervisor/README.md +++ b/packages/lifecycle-supervisor/README.md @@ -89,7 +89,11 @@ the recorded command birth. A reused numeric SID alone never authorizes a signal Terminal receipts prove the old guard completed cleanup; uncertain legacy or newer-member-only sessions remain unknown without effects. See [resource and qualification boundaries](../../docs/lifecycle-supervisor.md#resource-and-qualification-boundaries) -for offline rotation, Darwin different-UID limits and remaining crash-test gaps. +for offline rotation, Darwin different-UID limits and crash-window qualification. The ignored idle measurement has a native Darwin variant using proc_pid_rusage over 60 seconds; its kernel idle/interrupt wakeups differ from Linux context switches. Descriptor-limit and functional prepublication lease tests run on both platforms. + +Deterministic crash checkpoints are available only with the test-hooks feature. +Run cargo test --features test-hooks guard_killed_after_ to exercise both save/spawn +windows. Never enable this feature in shipped/default/release artifacts. diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index 5ca1c9c58..780a9dbbd 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -373,6 +373,24 @@ fn guard(request: GuardLaunch) -> Result<()> { outcome } +/// Only qualification builds can pause at these instruction boundaries. The +/// requested checkpoint is read from the explicit test process plan, never the +/// guard's ambient environment. Shipped/default builds contain no checkpoint. +#[cfg(feature = "test-hooks")] +fn test_pause(process: &protocol::Process, checkpoint: &str) { + if process + .env + .get("SUPERVISOR_TEST_CHECKPOINT") + .map(String::as_str) + == Some(checkpoint) + { + // SAFETY: affects only this guard, which the fixture owns and kills. + unsafe { + libc::raise(libc::SIGSTOP); + } + } +} + fn guard_command( process: &protocol::Process, root: &Root, @@ -380,6 +398,8 @@ fn guard_command( log: &mut File, ) -> Result<()> { let log_name = sanitize_service(state.display_name.as_deref().unwrap_or(&state.name)); + #[cfg(feature = "test-hooks")] + test_pause(process, "before-spawn"); let mut command = Command::new(&process.argv[0]); command .args(&process.argv[1..]) @@ -402,6 +422,8 @@ fn guard_command( return Err(refused()); } }; + #[cfg(feature = "test-hooks")] + test_pause(process, "after-spawn"); state.command_pid = Some(child.id() as i32); state.command_start_time = os::inspect(child.id() as i32)?.map(|identity| identity.start); state.phase = "running".into(); diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index 2267b52b0..2d959d91a 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1242,6 +1242,85 @@ fn preexisting_broad_log_is_refused_before_launch() { assert_eq!(fs::read(f.root.join("lifecycle.log")).unwrap(), b"sentinel"); } +#[cfg(feature = "test-hooks")] +fn stopped(pid: i32) -> bool { + #[cfg(target_os = "linux")] + { + fs::read_to_string(format!("/proc/{pid}/stat")) + .ok() + .and_then(|s| s.rsplit_once(") ").map(|(_, rest)| rest.starts_with('T'))) + .unwrap_or(false) + } + #[cfg(target_os = "macos")] + { + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: writable native buffer lives through this synchronous call. + let size = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + std::mem::size_of::() as i32, + ) + }; + size as usize == std::mem::size_of::() + && unsafe { info.assume_init() }.pbi_status == libc::SSTOP + } +} + +#[cfg(feature = "test-hooks")] +fn kill_guard_at_launch_checkpoint(checkpoint: &str) { + let f = Fixture::new(); + let mut process = f.process("worker", "hold"); + process["env"]["SUPERVISOR_TEST_CHECKPOINT"] = json!(checkpoint); + let mut caller = command(&f.root, "run").spawn().unwrap(); + send(caller.stdin.as_mut().unwrap(), &f.launch(vec![process])); + let state = f.state("worker"); + let guard = pid(&state, "guardPid"); + eventually(|| stopped(guard).then_some(())); + f.remember(&json!({"processes":[state.clone()]})); + assert_eq!(f.state("worker")["phase"], "starting"); + let children: Vec<_> = census() + .into_iter() + .filter(|p| parent_pid(p.pid) == Some(guard)) + .collect(); + assert_eq!(children.len(), usize::from(checkpoint == "after-spawn")); + for child in &children { + f.owned.borrow_mut().push(child.clone()); + } + // SAFETY: exact stopped guard and session belong to this fixture. + assert_eq!(unsafe { libc::kill(guard, libc::SIGKILL) }, 0); + let _ = wait(&mut caller, TIMEOUT); + let (mut stop, receipt) = f.request("stop", &f.control(TOKEN)); + if checkpoint == "before-spawn" { + assert_eq!(receipt["outcome"], "stopped"); + assert!(wait(&mut stop, TIMEOUT).success()); + } else { + // No running receipt/birth witness was published. Safety requires + // uncertainty without signals, rather than guessing session ownership. + assert_eq!(receipt["outcome"], "unknown"); + assert!(!wait(&mut stop, TIMEOUT).success()); + assert!(identity(children[0].pid).as_ref() == Some(&children[0])); + let (mut status, observed) = f.request("status", &f.control(TOKEN)); + assert!(!wait(&mut status, TIMEOUT).success()); + assert_eq!(observed["unknownProcessNames"], json!(["worker"])); + refused(&f, "run", f.launch(vec![f.process("worker", "hold")])); + } +} + +#[cfg(feature = "test-hooks")] +#[test] +fn guard_killed_after_starting_save_before_spawn_starts_nothing() { + kill_guard_at_launch_checkpoint("before-spawn"); +} + +#[cfg(feature = "test-hooks")] +#[test] +fn guard_killed_after_spawn_before_running_save_retains_unknown() { + kill_guard_at_launch_checkpoint("after-spawn"); +} + #[test] fn incomplete_frame_has_one_whole_handoff_deadline() { let f = Fixture::new(); From e2e8a30cc6de8e14005bacdabbbef13b80b17ac2 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:16:37 -0500 Subject: [PATCH 28/55] test(supervisor): await quiet command before guard crash --- .../lifecycle-supervisor/tests/fake_caller.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index 2d959d91a..7efde37e0 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1272,7 +1272,7 @@ fn stopped(pid: i32) -> bool { #[cfg(feature = "test-hooks")] fn kill_guard_at_launch_checkpoint(checkpoint: &str) { let f = Fixture::new(); - let mut process = f.process("worker", "hold"); + let mut process = f.process("worker", "hold-checkpoint"); process["env"]["SUPERVISOR_TEST_CHECKPOINT"] = json!(checkpoint); let mut caller = command(&f.root, "run").spawn().unwrap(); send(caller.stdin.as_mut().unwrap(), &f.launch(vec![process])); @@ -1289,6 +1289,17 @@ fn kill_guard_at_launch_checkpoint(checkpoint: &str) { for child in &children { f.owned.borrow_mut().push(child.clone()); } + if checkpoint == "after-spawn" { + // The paused guard cannot drain output. Wait until the command has + // finished its initial writes so killing the reader cannot race SIGPIPE. + eventually(|| { + f.root + .join("checkpoint-command-ready") + .exists() + .then_some(()) + }); + assert!(identity(children[0].pid).as_ref() == Some(&children[0])); + } // SAFETY: exact stopped guard and session belong to this fixture. assert_eq!(unsafe { libc::kill(guard, libc::SIGKILL) }, 0); let _ = wait(&mut caller, TIMEOUT); @@ -1718,11 +1729,14 @@ fn fixture_process() { std::io::stdout().flush().unwrap(); std::process::exit(0); } - "hold" => (), + "hold" | "hold-checkpoint" => (), _ => std::process::exit(99), } println!("fixture-ready"); std::io::stdout().flush().unwrap(); + if mode == "hold-checkpoint" { + fs::write("checkpoint-command-ready", b"ready").unwrap(); + } loop { thread::sleep(Duration::from_secs(1)); } From b228781ad02f3b9b246a2a16afd1ed1ad18125b7 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:55:04 -0500 Subject: [PATCH 29/55] fix(supervisor): retain recovery authority after witness exit --- docs/diagrams/lifecycle-supervisor.mmd | 5 +- docs/diagrams/lifecycle-supervisor.svg | 4 +- docs/lifecycle-supervisor.md | 49 +++++- docs/reference/v5-model-coverage.md | 4 +- packages/lifecycle-supervisor/README.md | 6 +- packages/lifecycle-supervisor/src/os.rs | 159 ++++++++++++------ .../lifecycle-supervisor/src/supervisor.rs | 82 ++++----- .../lifecycle-supervisor/tests/fake_caller.rs | 118 +++++++++++++ scripts/lib/tla-runtime-models.ts | 9 +- .../tla/lifecycle-supervisor/COVERAGE.md | 18 +- .../LifecycleSupervisor.tla | 62 ++++--- .../tla/lifecycle-supervisor/negative.cfg | 3 +- .../tla/lifecycle-supervisor/positive.cfg | 3 +- .../tla/lifecycle-supervisor/sid-reuse.cfg | 3 +- .../tla/lifecycle-supervisor/witness-loss.cfg | 6 + 15 files changed, 391 insertions(+), 140 deletions(-) create mode 100644 tests/models/tla/lifecycle-supervisor/witness-loss.cfg diff --git a/docs/diagrams/lifecycle-supervisor.mmd b/docs/diagrams/lifecycle-supervisor.mmd index 1021a15f0..c9fa975bf 100644 --- a/docs/diagrams/lifecycle-supervisor.mmd +++ b/docs/diagrams/lifecycle-supervisor.mmd @@ -12,12 +12,13 @@ stateDiagram-v2 Running --> Orphaned : GuardDeath [members live] Recoverable --> Orphaned : GuardDeath [members live] Recoverable --> Stopping : Stop [identity matches] - Orphaned --> Stopping : RecoverStop [boot, old birth witness and members verified] + Orphaned --> Stopping : RecoverStop [exact witness; capture members] Orphaned --> StopUnknown : RecoverStop [identity unconfirmed] Running --> Stopping : Stop [identity matches] Running --> Exited : Reap [no live members] Stopping --> Exited : Reap [no live members] Stopping --> KillPending : Kill [grace elapsed] + Stopping --> Stopping : WitnessExit [captured identities retained] KillPending --> Exited : Reap [no live members] KillPending --> StopUnknown : StopUnknown [members live] note right of Starting @@ -25,6 +26,6 @@ stateDiagram-v2 end note note right of Orphaned Unknown health retains ownership - No old birth witness refuses signals + No exact witness refuses capture Live session refuses duplicate end note diff --git a/docs/diagrams/lifecycle-supervisor.svg b/docs/diagrams/lifecycle-supervisor.svg index 18bff95da..91c811ee9 100644 --- a/docs/diagrams/lifecycle-supervisor.svg +++ b/docs/diagrams/lifecycle-supervisor.svg @@ -1,4 +1,4 @@ -Lifecycle supervisor · ownership and recoveryReserve [locks acquired]Spawn [identity durable]StartFailureSupervisorDeathRetry [duplicate refused]GuardDeath [members live]GuardDeath [members live]Stop [identity matches]RecoverStop [boot, old birthwitness and members verified]RecoverStop [identityunconfirmed]Stop [identity matches]Reap [no live members]Reap [no live members]Kill [grace elapsed]Reap [no live members]StopUnknown [members live]AbsentRunningExitedRecoverableStoppingStopUnknownKillPendingStartingOrphanedINV identity precedes commandUnknown health retains ownershipNo old birth witness refuses signalsLive session refuses duplicate \ No newline at end of file +]]>Lifecycle supervisor · ownership and recoveryReserve [locks acquired]Spawn [identity durable]StartFailureSupervisorDeathRetry [duplicate refused]GuardDeath [members live]GuardDeath [members live]Stop [identity matches]RecoverStop [exact witnessRecoverStop [identityunconfirmed]Stop [identity matches]Reap [no live members]Reap [no live members]Kill [grace elapsed]WitnessExit [capturedidentities retained]Reap [no live members]StopUnknown [members live]AbsentRunningExitedRecoverableStopping;capturemembers]StopUnknownKillPendingStartingOrphanedINV identity precedes commandUnknown health retains ownershipNo exact witness refuses captureLive session refuses duplicate \ No newline at end of file diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 90b5fde04..b1e066636 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -43,10 +43,9 @@ identity is a refusal, never proof that a process is absent. A different boot or leader with a different start identity proves that the old session ended; it grants no authority over the replacement. Guard-written terminal receipts are retained as proof that the old session ended. A missing leader plus a numeric SID is not ownership: orphan recovery requires -a same-user surviving member born no later than the recorded command birth. -Only newer members, or legacy receipts without that birth evidence, report -unknown without signals. Descendants born after the command whose birth was -recorded may therefore require manual recovery once that command exits. +the exact recorded command PID/birth or a matching guard, including a zombie. +Only unrecorded members, or legacy receipts without a surviving matching guard, report +unknown without signals. Descendants alone may require manual recovery when both recorded processes are gone. Replies contain `version`, `supervisorPid`, `processes`, and `outcome` (`ready`, `observed`, `stopped`, or `unknown`). A stop whose confirmation times out emits @@ -116,8 +115,10 @@ supervisor cannot launch a second copy. If a guard itself is separately killed while its command survives, health becomes unknown and duplicate launch is refused. Stop can recover the recorded session after verifying the boot, leader identity and each remaining member's UID, birth and session. Recovery -delivers TERM individually, grants a fresh five-second grace, then KILL and -confirms absence. Unverified identity never authorizes signals or a successful +delivers TERM individually, grants a fresh five-second grace, then KILL to the captured incarnations and +confirms absence. The capture remains authoritative if TERM ends the witness; +every signal still rechecks that member's PID, birth, UID and session (pidfd on Linux). +Unobserved descendants never inherit this authority and retain an unknown outcome. Unverified identity never authorizes signals or a successful stop. Launch and stop share a bounded lock; a startup descriptor lease keeps admission locked through guard publication even if the project owner dies. The guard temporarily inherits this stop-lock lease on stderr (fd 2), then closes @@ -198,7 +199,7 @@ the exact 1,048,576 test requires a host that permits that limit. Darwin compilation does not qualify execution, group-signal EPERM recovery or idle cost on a real Mac. -The 168-state TLA+ artifact is an abstract phase/ownership exploration. It models SID reuse and a required old-birth witness for orphan signaling. It does +The 322-state TLA+ artifact is an abstract phase/ownership exploration. It models SID reuse and a required old-birth witness for orphan signaling. It does not model actual kernel census observations, real flock behavior, full orphan recovery, startup descriptor leases or signal errors. Some safety predicates hold by construction. Native regressions, rather than that model, are the evidence for @@ -231,3 +232,37 @@ guard launch frames, and blocked ready delivery. Deterministic tests at the two smaller instruction windows (starting-state save to spawn, and spawn to running-state save) remain unqualified; polling a receipt cannot reliably stop a process in either window. They require a dedicated synchronization seam, not a timing-only test. + +## Manual recovery after an unknown stop + +Stop retires the owner token before validating all receipts. One unknown receipt +can therefore leave otherwise healthy guards running and block every new start +in that private root. Repeating up with a new token does not bypass the uncertainty. +This also applies when a guard dies after spawn but before publishing running: +the receipt has no command birth evidence, so an unanchored session is unknown. + +1. Keep the private root and all receipts. Run status with the recorded token; + use the reported guard/command PIDs and session as leads for inspection, never + as authority to kill. Inspect current processes with the OS process tools + (ps on macOS; ps and /proc on Linux), including PID, parent PID, SID, UID, + start time and command. Identify the actual application processes yourself. +2. For each process you confirm belongs to this failed launch, record its current + start identity. Send TERM to those individual PIDs, wait at least five seconds, + and recheck each identity before sending KILL to any remaining process. Never + signal a numeric process group/session from the stale receipt alone. Leave + unfamiliar or inaccessible processes untouched and seek operator help. +3. Retry stop with the original token (or hack down through the same persisted + project owner). Once the uncertain session is absent, normal stop can retire + the remaining healthy guards. If another receipt is unknown, inspect it in the + same way. Confirm stopped/status absence before a fresh up with a fresh token. +4. Do not delete receipts, locks, retirement markers or the root to unblock up. + That discards the ownership evidence without stopping anything. Offline root + rotation is allowed only after all original callers/guards and authored work + have been independently confirmed gone. + +Darwin start timestamps come from the wall clock and can decrease between guard +and command creation. Recovery therefore never orders those timestamps: it matches +PID and the recorded birth exactly. A backward step between those starts does not +invalidate a still-matching command witness. The native identity contract still +assumes a PID and exact recorded birth value identify one incarnation; Darwin has +no pidfd, so its short inspection-to-signal race remains as documented above. diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 59e008b65..737dca745 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -36,7 +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/reused-SID identities, arbitrary process exit and supervisor/guard loss; 168 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. | +| 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/reused-SID identities, arbitrary process exit and supervisor/guard loss; 322 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. | | Interactive exec write-ahead admission, crash, EOF, replacement boot and retirement | interactive-session (459 states); no-replay, EOF-retirement, failed-inspect and wrong-boot negative controls | `interactive_session.rs` atomic intent/exec records, exact stopped-boot proof, known-undispatched withdrawal, restore-before-busy-retirement and run-scoped cleanup-report tests, and lock-free observation/revalidation tests; Engine effect fences; authored owner start and SCM_RIGHTS handoff | One session, two boot identities, two possible starts. Atomic durable publication and exact identity/absence proofs are assumptions; real fsync/power loss, real owner/Engine handoff and VM loss need Mac acceptance; receipt lock timing and terminal restoration are regression-covered, not modeled. Validated exact exec exit permits retirement; it makes no descendant cleanup claim. | | Private interactive envelope, echo state and bytes still in flight on failure | interactive-envelope (15 states); restore-on-error negative control | HKEE2 launcher and host gate, Linux PTY seven-case matrix, bounded marker/ACK and coalesced-output tests | Two abstract private bytes, one handshake. PTY fixtures prove local termios ordering; Docker detach scanning, real guest wiring, same-user access and VM effects remain separate. Source/compiled Bun PTY and native-process fixtures cover lifetime/terminal boundaries, not model liveness. | | 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. | @@ -83,7 +83,7 @@ liveness/reachability controls have their own documented assumptions. | Model | Positive distinct states | Configurations | | --- | ---: | ---: | -| [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla) | 168 | 2 | +| [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla) | 322 | 4 | | [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/README.md b/packages/lifecycle-supervisor/README.md index 80e23a774..138cbab5f 100644 --- a/packages/lifecycle-supervisor/README.md +++ b/packages/lifecycle-supervisor/README.md @@ -80,8 +80,10 @@ The ignored `million_descriptor_limit_qualification` fixture requires a host har 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. -Orphan recovery requires a surviving member whose kernel birth is no later than -the recorded command birth. A reused numeric SID alone never authorizes a signal. +Orphan recovery requires an exact command PID/birth or matching guard, including +a zombie. It captures verified members before TERM and retains those incarnations +through KILL even after the witness exits. Darwin birth timestamps are matched, +never ordered across processes. A reused numeric SID alone never authorizes a signal. Terminal receipts prove the old guard completed cleanup; uncertain legacy or newer-member-only sessions remain unknown without effects. See [resource and qualification boundaries](../../docs/lifecycle-supervisor.md#resource-and-qualification-boundaries) diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index a333cb369..fec64c157 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -190,7 +190,12 @@ pub fn inspect(pid: i32) -> Result> { // 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) }; + let mut session = unsafe { libc::getsid(pid) }; + if session < 0 && info.pbi_status == libc::SZOMB && info.pbi_pgid as i32 == pid { + // Darwin may hide getsid for zombies. A matching recorded leader's + // immutable PID/birth/UID still proves it has not been reused. + session = pid; + } if session < 0 { let error = io::Error::last_os_error(); if error.raw_os_error() == Some(libc::ESRCH) { @@ -327,43 +332,62 @@ pub fn absent_error(error: &io::Error) -> bool { || matches!(error.raw_os_error(), Some(libc::ESRCH) | Some(libc::ENOENT)) } -/// Terminal receipts prove the guard finished its census. With no leader, a -/// matching SID alone is insufficient: that number may belong to a later session. -/// An observed member born by the recorded command birth anchors the old session. -/// Missing legacy birth evidence or only newer members is unknown, never authority. -pub fn recorded_live(state: &crate::storage::State, excluding_guard: bool) -> Result { - if matches!(state.phase.as_str(), "exited" | "killed" | "failed-start") - || state.boot_id != boot()? - { - return Ok(false); - } - 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(false); - } - let members: Vec<_> = census()? - .into_iter() - .filter(|p| p.session == state.guard_pid && !p.zombie) - .collect(); - if members.is_empty() { - return Ok(false); - } - if leader.is_none_or(|p| p.zombie) - && !members.iter().any(|p| { - p.uid == state.uid - && p.start >= state.guard_start_time - && state - .command_start_time - .is_some_and(|birth| p.start <= birth) - }) - { - return Err(refused()); +fn incarnation_matches(expected: &Identity, current: &Identity) -> bool { + expected.pid == current.pid + && expected.start == current.start + && expected.uid == current.uid + && expected.session == current.session +} + +/// Return one census with session identity proved across that census. A matching +/// leader (including zombie) or exact recorded command birth is the witness. +/// No ordering comparison uses Darwin's adjustable wall-clock start timestamps. +fn recorded_members(state: &crate::storage::State) -> Result> { + for _ in 0..2 { + if matches!(state.phase.as_str(), "exited" | "killed" | "failed-start") + || state.boot_id != boot()? + { + return Ok(Vec::new()); + } + let leader = inspect(state.guard_pid)?; + if let Some(p) = &leader + && (p.start != state.guard_start_time + || p.uid != state.uid + || p.session != state.guard_pid) + { + return Ok(Vec::new()); + } + let members: Vec<_> = census()? + .into_iter() + .filter(|p| p.session == state.guard_pid && !p.zombie) + .collect(); + if members.is_empty() { + return Ok(members); + } + let witness = leader + .as_ref() + .or_else(|| { + members.iter().find(|p| { + Some(p.pid) == state.command_pid + && Some(p.start) == state.command_start_time + && p.uid == state.uid + }) + }) + .ok_or_else(refused)?; + // Recheck after enumeration: the witness held the SID throughout the census. + if !inspect(witness.pid)? + .as_ref() + .is_some_and(|p| incarnation_matches(witness, p)) + { + continue; + } + return Ok(members); } - Ok(members + Err(refused()) +} + +pub fn recorded_live(state: &crate::storage::State, excluding_guard: bool) -> Result { + Ok(recorded_members(state)? .iter() .any(|p| !excluding_guard || p.pid != state.guard_pid)) } @@ -441,7 +465,8 @@ fn signal_member(expected: &Identity, signal: i32) -> Result<()> { let Some(current) = inspect(expected.pid)? else { return Ok(()); }; - if current.start != expected.start + if current.zombie + || current.start != expected.start || current.session != expected.session || current.uid != expected.uid { @@ -470,7 +495,8 @@ fn signal_member(expected: &Identity, signal: i32) -> Result<()> { let Some(current) = inspect(expected.pid)? else { return Ok(()); }; - if current.start != expected.start + if current.zombie + || current.start != expected.start || current.session != expected.session || current.uid != expected.uid { @@ -690,26 +716,49 @@ pub fn wait_activity(fds: &[i32], timeout: std::time::Duration) -> Result<()> { 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 { +/// In-memory authority captured while a witness holds the old SID. It survives +/// witness exit but never grants authority over a later, unobserved incarnation. +/// A new stop invocation must establish its own witness again. +pub struct Recovery { + session: i32, + members: Vec, +} + +impl Recovery { + pub fn capture(state: &crate::storage::State) -> Result { + let members: Vec<_> = recorded_members(state)? + .into_iter() + .filter(|p| p.pid != state.guard_pid) + .collect(); + if members.iter().any(|p| p.uid != state.uid) { return Err(refused()); } - if !recorded_live(state, false)? { - return Ok(()); + Ok(Self { + session: state.guard_pid, + members, + }) + } + + pub fn signal(&self, signal: i32) -> Result<()> { + for member in &self.members { + signal_member(member, signal)?; } - signal_member(&member, signal)?; + Ok(()) + } + + pub fn live(&self) -> Result { + for member in &self.members { + if inspect(member.pid)?.is_some_and(|p| !p.zombie && incarnation_matches(member, &p)) { + return Ok(true); + } + } + // Unobserved descendants or an unrelated reused session cannot be + // signalled by this snapshot, and must not be claimed as stopped. + if live_group(self.session, Some(self.session))? { + return Err(refused()); + } + Ok(false) } - Ok(()) } /// A successful census must contain our own readable identity. Known restricted diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index 37859b632..589f6eb8c 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -80,8 +80,8 @@ pub fn main() -> Result<()> { 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. + // Guard-written terminal phases prove completed retirement; + // non-terminal receipts require native identity evidence. return Err(refused()); } } @@ -465,12 +465,20 @@ fn guard_command( fn stop(states: &[State]) -> Result<()> { struct Target { state: State, - guard_live: bool, + recovery: Option, orphan_term: Option, } + impl Target { + fn recover(&mut self) -> Result<()> { + let recovery = os::Recovery::capture(&self.state)?; + recovery.signal(libc::SIGTERM)?; + self.orphan_term = Some(Instant::now()); + self.recovery = Some(recovery); + Ok(()) + } + } let mut active = Vec::new(); - // Validate every receipt before the first effect. Absence may race a guard's - // natural exit; a dead leader still leaves a recoverable recorded session. + // Validate all receipts and capture orphan members before the first effect. for state in states { if !os::recorded_live(state, false)? { continue; @@ -484,34 +492,23 @@ fn stop(states: &[State]) -> Result<()> { }; active.push(Target { state: state.clone(), - guard_live, + recovery: if guard_live { + None + } else { + Some(os::Recovery::capture(state)?) + }, orphan_term: None, }); } for target in &mut active { - let state = &target.state; - let result = if target.guard_live { - os::signal_guard(state) - } else { - let result = os::signal_recorded_session(state, libc::SIGTERM); + if let Some(recovery) = &target.recovery { + recovery.signal(libc::SIGTERM)?; target.orphan_term = Some(Instant::now()); - result - }; - if let Err(error) = result { - if !os::recorded_live(state, false)? { - continue; - } - // Guard death during admission switches to verified session recovery. - match os::inspect(state.guard_pid)? { - None => { - os::signal_recorded_session(state, libc::SIGTERM)?; - target.orphan_term = Some(Instant::now()); - } - Some(identity) if identity.zombie => { - os::signal_recorded_session(state, libc::SIGTERM)?; - target.orphan_term = Some(Instant::now()); - } - _ => return Err(error), + } else if let Err(error) = os::signal_guard(&target.state) { + if os::inspect(target.state.guard_pid)?.is_none_or(|p| p.zombie) { + target.recover()?; + } else if os::recorded_live(&target.state, false)? { + return Err(error); } } } @@ -519,25 +516,28 @@ fn stop(states: &[State]) -> Result<()> { while start.elapsed() < GRACE + Duration::from_secs(2) { let mut live = false; for target in &mut active { - let state = &target.state; - if !os::recorded_live(state, false)? { - continue; + if target.recovery.is_none() { + if !os::recorded_live(&target.state, false)? { + continue; + } + if os::inspect(target.state.guard_pid)?.is_none_or(|p| p.zombie) { + // Guard loss gets its own snapshot and full TERM grace. + target.recover()?; + } else { + live = true; + continue; + } } - live = true; - // Guard death after TERM admission must not skip authored TERM or - // borrow the dead guard's grace interval. Late loss may time out as - // unknown; it never shortens this recovered session's own grace. - let orphan = os::inspect(state.guard_pid)?.is_none_or(|p| p.zombie); - if orphan { - if target.orphan_term.is_none() { - os::signal_recorded_session(state, libc::SIGTERM)?; - target.orphan_term = Some(Instant::now()); + if let Some(recovery) = &target.recovery { + if !recovery.live()? { + continue; } + live = true; if target .orphan_term .is_some_and(|since| since.elapsed() >= GRACE) { - os::signal_recorded_session(state, libc::SIGKILL)?; + recovery.signal(libc::SIGKILL)?; } } } diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index 7e171cac3..cccd2e75c 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1030,6 +1030,97 @@ fn dead_guard_with_live_session_recovers_term_then_kill() { assert_eq!(receipt["liveProcessNames"], json!([])); } +#[test] +fn orphan_recovery_keeps_observed_child_after_term_ends_witness() { + let f = Fixture::new(); + let (mut caller, _) = f.start(vec![f.process("worker", "wrapper-term-child")]); + f.await_log("fixture-ready"); + eventually(|| f.root.join("wrapper-child-ready").exists().then_some(())); + let state = f.state("worker"); + let child = eventually(|| { + f.log().lines().find_map(|line| { + line.split("wrapper-child ") + .nth(1) + .and_then(|pid| pid.parse::().ok()) + .and_then(identity) + }) + }); + f.owned.borrow_mut().push(child.clone()); + assert!(child.birth > state["commandStartTime"].as_u64().unwrap()); + // SAFETY: captured guard belongs to this fixture; command/wrapper survives. + assert_eq!( + unsafe { libc::kill(pid(&state, "guardPid"), libc::SIGKILL) }, + 0 + ); + assert!(wait(&mut caller, TIMEOUT).success()); + let start = Instant::now(); + let (mut stop, receipt) = f.request("stop", &f.control(TOKEN)); + assert!(wait(&mut stop, TIMEOUT).success()); + assert_eq!(receipt["outcome"], "stopped"); + assert!( + start.elapsed() >= Duration::from_secs(5), + "child did not get a full TERM grace" + ); + assert!(start.elapsed() < Duration::from_secs(7)); + // A zombie is harmless and may await the host's subreaper; no live member remains. + assert!( + census() + .into_iter() + .all(|p| p.session != pid(&state, "guardPid") || process_is_zombie(p.pid)) + ); +} + +fn process_is_zombie(pid: i32) -> bool { + #[cfg(target_os = "linux")] + { + fs::read_to_string(format!("/proc/{pid}/stat")) + .ok() + .and_then(|s| s.rsplit_once(") ").map(|(_, rest)| rest.starts_with('Z'))) + .unwrap_or(true) + } + #[cfg(target_os = "macos")] + { + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: native output buffer is writable and exactly sized. + let size = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + std::mem::size_of::() as i32, + ) + }; + size == 0 + || (size as usize == std::mem::size_of::() + && unsafe { info.assume_init() }.pbi_status == libc::SZOMB) + } +} + +#[test] +fn unreaped_matching_guard_proves_legacy_session_ownership() { + 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"); + // Keep the parent from reaping its guard, then exercise legacy birth absence. + assert_eq!(unsafe { libc::kill(caller.id() as i32, libc::SIGSTOP) }, 0); + assert_eq!( + unsafe { libc::kill(pid(&state, "guardPid"), libc::SIGKILL) }, + 0 + ); + eventually(|| process_is_zombie(pid(&state, "guardPid")).then_some(())); + state["commandStartTime"] = Value::Null; + fs::write( + f.root.join("worker.json"), + serde_json::to_vec(&state).unwrap(), + ) + .unwrap(); + f.stop(); + assert_eq!(unsafe { libc::kill(caller.id() as i32, libc::SIGCONT) }, 0); + assert!(wait(&mut caller, TIMEOUT).success()); +} + #[test] fn guard_dying_during_stop_gets_a_fresh_authored_term_grace() { let f = Fixture::new(); @@ -1639,6 +1730,33 @@ fn fixture_process() { std::io::stdout().flush().unwrap(); std::process::exit(0); } + "wrapper-term-child" => { + // Make the child newer than the command even with Linux clock-tick + // resolution, so it cannot accidentally satisfy the old birth bound. + thread::sleep(Duration::from_millis(100)); + // SAFETY: fork child invokes only libc operations and never returns + // into Rust/libtest's inherited multithreaded allocator state. + let child = unsafe { libc::fork() }; + assert!(child >= 0); + if child == 0 { + unsafe { + libc::signal(libc::SIGTERM, libc::SIG_IGN); + let fd = libc::open( + c"wrapper-child-ready".as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL, + 0o600, + ); + if fd < 0 { + libc::_exit(98); + } + libc::close(fd); + loop { + libc::pause(); + } + } + } + println!("wrapper-child {child}"); + } "hold" => (), _ => std::process::exit(99), } diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 56fada166..97c17b6ff 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -144,7 +144,7 @@ const contracts: readonly ModelContract[] = [ { name: "lifecycle-supervisor", module: "LifecycleSupervisor", - states: 168, + states: 322, invariant: "NoDuplicate", action: "Retry", fields: ["duplicate = TRUE", "live = TRUE", "durable = TRUE"], @@ -160,6 +160,13 @@ const contracts: readonly ModelContract[] = [ "guard = FALSE", ], }, + { + name: "witness-loss", + negative: true, + invariant: "RecoveryAuthorityRetained", + action: "WitnessExit", + fields: ["snapshot = FALSE", "witness = FALSE", "live = TRUE"], + }, ], }, { diff --git a/tests/models/tla/lifecycle-supervisor/COVERAGE.md b/tests/models/tla/lifecycle-supervisor/COVERAGE.md index f4e0d382a..b5af7fb7e 100644 --- a/tests/models/tla/lifecycle-supervisor/COVERAGE.md +++ b/tests/models/tla/lifecycle-supervisor/COVERAGE.md @@ -13,7 +13,7 @@ standalone Rust binary exercised by a fake caller; the CLI remains unchanged. | `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: 168 distinct states, queue exhausted, depth 11. Negative +Positive TLC exploration: 322 distinct states, queue exhausted, depth 14. 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 @@ -25,7 +25,7 @@ validates the negative invariant/action/witness. Apalache is not qualified here. The guard-owned Stop and Kill checks still assume matching identity. The added orphan signal admission is exercised by a separate SID-reuse negative control. -The orphan path includes an environment SID-reuse action and requires an old-birth +The orphan path includes an environment SID-reuse action and requires an exact recorded incarnation witness, represented by matching identity. The sid-reuse negative control removes that witness guard and must violate NoForeignSignal at RecoverOrphan with identity sid-reused. Native clock units, enumeration and identity-to-effect races are omitted. `GuardRetainsLock` similarly checks the abstract action @@ -52,7 +52,8 @@ Native recovery added after this abstraction is deliberately not claimed as mode coverage: dead-guard TERM/grace/KILL recovery, supervisor death before guard publication, inherited launch-admission lease, stop/exit inspection races, census errors and restricted views, high descriptor limits, EINTR retries, and idle -poll scheduling. RecoverOrphan models signal admission only, not grace, KILL timing or confirmation. +poll scheduling. RecoverOrphan and OrphanKill model capture, witness loss and KILL admission; +wall-clock grace timing and native confirmation remain implementation tests. ReuseSessionID represents a later survivor after its replacement leader exits. Native regressions include `dead_guard_with_live_session_recovers_term_then_kill`, `supervisor_and_guard_prepublication_crashes_keep_stop_admission_leased`, @@ -75,3 +76,14 @@ regression creates a dead session leader and surviving child, then relocates a stale receipt SID to reproduce reuse without forcing the host PID allocator to wrap. It checks terminal absence, non-terminal unknown without signals, and fresh-owner launch after a terminal receipt. + +Orphan recovery now models WitnessExit after RecoverOrphan captured the old +incarnations. OrphanKill remains enabled after witness loss; the implementation +retains those exact identities through its grace/KILL loop. RecoveryAuthorityRetained +and OrphanKillEnabled check that availability boundary. The witness-loss negative +control deliberately clears the captured snapshot on WitnessExit and must violate +RecoveryAuthorityRetained with live work and no snapshot. SID reuse may follow +an emptied session even during stopping; the old snapshot never authorizes a +new incarnation. The native wrapper/TERM-ignoring-child regression checks this +sequence with real processes. New descendants not in the snapshot remain an +uncovered recovery/unknown boundary; no eventual-exit claim is made. diff --git a/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla b/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla index 3dbcbee0f..1d16e3f22 100644 --- a/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla +++ b/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla @@ -1,89 +1,103 @@ ------------------------ MODULE LifecycleSupervisor ------------------------ EXTENDS Naturals -CONSTANT SkipLiveFence, SkipOrphanFence +CONSTANT SkipLiveFence, SkipOrphanFence, LoseRecoverySnapshot VARIABLES phase, durable, guard, locked, live, supervisor, attempts, - identity, unsafeSignal, duplicate + identity, unsafeSignal, duplicate, witness, snapshot, orphanKilled vars == <> + identity, unsafeSignal, duplicate, witness, snapshot, orphanKilled>> Init == /\ phase = "Absent" /\ durable = FALSE /\ guard = FALSE /\ locked = FALSE /\ live = FALSE /\ supervisor = FALSE /\ attempts = 0 /\ identity = "match" /\ unsafeSignal = FALSE /\ duplicate = FALSE + /\ witness = FALSE /\ snapshot = FALSE /\ orphanKilled = FALSE Reserve == /\ phase \in {"Absent", "Exited"} /\ ~live /\ attempts < 2 /\ phase' = "Starting" /\ guard' = TRUE /\ locked' = TRUE /\ supervisor' = TRUE /\ durable' = FALSE /\ attempts' = attempts + 1 + /\ /\ witness' = FALSE /\ snapshot' = FALSE /\ orphanKilled' = FALSE /\ UNCHANGED <> Publish == /\ phase = "Starting" /\ ~durable /\ durable' = TRUE /\ UNCHANGED <> + identity, unsafeSignal, duplicate, witness, snapshot, orphanKilled>> Spawn == /\ phase = "Starting" /\ durable /\ guard /\ locked /\ phase' = "Running" /\ live' = TRUE - /\ UNCHANGED <> + /\ /\ witness' = TRUE + /\ UNCHANGED <> StartFailure == /\ phase = "Starting" /\ phase' = "Exited" /\ guard' = FALSE /\ locked' = FALSE /\ supervisor' = FALSE /\ UNCHANGED <> + unsafeSignal, duplicate, witness, snapshot, orphanKilled>> SupervisorDeath == /\ supervisor /\ phase = "Running" /\ supervisor' = FALSE /\ phase' = "Recoverable" /\ UNCHANGED <> + identity, unsafeSignal, duplicate, witness, snapshot, orphanKilled>> GuardDeath == /\ guard /\ live /\ guard' = FALSE /\ locked' = FALSE /\ phase' = "Orphaned" /\ supervisor' = FALSE /\ UNCHANGED <> + unsafeSignal, duplicate, witness, snapshot, orphanKilled>> Retry == /\ ~supervisor /\ live /\ attempts < 2 /\ SkipLiveFence /\ duplicate' = TRUE /\ attempts' = attempts + 1 /\ UNCHANGED <> + identity, unsafeSignal, witness, snapshot, orphanKilled>> ReplaceIdentity == /\ identity = "match" /\ durable /\ identity' \in {"foreign", "unknown"} /\ UNCHANGED <> + supervisor, attempts, unsafeSignal, duplicate, witness, snapshot, orphanKilled>> Stop == /\ phase \in {"Running", "Recoverable"} /\ guard /\ durable /\ identity = "match" /\ phase' = "Stopping" /\ unsafeSignal' = (unsafeSignal \/ identity # "match") /\ UNCHANGED <> + identity, duplicate, witness, snapshot, orphanKilled>> Kill == /\ phase = "Stopping" /\ guard /\ identity = "match" /\ phase' = "KillPending" /\ unsafeSignal' = (unsafeSignal \/ identity # "match") /\ UNCHANGED <> + identity, duplicate, witness, snapshot, orphanKilled>> StopUnknown == /\ phase \in {"Stopping", "KillPending"} /\ live /\ phase' = "StopUnknown" /\ UNCHANGED <> + identity, unsafeSignal, duplicate, witness, snapshot, orphanKilled>> ProcessExit == /\ live /\ live' = FALSE /\ UNCHANGED <> + identity, unsafeSignal, duplicate, witness, snapshot, orphanKilled>> Reap == /\ ~live /\ phase \notin {"Absent", "Starting", "Exited"} /\ phase' = "Exited" /\ guard' = FALSE /\ locked' = FALSE /\ supervisor' = FALSE - /\ UNCHANGED <> + /\ UNCHANGED <> \* Environment: after the old session empties, a new leader can take its \* numeric SID, fork, and exit. The survivor has no old birth witness. ReuseSessionID == /\ durable /\ ~guard /\ ~live /\ identity = "match" - /\ phase \in {"Orphaned", "Exited"} + /\ phase \in {"Orphaned", "Exited", "Stopping", "KillPending"} /\ identity' = "sid-reused" /\ live' = TRUE /\ UNCHANGED <> + attempts, unsafeSignal, duplicate, witness, snapshot, orphanKilled>> RecoverOrphan == /\ phase = "Orphaned" /\ ~guard /\ live /\ durable /\ (identity = "match" \/ (SkipOrphanFence /\ identity = "sid-reused")) /\ unsafeSignal' = (unsafeSignal \/ identity # "match") /\ phase' = "Stopping" - /\ UNCHANGED <> -Next == ReuseSessionID \/ RecoverOrphan \/ Reserve \/ Publish \/ Spawn \/ StartFailure \/ SupervisorDeath + /\ /\ (witness \/ SkipOrphanFence) /\ snapshot' = TRUE + /\ UNCHANGED <> +WitnessExit == /\ phase = "Stopping" /\ ~guard /\ live /\ witness + /\ witness' = FALSE + /\ snapshot' = IF LoseRecoverySnapshot THEN FALSE ELSE snapshot + /\ UNCHANGED <> +OrphanKill == /\ phase = "Stopping" /\ ~guard /\ live /\ snapshot + /\ identity = "match" + /\ phase' = "KillPending" /\ orphanKilled' = TRUE + /\ UNCHANGED <> +Next == WitnessExit \/ OrphanKill \/ ReuseSessionID \/ RecoverOrphan \/ Reserve \/ Publish \/ Spawn \/ StartFailure \/ SupervisorDeath \/ GuardDeath \/ Retry \/ ReplaceIdentity \/ Stop \/ Kill \/ StopUnknown \/ ProcessExit \/ Reap Spec == Init /\ [][Next]_vars @@ -91,9 +105,13 @@ TypeOK == /\ phase \in {"Absent", "Starting", "Running", "Recoverable", "Orphaned", "Stopping", "KillPending", "StopUnknown", "Exited"} /\ attempts \in 0..2 /\ identity \in {"match", "foreign", "unknown", "sid-reused"} /\ \A b \in {durable, guard, locked, live, supervisor, - unsafeSignal, duplicate} : b \in BOOLEAN + unsafeSignal, duplicate, witness, snapshot, orphanKilled} : b \in BOOLEAN DurableBeforeLaunch == live => durable GuardRetainsLock == guard => locked NoForeignSignal == ~unsafeSignal NoDuplicate == ~duplicate +RecoveryAuthorityRetained == (phase \in {"Stopping", "KillPending"} /\ ~guard + /\ live /\ identity = "match") => snapshot +OrphanKillEnabled == (phase = "Stopping" /\ ~guard /\ live /\ ~witness + /\ identity = "match" /\ snapshot) => ENABLED OrphanKill ============================================================================= diff --git a/tests/models/tla/lifecycle-supervisor/negative.cfg b/tests/models/tla/lifecycle-supervisor/negative.cfg index 1fb04f6c8..7659fcd6c 100644 --- a/tests/models/tla/lifecycle-supervisor/negative.cfg +++ b/tests/models/tla/lifecycle-supervisor/negative.cfg @@ -1,5 +1,6 @@ SPECIFICATION Spec +CONSTANT LoseRecoverySnapshot = FALSE CONSTANT SkipOrphanFence = FALSE CONSTANT SkipLiveFence = TRUE -INVARIANTS TypeOK DurableBeforeLaunch GuardRetainsLock NoForeignSignal NoDuplicate +INVARIANTS TypeOK DurableBeforeLaunch GuardRetainsLock NoForeignSignal NoDuplicate RecoveryAuthorityRetained OrphanKillEnabled CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/lifecycle-supervisor/positive.cfg b/tests/models/tla/lifecycle-supervisor/positive.cfg index b687855fe..2383ec14c 100644 --- a/tests/models/tla/lifecycle-supervisor/positive.cfg +++ b/tests/models/tla/lifecycle-supervisor/positive.cfg @@ -1,5 +1,6 @@ SPECIFICATION Spec +CONSTANT LoseRecoverySnapshot = FALSE CONSTANT SkipOrphanFence = FALSE CONSTANT SkipLiveFence = FALSE -INVARIANTS TypeOK DurableBeforeLaunch GuardRetainsLock NoForeignSignal NoDuplicate +INVARIANTS TypeOK DurableBeforeLaunch GuardRetainsLock NoForeignSignal NoDuplicate RecoveryAuthorityRetained OrphanKillEnabled CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/lifecycle-supervisor/sid-reuse.cfg b/tests/models/tla/lifecycle-supervisor/sid-reuse.cfg index 06c12eaa4..02e0a9b41 100644 --- a/tests/models/tla/lifecycle-supervisor/sid-reuse.cfg +++ b/tests/models/tla/lifecycle-supervisor/sid-reuse.cfg @@ -1,5 +1,6 @@ SPECIFICATION Spec CONSTANT SkipLiveFence = FALSE +CONSTANT LoseRecoverySnapshot = FALSE CONSTANT SkipOrphanFence = TRUE -INVARIANTS TypeOK DurableBeforeLaunch GuardRetainsLock NoForeignSignal NoDuplicate +INVARIANTS TypeOK DurableBeforeLaunch GuardRetainsLock NoForeignSignal NoDuplicate RecoveryAuthorityRetained OrphanKillEnabled CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/lifecycle-supervisor/witness-loss.cfg b/tests/models/tla/lifecycle-supervisor/witness-loss.cfg new file mode 100644 index 000000000..fb7e98a26 --- /dev/null +++ b/tests/models/tla/lifecycle-supervisor/witness-loss.cfg @@ -0,0 +1,6 @@ +SPECIFICATION Spec +CONSTANT SkipLiveFence = FALSE +CONSTANT SkipOrphanFence = FALSE +CONSTANT LoseRecoverySnapshot = TRUE +INVARIANTS TypeOK NoForeignSignal RecoveryAuthorityRetained OrphanKillEnabled +CHECK_DEADLOCK FALSE From 25772b68ef54b9a08bd100bcabe724ee8eaae906 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:58:04 -0500 Subject: [PATCH 30/55] fix(packaging): refresh reviewed toolchain recipe pins --- .hack/tasks.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.hack/tasks.json b/.hack/tasks.json index 0cf5d7bb5..782f7440a 100644 --- a/.hack/tasks.json +++ b/.hack/tasks.json @@ -34,7 +34,7 @@ "inputs": [ { "path": ".hack/toolchain/run.sh", - "sha256": "0690919e0937c49638ea1c8318d731df3c45df26c3865c6a37ae263e8854e6a5" + "sha256": "d71945600ee66cf53a2ffb23f7aa7603fd217fde8a6875e97668b72ccce4fad5" }, { "path": ".hack/toolchain/Dockerfile", @@ -97,7 +97,7 @@ "inputs": [ { "path": ".hack/toolchain/run.sh", - "sha256": "0690919e0937c49638ea1c8318d731df3c45df26c3865c6a37ae263e8854e6a5" + "sha256": "d71945600ee66cf53a2ffb23f7aa7603fd217fde8a6875e97668b72ccce4fad5" }, { "path": ".hack/toolchain/Dockerfile", @@ -154,7 +154,7 @@ "inputs": [ { "path": ".hack/toolchain/run.sh", - "sha256": "0690919e0937c49638ea1c8318d731df3c45df26c3865c6a37ae263e8854e6a5" + "sha256": "d71945600ee66cf53a2ffb23f7aa7603fd217fde8a6875e97668b72ccce4fad5" }, { "path": ".hack/toolchain/Dockerfile", From 30dde3891c36010cdeb56404b0258e0795434096 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:59:10 -0500 Subject: [PATCH 31/55] test(supervisor): reap paused caller on failed zombie assertion --- .../lifecycle-supervisor/tests/fake_caller.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index cccd2e75c..d4be3fadc 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1100,11 +1100,15 @@ fn process_is_zombie(pid: i32) -> bool { #[test] fn unreaped_matching_guard_proves_legacy_session_ownership() { let f = Fixture::new(); - let (mut caller, _) = f.start(vec![f.process("worker", "hold")]); + let (caller, _) = f.start(vec![f.process("worker", "hold")]); + let mut caller = ForeignChild(caller); f.await_log("fixture-ready"); let mut state = f.state("worker"); // Keep the parent from reaping its guard, then exercise legacy birth absence. - assert_eq!(unsafe { libc::kill(caller.id() as i32, libc::SIGSTOP) }, 0); + assert_eq!( + unsafe { libc::kill(caller.0.id() as i32, libc::SIGSTOP) }, + 0 + ); assert_eq!( unsafe { libc::kill(pid(&state, "guardPid"), libc::SIGKILL) }, 0 @@ -1117,8 +1121,11 @@ fn unreaped_matching_guard_proves_legacy_session_ownership() { ) .unwrap(); f.stop(); - assert_eq!(unsafe { libc::kill(caller.id() as i32, libc::SIGCONT) }, 0); - assert!(wait(&mut caller, TIMEOUT).success()); + assert_eq!( + unsafe { libc::kill(caller.0.id() as i32, libc::SIGCONT) }, + 0 + ); + assert!(wait(&mut caller.0, TIMEOUT).success()); } #[test] From 090013367b0da536f21a7a1b96b910815886d83b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:12:20 -0500 Subject: [PATCH 32/55] test(supervisor): report receipt on failed owned stop --- packages/lifecycle-supervisor/tests/fake_caller.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index d4be3fadc..845e3a83e 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -93,7 +93,14 @@ impl Fixture { 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"); + let status = wait(&mut child, TIMEOUT); + if !status.success() { + // Receipts contain only identity/phase metadata, never argv/env. + // Read after the same exit deadline so diagnostic collection cannot + // change stop admission or hide a resumed-guard timing failure. + let receipt = receive(child.stdout.as_mut().unwrap()); + panic!("owned stop failed: {receipt}"); + } } fn state(&self, name: &str) -> Value { From 71fa093c402fabceb7f93f9ba544d5d3bdf034e7 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:20:53 -0500 Subject: [PATCH 33/55] fix(supervisor): retain zombie guard birth proof on Darwin --- docs/lifecycle-supervisor.md | 5 ++ packages/lifecycle-supervisor/src/os.rs | 74 ++++++++++++++++++++----- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index b1e066636..4c3e91a9e 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -260,6 +260,11 @@ the receipt has no command birth evidence, so an unanchored session is unknown. rotation is allowed only after all original callers/guards and authored work have been independently confirmed gone. +Darwin may discard a zombie guard's session/group and credential metadata while +retaining its PID and birth timestamp. A matching boot, PID and birth still prove +the original guard has not been reused; this zombie never counts as live work +and is never signalled. Live recovery members still require the recorded UID. + Darwin start timestamps come from the wall clock and can decrease between guard and command creation. Recovery therefore never orders those timestamps: it matches PID and the recorded birth exactly. A backward step between those starts does not diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index fec64c157..69ee12ba4 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -191,10 +191,13 @@ pub fn inspect(pid: i32) -> Result> { let info = unsafe { info.assume_init() }; // SAFETY: getsid only observes the named PID. ESRCH is an exit race. let mut session = unsafe { libc::getsid(pid) }; - if session < 0 && info.pbi_status == libc::SZOMB && info.pbi_pgid as i32 == pid { - // Darwin may hide getsid for zombies. A matching recorded leader's - // immutable PID/birth/UID still proves it has not been reused. - session = pid; + if session < 0 + && info.pbi_status == libc::SZOMB + && io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) + { + // A zombie can lose session/group/credential metadata during exit. + // Preserve its immutable PID/birth, but never invent live membership. + session = 0; } if session < 0 { let error = io::Error::last_os_error(); @@ -339,6 +342,14 @@ fn incarnation_matches(expected: &Identity, current: &Identity) -> bool { && expected.session == current.session } +/// PID plus immutable birth pins the original zombie guard even when Darwin +/// has discarded its session/credentials. Live leaders still require UID/SID. +fn recorded_guard_matches(state: &crate::storage::State, p: &Identity) -> bool { + p.pid == state.guard_pid + && p.start == state.guard_start_time + && (p.zombie || (p.uid == state.uid && p.session == state.guard_pid)) +} + /// Return one census with session identity proved across that census. A matching /// leader (including zombie) or exact recorded command birth is the witness. /// No ordering comparison uses Darwin's adjustable wall-clock start timestamps. @@ -351,9 +362,7 @@ fn recorded_members(state: &crate::storage::State) -> Result> { } let leader = inspect(state.guard_pid)?; if let Some(p) = &leader - && (p.start != state.guard_start_time - || p.uid != state.uid - || p.session != state.guard_pid) + && !recorded_guard_matches(state, p) { return Ok(Vec::new()); } @@ -375,10 +384,13 @@ fn recorded_members(state: &crate::storage::State) -> Result> { }) .ok_or_else(refused)?; // Recheck after enumeration: the witness held the SID throughout the census. - if !inspect(witness.pid)? - .as_ref() - .is_some_and(|p| incarnation_matches(witness, p)) - { + if !inspect(witness.pid)?.as_ref().is_some_and(|p| { + if witness.pid == state.guard_pid { + recorded_guard_matches(state, p) + } else { + incarnation_matches(witness, p) + } + }) { continue; } return Ok(members); @@ -407,9 +419,7 @@ pub fn observe_state(state: &crate::storage::State) -> Observation { } 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) + && !recorded_guard_matches(state, identity) { return Ok(Observation::Absent); } @@ -851,6 +861,42 @@ pub fn timestamp() -> Result { #[cfg(test)] mod tests { + #[test] + fn zombie_guard_birth_survives_discarded_exit_metadata() { + let state = crate::storage::State { + version: 1, + name: "worker".into(), + owner_token: "synthetic".into(), + guard_pid: 44, + process_group_id: 44, + guard_start_time: 100, + boot_id: String::new(), + uid: 501, + command_pid: None, + command_start_time: None, + phase: "running".into(), + exit_code: None, + signal: None, + }; + let mut identity = super::Identity { + pid: 44, + group: 0, + session: 0, + start: 100, + uid: 0, + zombie: true, + }; + assert!(super::recorded_guard_matches(&state, &identity)); + identity.start += 1; + assert!(!super::recorded_guard_matches(&state, &identity)); + identity.start = 100; + identity.zombie = false; + assert!(!super::recorded_guard_matches(&state, &identity)); + identity.uid = state.uid; + identity.session = state.guard_pid; + assert!(super::recorded_guard_matches(&state, &identity)); + } + #[test] fn flock_interruption_retries_but_other_errors_do_not() { let mut calls = 0; From 7dca61724014fff13035fdd2f4f4be0529a19b94 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:33:27 -0500 Subject: [PATCH 34/55] fix(supervisor): inspect unreaped Darwin guard records --- docs/lifecycle-supervisor.md | 9 ++++--- packages/lifecycle-supervisor/src/os.rs | 16 ++++++----- .../lifecycle-supervisor/tests/fake_caller.rs | 27 ++++++++++++++----- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 4c3e91a9e..8d816dff2 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -260,10 +260,11 @@ the receipt has no command birth evidence, so an unanchored session is unknown. rotation is allowed only after all original callers/guards and authored work have been independently confirmed gone. -Darwin may discard a zombie guard's session/group and credential metadata while -retaining its PID and birth timestamp. A matching boot, PID and birth still prove -the original guard has not been reused; this zombie never counts as live work -and is never signalled. Live recovery members still require the recorded UID. +Darwin libproc requires a nonzero PROC_PIDTBSDINFO argument to include unreaped +zombies. The inspector requests it explicitly. A matching boot, PID, birth and +UID prove the original zombie guard has not been reused even when getsid rejects +it; this zombie never counts as live work and is never signalled. Live guards +and recovery members still require the recorded session and UID. Darwin start timestamps come from the wall clock and can decrease between guard and command creation. Recovery therefore never orders those timestamps: it matches diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index 69ee12ba4..d9c708cc1 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -172,7 +172,7 @@ pub fn inspect(pid: i32) -> Result> { libc::proc_pidinfo( pid, libc::PROC_PIDTBSDINFO, - 0, + 1, // XNU requires nonzero arg to search unreaped zombie records. info.as_mut_ptr().cast(), std::mem::size_of::() as i32, ) @@ -195,7 +195,7 @@ pub fn inspect(pid: i32) -> Result> { && info.pbi_status == libc::SZOMB && io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { - // A zombie can lose session/group/credential metadata during exit. + // Darwin getsid can reject zombies even when libproc returns birth/UID. // Preserve its immutable PID/birth, but never invent live membership. session = 0; } @@ -342,12 +342,13 @@ fn incarnation_matches(expected: &Identity, current: &Identity) -> bool { && expected.session == current.session } -/// PID plus immutable birth pins the original zombie guard even when Darwin -/// has discarded its session/credentials. Live leaders still require UID/SID. +/// PID/birth/UID pins the original zombie guard when Darwin getsid rejects it. +/// Live leaders additionally require the recorded session. fn recorded_guard_matches(state: &crate::storage::State, p: &Identity) -> bool { p.pid == state.guard_pid && p.start == state.guard_start_time - && (p.zombie || (p.uid == state.uid && p.session == state.guard_pid)) + && p.uid == state.uid + && (p.zombie || p.session == state.guard_pid) } /// Return one census with session identity proved across that census. A matching @@ -883,13 +884,16 @@ mod tests { group: 0, session: 0, start: 100, - uid: 0, + uid: 501, zombie: true, }; assert!(super::recorded_guard_matches(&state, &identity)); identity.start += 1; assert!(!super::recorded_guard_matches(&state, &identity)); identity.start = 100; + identity.uid = 0; + assert!(!super::recorded_guard_matches(&state, &identity)); + identity.uid = state.uid; identity.zombie = false; assert!(!super::recorded_guard_matches(&state, &identity)); identity.uid = state.uid; diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index 845e3a83e..92e42b936 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1073,7 +1073,9 @@ fn orphan_recovery_keeps_observed_child_after_term_ends_witness() { assert!( census() .into_iter() - .all(|p| p.session != pid(&state, "guardPid") || process_is_zombie(p.pid)) + .all(|p| p.session != pid(&state, "guardPid") + || identity(p.pid).is_none() + || process_is_zombie(p.pid)) ); } @@ -1083,7 +1085,7 @@ fn process_is_zombie(pid: i32) -> bool { fs::read_to_string(format!("/proc/{pid}/stat")) .ok() .and_then(|s| s.rsplit_once(") ").map(|(_, rest)| rest.starts_with('Z'))) - .unwrap_or(true) + .unwrap_or(false) } #[cfg(target_os = "macos")] { @@ -1093,14 +1095,13 @@ fn process_is_zombie(pid: i32) -> bool { libc::proc_pidinfo( pid, libc::PROC_PIDTBSDINFO, - 0, + 1, info.as_mut_ptr().cast(), std::mem::size_of::() as i32, ) }; - size == 0 - || (size as usize == std::mem::size_of::() - && unsafe { info.assume_init() }.pbi_status == libc::SZOMB) + size as usize == std::mem::size_of::() + && unsafe { info.assume_init() }.pbi_status == libc::SZOMB } } @@ -1116,6 +1117,20 @@ fn unreaped_matching_guard_proves_legacy_session_ownership() { unsafe { libc::kill(caller.0.id() as i32, libc::SIGSTOP) }, 0 ); + // SIGSTOP delivery is asynchronous. Observe our own child's stopped + // wait status before killing its guard, so it cannot win the reap race. + eventually(|| { + let mut status = 0; + // SAFETY: this is our unreaped child; status is writable for waitpid. + let observed = unsafe { + libc::waitpid( + caller.0.id() as i32, + &mut status, + libc::WNOHANG | libc::WUNTRACED, + ) + }; + (observed == caller.0.id() as i32 && libc::WIFSTOPPED(status)).then_some(()) + }); assert_eq!( unsafe { libc::kill(pid(&state, "guardPid"), libc::SIGKILL) }, 0 From f952a17733e26c7194bf3408ed78165a1974ca74 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:36:54 -0500 Subject: [PATCH 35/55] fix(supervisor): retain stop admission recovery authority --- docs/lifecycle-supervisor.md | 4 +++ docs/reference/v5-model-coverage.md | 4 +-- .../lifecycle-supervisor/src/supervisor.rs | 30 +++++++++------- .../lifecycle-supervisor/tests/fake_caller.rs | 34 +++++++++++++++++++ scripts/lib/tla-runtime-models.ts | 2 +- .../tla/lifecycle-supervisor/COVERAGE.md | 7 +++- .../LifecycleSupervisor.tla | 9 ++--- 7 files changed, 70 insertions(+), 20 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 8d816dff2..580627c03 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -46,6 +46,10 @@ A missing leader plus a numeric SID is not ownership: orphan recovery requires the exact recorded command PID/birth or a matching guard, including a zombie. Only unrecorded members, or legacy receipts without a surviving matching guard, report unknown without signals. Descendants alone may require manual recovery when both recorded processes are gone. +Stop captures verified members before signaling even a live guard. If that guard +is lost during stop, recovery prefers a fresh capture and falls back to those +admitted identities, rechecking each before TERM/KILL. Unobserved descendants +still report unknown. Replies contain `version`, `supervisorPid`, `processes`, and `outcome` (`ready`, `observed`, `stopped`, or `unknown`). A stop whose confirmation times out emits diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 737dca745..ab1ee7d2b 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -36,7 +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/reused-SID identities, arbitrary process exit and supervisor/guard loss; 322 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. | +| 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/reused-SID identities, arbitrary process exit and supervisor/guard loss; 426 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. | | Interactive exec write-ahead admission, crash, EOF, replacement boot and retirement | interactive-session (459 states); no-replay, EOF-retirement, failed-inspect and wrong-boot negative controls | `interactive_session.rs` atomic intent/exec records, exact stopped-boot proof, known-undispatched withdrawal, restore-before-busy-retirement and run-scoped cleanup-report tests, and lock-free observation/revalidation tests; Engine effect fences; authored owner start and SCM_RIGHTS handoff | One session, two boot identities, two possible starts. Atomic durable publication and exact identity/absence proofs are assumptions; real fsync/power loss, real owner/Engine handoff and VM loss need Mac acceptance; receipt lock timing and terminal restoration are regression-covered, not modeled. Validated exact exec exit permits retirement; it makes no descendant cleanup claim. | | Private interactive envelope, echo state and bytes still in flight on failure | interactive-envelope (15 states); restore-on-error negative control | HKEE2 launcher and host gate, Linux PTY seven-case matrix, bounded marker/ACK and coalesced-output tests | Two abstract private bytes, one handshake. PTY fixtures prove local termios ordering; Docker detach scanning, real guest wiring, same-user access and VM effects remain separate. Source/compiled Bun PTY and native-process fixtures cover lifetime/terminal boundaries, not model liveness. | | 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. | @@ -83,7 +83,7 @@ liveness/reachability controls have their own documented assumptions. | Model | Positive distinct states | Configurations | | --- | ---: | ---: | -| [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla) | 322 | 4 | +| [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla) | 426 | 4 | | [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/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index 589f6eb8c..5ba8426f1 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -466,11 +466,15 @@ fn stop(states: &[State]) -> Result<()> { struct Target { state: State, recovery: Option, + admission: Option, orphan_term: Option, } impl Target { fn recover(&mut self) -> Result<()> { - let recovery = os::Recovery::capture(&self.state)?; + // Prefer a fresh census, but retain already-proved identities if + // TERM ended the command witness before the guard was lost. + let recovery = os::Recovery::capture(&self.state) + .or_else(|error| self.admission.take().ok_or(error))?; recovery.signal(libc::SIGTERM)?; self.orphan_term = Some(Instant::now()); self.recovery = Some(recovery); @@ -478,7 +482,7 @@ fn stop(states: &[State]) -> Result<()> { } } let mut active = Vec::new(); - // Validate all receipts and capture orphan members before the first effect. + // Capture every target before effects, including live guards that may die mid-stop. for state in states { if !os::recorded_live(state, false)? { continue; @@ -490,13 +494,16 @@ fn stop(states: &[State]) -> Result<()> { } _ => false, }; + let captured = os::Recovery::capture(state)?; + let (recovery, admission) = if guard_live { + (None, Some(captured)) + } else { + (Some(captured), None) + }; active.push(Target { state: state.clone(), - recovery: if guard_live { - None - } else { - Some(os::Recovery::capture(state)?) - }, + recovery, + admission, orphan_term: None, }); } @@ -517,14 +524,13 @@ fn stop(states: &[State]) -> Result<()> { let mut live = false; for target in &mut active { if target.recovery.is_none() { - if !os::recorded_live(&target.state, false)? { - continue; - } if os::inspect(target.state.guard_pid)?.is_none_or(|p| p.zombie) { - // Guard loss gets its own snapshot and full TERM grace. + // Guard loss gets a fresh census or the admission snapshot. target.recover()?; } else { - live = true; + if os::recorded_live(&target.state, false)? { + live = true; + } continue; } } diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index 92e42b936..e34c5c668 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1037,6 +1037,40 @@ fn dead_guard_with_live_session_recovers_term_then_kill() { assert_eq!(receipt["liveProcessNames"], json!([])); } +#[test] +fn guard_lost_mid_stop_keeps_admission_snapshot() { + let f = Fixture::new(); + let (mut caller, _) = f.start(vec![f.process("worker", "wrapper-term-child")]); + eventually(|| f.root.join("wrapper-child-ready").exists().then_some(())); + let state = f.state("worker"); + let child = eventually(|| { + f.log().lines().find_map(|line| { + line.split("wrapper-child ") + .nth(1) + .and_then(|pid| pid.parse::().ok()) + .and_then(identity) + }) + }); + f.owned.borrow_mut().push(child); + let mut stop = command(&f.root, "stop").spawn().unwrap(); + send(stop.stdin.as_mut().unwrap(), &f.control(TOKEN)); + // The guard has acted on this stop once its wrapper is dead. Its five-second + // grace leaves time to remove the guard before it can KILL the child. + eventually(|| { + identity(pid(&state, "commandPid")) + .is_none_or(|_| process_is_zombie(pid(&state, "commandPid"))) + .then_some(()) + }); + assert_eq!( + unsafe { libc::kill(pid(&state, "guardPid"), libc::SIGKILL) }, + 0 + ); + assert!(wait(&mut caller, TIMEOUT).success()); + let receipt = receive(stop.stdout.as_mut().unwrap()); + assert!(wait(&mut stop, TIMEOUT).success(), "{receipt}"); + assert_eq!(receipt["outcome"], "stopped"); +} + #[test] fn orphan_recovery_keeps_observed_child_after_term_ends_witness() { let f = Fixture::new(); diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 97c17b6ff..f48ee4119 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -144,7 +144,7 @@ const contracts: readonly ModelContract[] = [ { name: "lifecycle-supervisor", module: "LifecycleSupervisor", - states: 322, + states: 426, invariant: "NoDuplicate", action: "Retry", fields: ["duplicate = TRUE", "live = TRUE", "durable = TRUE"], diff --git a/tests/models/tla/lifecycle-supervisor/COVERAGE.md b/tests/models/tla/lifecycle-supervisor/COVERAGE.md index b5af7fb7e..9b0e63589 100644 --- a/tests/models/tla/lifecycle-supervisor/COVERAGE.md +++ b/tests/models/tla/lifecycle-supervisor/COVERAGE.md @@ -13,7 +13,7 @@ standalone Rust binary exercised by a fake caller; the CLI remains unchanged. | `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: 322 distinct states, queue exhausted, depth 14. Negative +Positive TLC exploration: 426 distinct states, queue exhausted, depth 14. 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 @@ -87,3 +87,8 @@ an emptied session even during stopping; the old snapshot never authorizes a new incarnation. The native wrapper/TERM-ignoring-child regression checks this sequence with real processes. New descendants not in the snapshot remain an uncovered recovery/unknown boundary; no eventual-exit claim is made. + +Stop now captures members while a live guard proves ownership as well. GuardDeath +retains the stopping phase, and WitnessExit may happen before or after guard loss. +The native guard_lost_mid_stop_keeps_admission_snapshot regression kills the guard +after TERM ended its wrapper and checks KILL and confirmed absence for its child. diff --git a/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla b/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla index 1d16e3f22..e68d1b885 100644 --- a/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla +++ b/tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla @@ -38,7 +38,8 @@ SupervisorDeath == /\ supervisor /\ phase = "Running" identity, unsafeSignal, duplicate, witness, snapshot, orphanKilled>> GuardDeath == /\ guard /\ live /\ guard' = FALSE /\ locked' = FALSE - /\ phase' = "Orphaned" /\ supervisor' = FALSE + /\ phase' = IF phase = "Stopping" THEN "Stopping" ELSE "Orphaned" + /\ supervisor' = FALSE /\ UNCHANGED <> Retry == /\ ~supervisor /\ live /\ attempts < 2 @@ -52,10 +53,10 @@ ReplaceIdentity == /\ identity = "match" /\ durable supervisor, attempts, unsafeSignal, duplicate, witness, snapshot, orphanKilled>> Stop == /\ phase \in {"Running", "Recoverable"} /\ guard /\ durable /\ identity = "match" - /\ phase' = "Stopping" + /\ phase' = "Stopping" /\ snapshot' = TRUE /\ unsafeSignal' = (unsafeSignal \/ identity # "match") /\ UNCHANGED <> + identity, duplicate, witness, orphanKilled>> Kill == /\ phase = "Stopping" /\ guard /\ identity = "match" /\ phase' = "KillPending" /\ unsafeSignal' = (unsafeSignal \/ identity # "match") @@ -87,7 +88,7 @@ RecoverOrphan == /\ phase = "Orphaned" /\ ~guard /\ live /\ durable /\ /\ (witness \/ SkipOrphanFence) /\ snapshot' = TRUE /\ UNCHANGED <> -WitnessExit == /\ phase = "Stopping" /\ ~guard /\ live /\ witness +WitnessExit == /\ phase = "Stopping" /\ live /\ witness /\ witness' = FALSE /\ snapshot' = IF LoseRecoverySnapshot THEN FALSE ELSE snapshot /\ UNCHANGED < Date: Sat, 14 Nov 2026 09:44:08 -0500 Subject: [PATCH 36/55] fix(supervisor): preserve recovered stop grace and guard admission --- docs/lifecycle-supervisor.md | 7 ++- .../lifecycle-supervisor/src/supervisor.rs | 43 +++++++++++++++---- .../lifecycle-supervisor/tests/fake_caller.rs | 13 ++++++ .../tla/lifecycle-supervisor/COVERAGE.md | 8 ++++ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 580627c03..33d8ba823 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -46,9 +46,12 @@ A missing leader plus a numeric SID is not ownership: orphan recovery requires the exact recorded command PID/birth or a matching guard, including a zombie. Only unrecorded members, or legacy receipts without a surviving matching guard, report unknown without signals. Descendants alone may require manual recovery when both recorded processes are gone. -Stop captures verified members before signaling even a live guard. If that guard +Stop attempts to capture verified members before signaling even a live guard; +a failed capture does not prevent that healthy guard from handling shutdown. If that guard is lost during stop, recovery prefers a fresh capture and falls back to those -admitted identities, rechecking each before TERM/KILL. Unobserved descendants +admitted identities, rechecking each before TERM/KILL. Each recovered target gets +its own five-second TERM grace and two-second confirmation bound. Recovery begins +at most once per target; late guard loss can extend stop to roughly fourteen seconds. Unobserved descendants still report unknown. Replies contain `version`, `supervisorPid`, `processes`, and `outcome` (`ready`, diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index 5ba8426f1..c57f5fa9c 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -462,6 +462,19 @@ fn guard_command( } } +// A live guard owns shutdown even when a complete recovery census is unavailable +// (for example, a different-UID descendant). Orphans require strict capture. +fn stop_capture( + guard_live: bool, + capture: impl FnOnce() -> Result, +) -> Result<(Option, Option)> { + if guard_live { + Ok((None, capture().ok())) + } else { + Ok((Some(capture()?), None)) + } +} + fn stop(states: &[State]) -> Result<()> { struct Target { state: State, @@ -494,12 +507,7 @@ fn stop(states: &[State]) -> Result<()> { } _ => false, }; - let captured = os::Recovery::capture(state)?; - let (recovery, admission) = if guard_live { - (None, Some(captured)) - } else { - (Some(captured), None) - }; + let (recovery, admission) = stop_capture(guard_live, || os::Recovery::capture(state))?; active.push(Target { state: state.clone(), recovery, @@ -520,7 +528,7 @@ fn stop(states: &[State]) -> Result<()> { } } let start = Instant::now(); - while start.elapsed() < GRACE + Duration::from_secs(2) { + loop { let mut live = false; for target in &mut active { if target.recovery.is_none() { @@ -529,6 +537,9 @@ fn stop(states: &[State]) -> Result<()> { target.recover()?; } else { if os::recorded_live(&target.state, false)? { + if start.elapsed() >= GRACE + Duration::from_secs(2) { + return Err(refused()); + } live = true; } continue; @@ -538,6 +549,12 @@ fn stop(states: &[State]) -> Result<()> { if !recovery.live()? { continue; } + if target + .orphan_term + .is_some_and(|since| since.elapsed() >= GRACE + Duration::from_secs(2)) + { + return Err(refused()); + } live = true; if target .orphan_term @@ -552,7 +569,6 @@ fn stop(states: &[State]) -> Result<()> { } thread::sleep(TICK); } - Err(refused()) } fn detach_stdio() -> Result<()> { @@ -652,6 +668,17 @@ fn incomplete_utf8_tail(bytes: &[u8]) -> usize { mod tests { use super::incomplete_utf8_tail; + #[test] + fn live_guard_admission_tolerates_unavailable_recovery_census() { + let denied = || Err::<(), _>(std::io::Error::from_raw_os_error(libc::EPERM)); + let (recovery, admission) = super::stop_capture(true, denied).unwrap(); + assert!(recovery.is_none() && admission.is_none()); + assert!(super::stop_capture(false, denied).is_err()); + let (recovery, admission) = super::stop_capture(true, || Ok(42)).unwrap(); + assert!(recovery.is_none()); + assert_eq!(admission, Some(42)); + } + #[test] fn chunk_boundary_retains_only_incomplete_codepoints() { for text in ["a", "é", "€", "🦀"] { diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index e34c5c668..8e42f1057 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1039,6 +1039,15 @@ fn dead_guard_with_live_session_recovers_term_then_kill() { #[test] fn guard_lost_mid_stop_keeps_admission_snapshot() { + guard_lost_during_stop(Duration::ZERO); +} + +#[test] +fn guard_lost_late_in_stop_gets_full_recovery_deadline() { + guard_lost_during_stop(Duration::from_secs(3)); +} + +fn guard_lost_during_stop(delay: Duration) { let f = Fixture::new(); let (mut caller, _) = f.start(vec![f.process("worker", "wrapper-term-child")]); eventually(|| f.root.join("wrapper-child-ready").exists().then_some(())); @@ -1061,6 +1070,8 @@ fn guard_lost_mid_stop_keeps_admission_snapshot() { .is_none_or(|_| process_is_zombie(pid(&state, "commandPid"))) .then_some(()) }); + thread::sleep(delay); + let recovery_started = Instant::now(); assert_eq!( unsafe { libc::kill(pid(&state, "guardPid"), libc::SIGKILL) }, 0 @@ -1069,6 +1080,8 @@ fn guard_lost_mid_stop_keeps_admission_snapshot() { let receipt = receive(stop.stdout.as_mut().unwrap()); assert!(wait(&mut stop, TIMEOUT).success(), "{receipt}"); assert_eq!(receipt["outcome"], "stopped"); + assert!(recovery_started.elapsed() >= Duration::from_secs(5)); + assert!(recovery_started.elapsed() < Duration::from_secs(7)); } #[test] diff --git a/tests/models/tla/lifecycle-supervisor/COVERAGE.md b/tests/models/tla/lifecycle-supervisor/COVERAGE.md index 9b0e63589..8f699fd69 100644 --- a/tests/models/tla/lifecycle-supervisor/COVERAGE.md +++ b/tests/models/tla/lifecycle-supervisor/COVERAGE.md @@ -92,3 +92,11 @@ Stop now captures members while a live guard proves ownership as well. GuardDeat retains the stopping phase, and WitnessExit may happen before or after guard loss. The native guard_lost_mid_stop_keeps_admission_snapshot regression kills the guard after TERM ended its wrapper and checks KILL and confirmed absence for its child. + +Recovery deadlines are not modeled: each recovered target receives a fresh five-second +TERM grace plus two seconds for confirmation, independently of the original guard +wait. guard_lost_late_in_stop_gets_full_recovery_deadline kills the guard three +seconds after wrapper death and checks the full recovery grace and successful +KILL confirmation; the early-loss variant checks the same boundary without delay. +Live-guard admission snapshots are best-effort; a denied census does not prevent +the guard-owned shutdown path. Orphan capture stays strict (unit-tested admission seam). From 2288c400c32228082a417b7769aaa61c00aa9813 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:51:56 -0500 Subject: [PATCH 37/55] fix(supervisor): recover guard loss during stop census --- docs/lifecycle-supervisor.md | 3 +- .../lifecycle-supervisor/src/supervisor.rs | 128 ++++++++++++------ .../lifecycle-supervisor/tests/fake_caller.rs | 46 +++++-- .../tla/lifecycle-supervisor/COVERAGE.md | 8 +- 4 files changed, 128 insertions(+), 57 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 33d8ba823..2c27c1b33 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -52,7 +52,8 @@ is lost during stop, recovery prefers a fresh capture and falls back to those admitted identities, rechecking each before TERM/KILL. Each recovered target gets its own five-second TERM grace and two-second confirmation bound. Recovery begins at most once per target; late guard loss can extend stop to roughly fourteen seconds. Unobserved descendants -still report unknown. +still report unknown. A failed or expired target does not abandon other admitted +targets: stop drives them to their own outcomes before returning unknown. Replies contain `version`, `supervisorPid`, `processes`, and `outcome` (`ready`, `observed`, `stopped`, or `unknown`). A stop whose confirmation times out emits diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index c57f5fa9c..39bad47f1 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -475,12 +475,30 @@ fn stop_capture( } } +fn guard_lost(state: &State) -> Result { + Ok(os::inspect(state.guard_pid)?.is_none_or(|p| p.zombie)) +} + +// None transfers to orphan recovery. A census error cannot bypass that transfer +// when the guard disappears during the census or its witness recheck. +fn guarded_live(state: &State) -> Result> { + if guard_lost(state)? { + return Ok(None); + } + match os::recorded_live(state, false) { + Ok(live) => Ok(Some(live)), + Err(_) if guard_lost(state)? => Ok(None), + Err(error) => Err(error), + } +} + fn stop(states: &[State]) -> Result<()> { struct Target { state: State, recovery: Option, admission: Option, orphan_term: Option, + finished: bool, } impl Target { fn recover(&mut self) -> Result<()> { @@ -493,79 +511,101 @@ fn stop(states: &[State]) -> Result<()> { self.recovery = Some(recovery); Ok(()) } + + fn advance(&mut self, start: Instant) -> Result { + if self.recovery.is_none() { + match guarded_live(&self.state)? { + None => self.recover()?, + Some(false) => return Ok(false), + Some(true) => { + if start.elapsed() >= GRACE + Duration::from_secs(2) { + return Err(refused()); + } + return Ok(true); + } + } + } + let recovery = self.recovery.as_ref().ok_or_else(refused)?; + if !recovery.live()? { + return Ok(false); + } + let elapsed = self.orphan_term.ok_or_else(refused)?.elapsed(); + if elapsed >= GRACE + Duration::from_secs(2) { + return Err(refused()); + } + if elapsed >= GRACE { + recovery.signal(libc::SIGKILL)?; + } + Ok(true) + } } let mut active = Vec::new(); - // Capture every target before effects, including live guards that may die mid-stop. + // Validate admission before effects. Capture while a live guard can still + // prove membership, so loss during the later liveness check retains authority. for state in states { - if !os::recorded_live(state, false)? { - continue; - } let guard_live = match os::inspect(state.guard_pid)? { Some(identity) if !identity.zombie => { - os::same(state, &identity)?; + if let Err(error) = os::same(state, &identity) { + if !os::recorded_live(state, false)? { + continue; + } + return Err(error); + } true } _ => false, }; let (recovery, admission) = stop_capture(guard_live, || os::Recovery::capture(state))?; + match os::recorded_live(state, false) { + Ok(false) => continue, + Ok(true) => (), + Err(_) if guard_lost(state)? && (recovery.is_some() || admission.is_some()) => (), + Err(error) => return Err(error), + } active.push(Target { state: state.clone(), recovery, admission, orphan_term: None, + finished: false, }); } + let mut failure = None; for target in &mut active { - if let Some(recovery) = &target.recovery { - recovery.signal(libc::SIGTERM)?; + let signalled = if let Some(recovery) = &target.recovery { target.orphan_term = Some(Instant::now()); - } else if let Err(error) = os::signal_guard(&target.state) { - if os::inspect(target.state.guard_pid)?.is_none_or(|p| p.zombie) { - target.recover()?; - } else if os::recorded_live(&target.state, false)? { - return Err(error); + recovery.signal(libc::SIGTERM) + } else { + match os::signal_guard(&target.state) { + Ok(()) => Ok(()), + Err(error) => match guarded_live(&target.state) { + Ok(None) => target.recover(), + Ok(Some(false)) => Ok(()), + Ok(Some(true)) => Err(error), + Err(error) => Err(error), + }, } + }; + if let Err(error) = signalled { + target.finished = true; + failure.get_or_insert(error); } } let start = Instant::now(); loop { let mut live = false; - for target in &mut active { - if target.recovery.is_none() { - if os::inspect(target.state.guard_pid)?.is_none_or(|p| p.zombie) { - // Guard loss gets a fresh census or the admission snapshot. - target.recover()?; - } else { - if os::recorded_live(&target.state, false)? { - if start.elapsed() >= GRACE + Duration::from_secs(2) { - return Err(refused()); - } - live = true; - } - continue; - } - } - if let Some(recovery) = &target.recovery { - if !recovery.live()? { - continue; - } - if target - .orphan_term - .is_some_and(|since| since.elapsed() >= GRACE + Duration::from_secs(2)) - { - return Err(refused()); - } - live = true; - if target - .orphan_term - .is_some_and(|since| since.elapsed() >= GRACE) - { - recovery.signal(libc::SIGKILL)?; + for target in active.iter_mut().filter(|target| !target.finished) { + match target.advance(start) { + Ok(true) => live = true, + Ok(false) => target.finished = true, + Err(error) => { + target.finished = true; + failure.get_or_insert(error); } } } if !live { - return Ok(()); + return failure.map_or(Ok(()), Err); } thread::sleep(TICK); } diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index 8e42f1057..eaf374e22 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1039,17 +1039,30 @@ fn dead_guard_with_live_session_recovers_term_then_kill() { #[test] fn guard_lost_mid_stop_keeps_admission_snapshot() { - guard_lost_during_stop(Duration::ZERO); + guard_lost_during_stop(Duration::ZERO, false); } #[test] fn guard_lost_late_in_stop_gets_full_recovery_deadline() { - guard_lost_during_stop(Duration::from_secs(3)); + guard_lost_during_stop(Duration::from_secs(3), false); } -fn guard_lost_during_stop(delay: Duration) { +#[test] +fn expired_target_does_not_abandon_other_recovery() { + guard_lost_during_stop(Duration::from_secs(3), true); +} + +fn guard_lost_during_stop(delay: Duration, blocked: bool) { let f = Fixture::new(); - let (mut caller, _) = f.start(vec![f.process("worker", "wrapper-term-child")]); + let mut processes = vec![f.process("worker", "wrapper-term-child")]; + if blocked { + processes.push(f.process("blocked", "hold")); + } + let (mut caller, _) = f.start(processes); + let blocked_guard = blocked.then(|| pid(&f.state("blocked"), "guardPid")); + if let Some(guard) = blocked_guard { + assert_eq!(unsafe { libc::kill(guard, libc::SIGSTOP) }, 0); + } eventually(|| f.root.join("wrapper-child-ready").exists().then_some(())); let state = f.state("worker"); let child = eventually(|| { @@ -1060,7 +1073,7 @@ fn guard_lost_during_stop(delay: Duration) { .and_then(identity) }) }); - f.owned.borrow_mut().push(child); + f.owned.borrow_mut().push(child.clone()); let mut stop = command(&f.root, "stop").spawn().unwrap(); send(stop.stdin.as_mut().unwrap(), &f.control(TOKEN)); // The guard has acted on this stop once its wrapper is dead. Its five-second @@ -1076,12 +1089,25 @@ fn guard_lost_during_stop(delay: Duration) { unsafe { libc::kill(pid(&state, "guardPid"), libc::SIGKILL) }, 0 ); - assert!(wait(&mut caller, TIMEOUT).success()); + if !blocked { + assert!(wait(&mut caller, TIMEOUT).success()); + } let receipt = receive(stop.stdout.as_mut().unwrap()); - assert!(wait(&mut stop, TIMEOUT).success(), "{receipt}"); - assert_eq!(receipt["outcome"], "stopped"); - assert!(recovery_started.elapsed() >= Duration::from_secs(5)); - assert!(recovery_started.elapsed() < Duration::from_secs(7)); + let stopped = wait(&mut stop, TIMEOUT).success(); + assert_eq!(stopped, !blocked, "{receipt}"); + assert_eq!( + receipt["outcome"], + if blocked { "unknown" } else { "stopped" } + ); + // Scheduling may let the guard itself finish escalation. Either path must + // confirm absence; do not mistake test observation latency for grace timing. + assert!(recovery_started.elapsed() < TIMEOUT); + assert!(identity(child.pid).is_none() || process_is_zombie(child.pid)); + if let Some(guard) = blocked_guard { + assert_eq!(unsafe { libc::kill(guard, libc::SIGCONT) }, 0); + f.stop(); + assert!(wait(&mut caller, TIMEOUT).success()); + } } #[test] diff --git a/tests/models/tla/lifecycle-supervisor/COVERAGE.md b/tests/models/tla/lifecycle-supervisor/COVERAGE.md index 8f699fd69..400a0eebf 100644 --- a/tests/models/tla/lifecycle-supervisor/COVERAGE.md +++ b/tests/models/tla/lifecycle-supervisor/COVERAGE.md @@ -96,7 +96,11 @@ after TERM ended its wrapper and checks KILL and confirmed absence for its child Recovery deadlines are not modeled: each recovered target receives a fresh five-second TERM grace plus two seconds for confirmation, independently of the original guard wait. guard_lost_late_in_stop_gets_full_recovery_deadline kills the guard three -seconds after wrapper death and checks the full recovery grace and successful -KILL confirmation; the early-loss variant checks the same boundary without delay. +seconds after wrapper death and checks successful KILL confirmation and child absence; the early-loss variant checks the same boundary without delay. Live-guard admission snapshots are best-effort; a denied census does not prevent the guard-owned shutdown path. Orphan capture stays strict (unit-tested admission seam). + +The census-to-guard-loss handoff and multi-target timeout scheduling are native +implementation boundaries, not modeled. guarded_live rechecks guard loss after +a failed census. expired_target_does_not_abandon_other_recovery checks that one +paused guard timing out does not prevent another target from completing KILL. From 59bd8d52923e9e32f47d0578a3ffcced8c69a464 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:12:54 -0500 Subject: [PATCH 38/55] test(supervisor): make guard-loss recovery regressions deterministic Freeze the guard before removing it and prove it has not begun its own escalation and that the TERM-ignoring child is still live, so only the stop's admission snapshot can KILL the child. Require a fresh five-second recovery grace after guard loss. Align the documented model summary with the maintained 426-state coverage. Co-Authored-By: Claude Opus 5.5 --- docs/lifecycle-supervisor.md | 6 ++-- .../lifecycle-supervisor/tests/fake_caller.rs | 31 +++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 2c27c1b33..3221004d8 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -207,8 +207,10 @@ the exact 1,048,576 test requires a host that permits that limit. Darwin compilation does not qualify execution, group-signal EPERM recovery or idle cost on a real Mac. -The 322-state TLA+ artifact is an abstract phase/ownership exploration. It models SID reuse and a required old-birth witness for orphan signaling. It does -not model actual kernel census observations, real flock behavior, full orphan recovery, +The 426-state TLA+ artifact is an abstract phase/ownership exploration. It models +SID reuse, an exact recorded-incarnation witness for orphan signaling, witness loss +after capture and the retained-snapshot KILL step. It does +not model actual kernel census observations, real flock behavior, recovery timing, startup descriptor leases or signal errors. Some safety predicates hold by construction. Native regressions, rather than that model, are the evidence for those boundaries; see the model coverage document for the reduced claims. diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index eaf374e22..420ded1da 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1084,11 +1084,24 @@ fn guard_lost_during_stop(delay: Duration, blocked: bool) { .then_some(()) }); thread::sleep(delay); - let recovery_started = Instant::now(); + // Freeze the guard before removing it, then prove it has not begun its own + // escalation: it persists kill-pending before its first KILL. The child must + // still be live, so only the stop's admission snapshot can KILL it. + let guard = pid(&state, "guardPid"); + assert_eq!(unsafe { libc::kill(guard, libc::SIGSTOP) }, 0); + let frozen = f.state("worker"); assert_eq!( - unsafe { libc::kill(pid(&state, "guardPid"), libc::SIGKILL) }, - 0 + frozen["phase"], "stopping", + "guard escalated first: {frozen}" ); + assert!( + identity(child.pid).is_some_and(|current| current.birth == child.birth) + && !process_is_zombie(child.pid), + "TERM-ignoring child exited before guard loss" + ); + let recovery_started = Instant::now(); + // SAFETY: the frozen guard belongs to this fixture. + assert_eq!(unsafe { libc::kill(guard, libc::SIGKILL) }, 0); if !blocked { assert!(wait(&mut caller, TIMEOUT).success()); } @@ -1099,9 +1112,15 @@ fn guard_lost_during_stop(delay: Duration, blocked: bool) { receipt["outcome"], if blocked { "unknown" } else { "stopped" } ); - // Scheduling may let the guard itself finish escalation. Either path must - // confirm absence; do not mistake test observation latency for grace timing. - assert!(recovery_started.elapsed() < TIMEOUT); + // Recovery starts its own TERM grace after detecting guard loss, so KILL of + // the captured child cannot precede a fresh five-second interval, even when + // the original guard deadline had almost expired. + let recovered_after = recovery_started.elapsed(); + assert!( + recovered_after >= Duration::from_secs(5), + "recovery skipped its fresh grace: {recovered_after:?}" + ); + assert!(recovered_after < TIMEOUT); assert!(identity(child.pid).is_none() || process_is_zombie(child.pid)); if let Some(guard) = blocked_guard { assert_eq!(unsafe { libc::kill(guard, libc::SIGCONT) }, 0); From 98d0b0a401ba0864b86ded6257782a01c78eb80b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:15:09 -0500 Subject: [PATCH 39/55] test(supervisor): wait for kernel-observed guard stop before guard loss kill(SIGSTOP) only submits the signal. Wait until the kernel reports the same guard incarnation stopped (Linux /proc state, Darwin SSTOP) before checking its persisted phase and the child's liveness, so the guard cannot escalate after those observations. Co-Authored-By: Claude Opus 5.5 --- .../lifecycle-supervisor/tests/fake_caller.rs | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index 420ded1da..ce7122580 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -1088,7 +1088,15 @@ fn guard_lost_during_stop(delay: Duration, blocked: bool) { // escalation: it persists kill-pending before its first KILL. The child must // still be live, so only the stop's admission snapshot can KILL it. let guard = pid(&state, "guardPid"); + let guard_birth = identity(guard).expect("guard identity").birth; assert_eq!(unsafe { libc::kill(guard, libc::SIGSTOP) }, 0); + // SIGSTOP delivery is asynchronous: wait until the kernel reports this + // guard incarnation stopped before trusting the observations below. + eventually(|| { + (identity(guard).is_some_and(|current| current.birth == guard_birth) + && process_is_stopped(guard)) + .then_some(()) + }); let frozen = f.state("worker"); assert_eq!( frozen["phase"], "stopping", @@ -1172,12 +1180,24 @@ fn orphan_recovery_keeps_observed_child_after_term_ends_witness() { } fn process_is_zombie(pid: i32) -> bool { + process_state(pid) == Some(b'Z') +} + +/// Job-control stop observed by the kernel, not merely a submitted SIGSTOP. +fn process_is_stopped(pid: i32) -> bool { + matches!(process_state(pid), Some(b'T' | b't')) +} + +/// Linux /proc state letter; Darwin maps SZOMB to Z and SSTOP to T. +fn process_state(pid: i32) -> Option { #[cfg(target_os = "linux")] { fs::read_to_string(format!("/proc/{pid}/stat")) .ok() - .and_then(|s| s.rsplit_once(") ").map(|(_, rest)| rest.starts_with('Z'))) - .unwrap_or(false) + .and_then(|s| { + s.rsplit_once(") ") + .and_then(|(_, rest)| rest.bytes().next()) + }) } #[cfg(target_os = "macos")] { @@ -1192,8 +1212,14 @@ fn process_is_zombie(pid: i32) -> bool { std::mem::size_of::() as i32, ) }; - size as usize == std::mem::size_of::() - && unsafe { info.assume_init() }.pbi_status == libc::SZOMB + if size as usize != std::mem::size_of::() { + return None; + } + Some(match unsafe { info.assume_init() }.pbi_status { + libc::SZOMB => b'Z', + libc::SSTOP => b'T', + _ => b'R', + }) } } From 616b2f5325851c5ff645a445eabd2084d545da06 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:28:15 -0500 Subject: [PATCH 40/55] fix(lifecycle): refuse lost ownership state after supervisor use A missing .hack/.internal/lifecycle/state.json read as an empty inventory even when the durable supervisor-state marker showed native ownership had been published, so down, status and runner changes could skip native retirement while commands kept running. With the marker present, a missing file is now refused like unparsable JSON. Publication writes the entry before the marker, so a first launch (no file yet) and a crash between the two never leave a marker without readable state. Document that neither runner restarts an exited persistent command and that automatic restart with backoff is deferred, replacing the stale assignment of restart policy to this integration. Co-Authored-By: Claude Opus 5.5 --- docs/lifecycle-supervisor.md | 6 +- docs/lifecycle.md | 10 ++- src/lib/lifecycle-runtime.ts | 44 ++++++++----- tests/lifecycle-state-publication.test.ts | 32 ++++++++++ tests/lifecycle-supervisor-controller.test.ts | 61 ++++++++++++++++++- 5 files changed, 134 insertions(+), 19 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 4bd08d90a..e251257cf 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -330,8 +330,10 @@ Linux ownership checks read the process's effective UID from `/proc//status They do not infer UID from proc-file ownership, which changes when a command disables dumpability. Such a command remains recoverable after guard loss. -Restart policy and restart backoff belong to the CLI integration in #811, not -this binary's command ownership loop. This crate does not restart commands. +This crate does not restart commands, and the CLI opt-in does not add a restart +policy either: a persistent command that exits stays down under both runners +until `hack restart` or `hack up`. Automatic restart with crash-loop backoff is +deferred to a later slice and would belong to the CLI, not this ownership loop. Logs are append-only and have no automatic rotation. Stop all generations and confirm native absence before archiving the private root/log and starting with diff --git a/docs/lifecycle.md b/docs/lifecycle.md index c1c420abc..842412dfd 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -699,7 +699,10 @@ 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 +runner setting changes. Neither runner restarts a persistent command that exits or +crashes: it stays down, its exit is recorded, and status reports it as not live. +Run `hack restart` (or `hack up`) to start it again. Automatic restart with backoff +is not part of this opt-in. 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. @@ -710,7 +713,10 @@ 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. +disables that fallback: losing ownership may hide running native commands. With +that marker present, a missing `state.json` is also refused rather than read as +"nothing running", so `hack down`, status and runner changes cannot skip native +retirement. Removing entries rewrites the file; Hack never deletes it. 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. diff --git a/src/lib/lifecycle-runtime.ts b/src/lib/lifecycle-runtime.ts index 4b0d50e14..bd143759e 100644 --- a/src/lib/lifecycle-runtime.ts +++ b/src/lib/lifecycle-runtime.ts @@ -186,6 +186,12 @@ export async function readLifecycleState(opts: { refusal: lifecycleStateRefused, }); if (file === null) { + // Removal rewrites an empty entry list and never deletes this file. Once + // supervisor ownership was recorded, a missing file is lost state, not + // absence: native commands may still run. + if (await supervisorStateRecorded(root)) { + throw lifecycleStateRefused(); + } return []; } await verifyLifecycleStateTarget(path); @@ -194,14 +200,7 @@ export async function readLifecycleState(opts: { try { parsed = JSON.parse(text); } catch { - if ( - await lstat(resolve(root, "supervisor-state")).catch((error: unknown) => { - if (isRecord(error) && error.code === "ENOENT") { - return null; - } - throw error; - }) - ) { + if (await supervisorStateRecorded(root)) { throw lifecycleStateRefused(); } if (!opts.stateLocked) { @@ -244,12 +243,8 @@ export async function upsertLifecycleStateEntry(opts: { readonly entry: LifecycleStateEntry; }): Promise { return await mutateLifecycleState(opts.projectDir, async () => { - if (opts.entry.backend === "supervisor") { - await appendPrivateFile({ - path: resolve(resolveLifecycleRootDir(opts), "supervisor-state"), - data: "", - }); - } + // Read before marking: the marker turns a missing state file into a + // refusal, and a project's first supervisor entry has no file yet. const state = await readLifecycleState({ projectDir: opts.projectDir, stateLocked: true, @@ -266,6 +261,14 @@ export async function upsertLifecycleStateEntry(opts: { projectDir: opts.projectDir, state: next, }); + // The entry is durable before the marker and before any native launch, so + // a crash between them never leaves a marker without a readable file. + if (opts.entry.backend === "supervisor") { + await appendPrivateFile({ + path: resolve(resolveLifecycleRootDir(opts), "supervisor-state"), + data: "", + }); + } }); } @@ -599,6 +602,19 @@ export async function drainLifecycleOutput(opts: { } } +/** Durable marker written once a supervisor entry has been published. */ +async function supervisorStateRecorded(root: string): Promise { + const marker = await lstat(resolve(root, "supervisor-state")).catch( + (error: unknown) => { + if (isRecord(error) && error.code === "ENOENT") { + return null; + } + throw error; + } + ); + return marker !== null; +} + function lifecycleStateRefused(): Error { return new Error( "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/tests/lifecycle-state-publication.test.ts b/tests/lifecycle-state-publication.test.ts index 0efbde7c5..25e4a6b7d 100644 --- a/tests/lifecycle-state-publication.test.ts +++ b/tests/lifecycle-state-publication.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { existsSync } from "node:fs"; import { chmod, link, @@ -285,3 +286,34 @@ test("supervisor ownership disables legacy JSON recovery", async () => { expect(await readFile(path, "utf8")).toBe("{"); }); }); + +test("missing state after supervisor ownership refuses instead of proving absence", async () => { + await fixture(async ({ projectDir, root, path }) => { + // First supervisor publication starts with no state file at all. + await upsertLifecycleStateEntry({ projectDir, entry: entry("native") }); + expect(await lstat(join(root, "supervisor-state"))).toBeTruthy(); + await rm(path); + await expect(readLifecycleState({ projectDir })).rejects.toThrow( + "Lifecycle ownership state" + ); + await expect( + upsertLifecycleStateEntry({ projectDir, entry: entry("other") }) + ).rejects.toThrow("Lifecycle ownership state"); + await expect( + removeLifecycleStateEntry({ projectDir, composeProject: "native" }) + ).rejects.toThrow("Lifecycle ownership state"); + expect(existsSync(path)).toBe(false); + }); +}); + +test("missing state without supervisor history remains empty", async () => { + await fixture(async ({ projectDir, root }) => { + await upsertLifecycleStateEntry({ + projectDir, + entry: { ...entry("mux"), backend: "tmux" }, + }); + await rm(resolveLifecycleStatePath({ projectDir })); + expect(existsSync(join(root, "supervisor-state"))).toBe(false); + expect(await readLifecycleState({ projectDir })).toEqual([]); + }); +}); diff --git a/tests/lifecycle-supervisor-controller.test.ts b/tests/lifecycle-supervisor-controller.test.ts index 7caea59fb..e89132be2 100644 --- a/tests/lifecycle-supervisor-controller.test.ts +++ b/tests/lifecycle-supervisor-controller.test.ts @@ -1,11 +1,19 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { readLifecycleState, resolveLifecycleLogPath, + resolveLifecycleStatePath, } from "../src/lib/lifecycle-runtime.ts"; import { inspectLifecycleSupervisor, @@ -624,3 +632,54 @@ nativeTest( }), 30_000 ); + +nativeTest( + "lost common state keeps native work unconfirmed instead of stopped", + () => + fixture(async (project) => { + const owner = controller(project); + await owner.startMany({ processes: [{ name: "worker", command }] }); + await owner.finalize(); + const entry = (await readLifecycleState(project))[0]; + const commandPid = entry?.processes[0]?.commandPid; + const ownershipToken = entry?.ownershipToken; + if (!(commandPid && ownershipToken)) { + throw new Error("fixture command was not recorded"); + } + const statePath = resolveLifecycleStatePath(project); + const aside = `${statePath}.test-aside`; + await rename(statePath, aside); + const stop = () => + stopLifecycleProcessController({ + project, + projectName: "fixture", + composeProject: "fixture", + branch: null, + definitionHash: "synthetic", + mux: async () => { + throw new Error("mux must not be consulted"); + }, + }); + await expect(stop()).rejects.toThrow("Lifecycle ownership state"); + await expect( + inspectLifecycleSupervisor({ + ...project, + composeProject: "fixture", + ownershipToken, + }) + ).rejects.toThrow(); + // Nothing was signalled: the native command still runs. + expect(() => process.kill(commandPid, 0)).not.toThrow(); + await rename(aside, statePath); + await stop(); + await eventually(async () => { + try { + process.kill(commandPid, 0); + return false; + } catch { + return true; + } + }); + }), + 30_000 +); From b2f997f4a15186e34f87394bb438ab77ec56c361 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:38:08 -0500 Subject: [PATCH 41/55] fix(release): refuse upgrades whose supervisor companion mismatches its CLI Self-upgrade published a checksum-valid release without checking that its hack-lifecycle-supervisor answers the handshake the new CLI requires, so a wrong or missing helper could replace a working pair and leave supervisor ownership unretirable through that installation. The staged CLI now declares its required handshake through a hidden argument, and publication refuses, unchanged, when the staged helper is missing or answers differently. CLIs that predate the helper are still accepted. Release acceptance now checks the exact --version reply of installed and relocated helpers and that each installed CLI declares the same handshake. Co-Authored-By: Claude Opus 5.5 --- docs/guides/prereleases.md | 6 +- docs/versions/v5/install.md | 6 +- index.ts | 9 +++ scripts/check-release-lifecycle-supervisor.ts | 40 ++++++++++- src/lib/lifecycle-supervisor-handshake.ts | 11 +++ src/lib/lifecycle-supervisor.ts | 4 +- src/lib/release-install.ts | 11 ++- src/lib/release-version.ts | 46 +++++++++++++ tests/upgrade-installer.test.ts | 69 +++++++++++++++++++ 9 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 src/lib/lifecycle-supervisor-handshake.ts diff --git a/docs/guides/prereleases.md b/docs/guides/prereleases.md index a9c6bd62e..39ff6351c 100644 --- a/docs/guides/prereleases.md +++ b/docs/guides/prereleases.md @@ -280,5 +280,9 @@ 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 +bytes and SHA-256 in the backup, and restore it if publication fails. +Before publishing anything, a self-upgrade asks the staged CLI which helper +handshake it requires and refuses the release, leaving the installation +unchanged, if the staged helper is missing or answers differently. Releases +whose CLI predates the helper are still accepted. 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 0acfb0d6a..cf354eee8 100644 --- a/docs/versions/v5/install.md +++ b/docs/versions/v5/install.md @@ -122,5 +122,9 @@ 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 +bytes and SHA-256 in the backup, and restore it if publication fails. +Before publishing anything, a self-upgrade asks the staged CLI which helper +handshake it requires and refuses the release, leaving the installation +unchanged, if the staged helper is missing or answers differently. Releases +whose CLI predates the helper are still accepted. Keep the helper with its matching CLI when moving an installation. diff --git a/index.ts b/index.ts index 55319426e..eed28784d 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,9 @@ #!/usr/bin/env bun +import { + LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT, + LIFECYCLE_SUPERVISOR_VERSION, +} from "./src/lib/lifecycle-supervisor-handshake.ts"; import { finishNativeCpuDiagnostics, initializeNativeCpuDiagnostics, @@ -25,6 +29,11 @@ if (Bun.argv[2] === NATIVE_DETACHED_OWNER_ARGUMENT) { ); } +if (Bun.argv[2] === LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT) { + process.stdout.write(`${LIFECYCLE_SUPERVISOR_VERSION}\n`); + process.exit(0); +} + if (Bun.argv[2] === TTY_SUPERVISOR_ARGUMENT && process.send) { process.exit(await runTtySupervisor()); } diff --git a/scripts/check-release-lifecycle-supervisor.ts b/scripts/check-release-lifecycle-supervisor.ts index b02979a92..573dc8476 100644 --- a/scripts/check-release-lifecycle-supervisor.ts +++ b/scripts/check-release-lifecycle-supervisor.ts @@ -12,6 +12,10 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { + LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT, + LIFECYCLE_SUPERVISOR_VERSION, +} from "../src/lib/lifecycle-supervisor-handshake.ts"; const name = "hack-lifecycle-supervisor"; const release = resolve(Bun.argv[2] ?? "dist/release"); @@ -95,6 +99,15 @@ try { Buffer.from(await Bun.file(installed).bytes()).equals(Buffer.from(bytes)), "installed bytes" ); + // The installed CLI must declare the same handshake its sibling answers; + // its supervisor preflight refuses any other reply. + assert( + (await stdoutOf( + [join(bin, "hack"), LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT], + env + )) === LIFECYCLE_SUPERVISOR_VERSION, + "installed CLI handshake" + ); const relocated = join(fixture, name); await copyFile(installed, relocated); await chmod(relocated, 0o755); @@ -102,6 +115,12 @@ try { if (process.platform === "darwin") { await run(["/usr/bin/codesign", "--verify", "--strict", binary], env); } + // Same empty environment as the CLI preflight; exact reply required. + assert( + (await stdoutOf([binary, "--version"], {})) === + LIFECYCLE_SUPERVISOR_VERSION, + "installed supervisor handshake" + ); // Empty framed input must refuse without launching work or consulting any home. const child = Bun.spawn([binary, "status"], { env, @@ -139,7 +158,7 @@ try { 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` + `Release supervisor acceptance (${process.platform}/${process.arch}): archive checksum, normal/slim/local-bin installs, exact bytes, modes, CLI/supervisor handshake, relocation and framed refusal passed\n` ); } finally { await rm(root, { recursive: true, force: true }); @@ -169,3 +188,22 @@ async function run( ]); assert(code === 0, `installer/extraction: ${stderr}`); } +async function stdoutOf( + command: string[], + env: Record +): Promise { + const child = Bun.spawn(command, { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 15_000, + }); + const [code, stdout] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + assert(code === 0, `${command.at(-1)} exit`); + return stdout.trim(); +} diff --git a/src/lib/lifecycle-supervisor-handshake.ts b/src/lib/lifecycle-supervisor-handshake.ts new file mode 100644 index 000000000..d021ecc11 --- /dev/null +++ b/src/lib/lifecycle-supervisor-handshake.ts @@ -0,0 +1,11 @@ +/** Exact `--version` reply this CLI requires from its sibling supervisor. */ +export const LIFECYCLE_SUPERVISOR_VERSION = + "hack-lifecycle-supervisor 0.1.0 protocol=1 incremental=1"; + +/** + * Hidden CLI argument printing LIFECYCLE_SUPERVISOR_VERSION, so an installer + * can check a staged companion against its staged CLI. CLIs released before + * the companion do not answer it. + */ +export const LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT = + "--internal-lifecycle-supervisor-handshake"; diff --git a/src/lib/lifecycle-supervisor.ts b/src/lib/lifecycle-supervisor.ts index 0a9e751da..a3d254d1e 100644 --- a/src/lib/lifecycle-supervisor.ts +++ b/src/lib/lifecycle-supervisor.ts @@ -8,6 +8,7 @@ import { readLifecycleState, resolveLifecycleLogPath, } from "./lifecycle-runtime.ts"; +import { LIFECYCLE_SUPERVISOR_VERSION } from "./lifecycle-supervisor-handshake.ts"; import { ensurePrivateDirectory } from "./private-file.ts"; import { withProjectsRegistryLock } from "./projects-registry-lock.ts"; @@ -106,9 +107,6 @@ 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; diff --git a/src/lib/release-install.ts b/src/lib/release-install.ts index 2ef21e4a4..6ae70d550 100644 --- a/src/lib/release-install.ts +++ b/src/lib/release-install.ts @@ -17,7 +17,10 @@ import { import { dirname, join } from "node:path"; import { withProjectsRegistryLock } from "./projects-registry-lock.ts"; import { markReleaseStage, reclaimReleaseStages } from "./release-staging.ts"; -import { verifyStagedReleaseVersion } from "./release-version.ts"; +import { + verifyStagedLifecycleSupervisor, + verifyStagedReleaseVersion, +} from "./release-version.ts"; async function present(path: string) { return await lstat(path).catch((error: NodeJS.ErrnoException) => { @@ -229,6 +232,12 @@ export async function publishRelease(opts: { await syncTree(backup); await syncTree(binaryStage); await verifyStagedReleaseVersion(next, opts.version); + await verifyStagedLifecycleSupervisor({ + cli: next, + supervisor: hasSupervisor + ? join(binaryStage, "hack-lifecycle-supervisor") + : null, + }); opts.checkpoint?.("staged"); assetsIdentity = await lstat(assetsStage); binaryIdentity = await lstat(next); diff --git a/src/lib/release-version.ts b/src/lib/release-version.ts index 763265d4c..4e78a5ad8 100644 --- a/src/lib/release-version.ts +++ b/src/lib/release-version.ts @@ -1,3 +1,4 @@ +import { LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT } from "./lifecycle-supervisor-handshake.ts"; import { execOrThrow } from "./shell.ts"; /** Execute only a checksum-verified staged CLI and require its documented output. */ @@ -15,3 +16,48 @@ export async function verifyStagedReleaseVersion( ); } } + +const HANDSHAKE_PATTERN = + /^hack-lifecycle-supervisor \S+ protocol=\d+ incremental=\d+$/; + +async function outputOf(command: readonly string[]): Promise { + const child = Bun.spawn([...command], { + env: {}, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + timeout: 10_000, + }); + const [code, stdout] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + ]); + return code === 0 ? stdout.trim() : null; +} + +/** + * Before publication, require the staged companion to answer exactly the + * handshake its staged CLI expects, so an upgrade cannot replace a working + * pair with a CLI whose supervisor preflight (and therefore native stop) + * fails. Staged CLIs that do not declare a handshake are legacy releases. + */ +export async function verifyStagedLifecycleSupervisor(opts: { + readonly cli: string; + readonly supervisor: string | null; +}): Promise { + const expected = await outputOf([ + opts.cli, + LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT, + ]); + if (expected === null || !HANDSHAKE_PATTERN.test(expected)) { + return; + } + const actual = opts.supervisor + ? await outputOf([opts.supervisor, "--version"]) + : null; + if (actual !== expected) { + throw new Error( + "Release lifecycle supervisor companion is missing or does not match its CLI; installation unchanged." + ); + } +} diff --git a/tests/upgrade-installer.test.ts b/tests/upgrade-installer.test.ts index be686a805..f965e8157 100644 --- a/tests/upgrade-installer.test.ts +++ b/tests/upgrade-installer.test.ts @@ -14,6 +14,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { writeReleaseArchive } from "../scripts/build-release.ts"; import { download } from "../scripts/installer/download.ts"; +import { + LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT, + LIFECYCLE_SUPERVISOR_VERSION, +} from "../src/lib/lifecycle-supervisor-handshake.ts"; import { publishRelease } from "../src/lib/release-install.ts"; import { verifyReleaseDirectory } from "../src/lib/release-integrity.ts"; import { @@ -411,6 +415,71 @@ for (const phase of [ ).toBe(false); }); } +/** A release CLI that declares a companion handshake, as current builds do. */ +async function requireCompanion(release: string, reply: string | null) { + const handshake = LIFECYCLE_SUPERVISOR_VERSION; + await writeFile( + join(release, "hack"), + `#!/bin/sh\ncase "$0" in */.hack-update-stage-*/hack) ;; *) exit 93 ;; esac\nif [ "$1" = '${LIFECYCLE_SUPERVISOR_HANDSHAKE_ARGUMENT}' ]; then printf '%s\\n' '${handshake}'; exit 0; fi\nprintf '%s\\n' 'hack v4.3.1'\n`, + { mode: 0o755 } + ); + const companion = join(release, "hack-lifecycle-supervisor"); + if (reply === null) { + await rm(companion); + } else { + await writeFile( + companion, + `#!/bin/sh\n[ "$1" = --version ] && printf '%s\\n' '${reply}'\n`, + { mode: 0o755 } + ); + } +} +for (const [label, reply] of [ + ["missing", null], + ["incompatible", "hack-lifecycle-supervisor 0.0.9 protocol=0 incremental=0"], +] as const) { + test(`${label} companion for a handshake-declaring CLI leaves the installation unchanged`, async () => { + const root = await scratch(); + const f = await releaseFixture(root); + await requireCompanion(f.release, reply); + const bin = join(root, "bin"); + await mkdir(bin); + const binary = join(bin, "hack"); + const supervisor = join(bin, "hack-lifecycle-supervisor"); + await writeFile(binary, "old CLI"); + await writeFile(supervisor, "old supervisor", { mode: 0o700 }); + await expect( + publishRelease({ + version: "4.3.1", + extractedReleaseDir: f.release, + binaryPath: binary, + assetsDir: join(root, "assets"), + }) + ).rejects.toThrow("companion is missing or does not match its CLI"); + expect(await readFile(binary, "utf8")).toBe("old CLI"); + expect(await readFile(supervisor, "utf8")).toBe("old supervisor"); + expect((await readdir(bin)).sort()).toEqual([ + "hack", + "hack-lifecycle-supervisor", + ]); + }); +} +test("matching companion handshake publishes the pair", async () => { + const root = await scratch(); + const f = await releaseFixture(root); + await requireCompanion(f.release, LIFECYCLE_SUPERVISOR_VERSION); + const binary = join(root, "bin/hack"); + await publishRelease({ + version: "4.3.1", + extractedReleaseDir: f.release, + binaryPath: binary, + assetsDir: join(root, "assets"), + }); + expect( + await readFile(join(root, "bin/hack-lifecycle-supervisor"), "utf8") + ).toContain(LIFECYCLE_SUPERVISOR_VERSION); +}); + test("failed first supervisor publication removes the new sibling", async () => { const root = await scratch(); const f = await releaseFixture(root); From 2fb3e0eacbd131d2113227510c08c0326c50e78c Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 02:51:33 +0000 Subject: [PATCH 42/55] test(e2e): register the recovery scenario's tmux root with the socket guard next's tmux socket guard (#845) admits only registered private socket roots. lifecycle-session-recovery points TMUX_TMPDIR at its own fixture root, so its first tmux call was refused, and its cleanup then threw before restoring TMUX_TMPDIR, which broke later scenarios in the shard with ENOENT on the deleted root. Register the fixture root (mkdtemp, 0700) for the scenario and always restore and unregister in an inner finally. Co-Authored-By: Claude Opus 5.5 --- .../scenarios/lifecycle-session-recovery.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/e2e/scenarios/lifecycle-session-recovery.ts b/tests/e2e/scenarios/lifecycle-session-recovery.ts index 70db47f35..cdd6a4d0d 100644 --- a/tests/e2e/scenarios/lifecycle-session-recovery.ts +++ b/tests/e2e/scenarios/lifecycle-session-recovery.ts @@ -1,6 +1,7 @@ import { chmod, copyFile, mkdir } from "node:fs/promises"; import { join, resolve } from "node:path"; import { restoreEnv } from "../../helpers/env.ts"; +import { registerPrivateTmuxRoot } from "../../helpers/tmux-socket-guard.ts"; import { resolveExecutableCapability } from "../capabilities.ts"; import { createMonorepoFixture } from "../fixture.ts"; @@ -50,6 +51,9 @@ export const lifecycleSessionRecoveryScenario: Scenario = { const path = `${fakeBin}:${process.env.PATH ?? ""}`; const dockerLog = join(ctx.tempRoot, "fake-docker.log"); const previousTmuxRoot = process.env.TMUX_TMPDIR; + // The run's tmux socket guard admits only registered private roots; this + // scenario owns its fixture root (mkdtemp, 0700) for its own tmux server. + const unregisterTmuxRoot = registerPrivateTmuxRoot(ctx.tempRoot); process.env.TMUX_TMPDIR = ctx.tempRoot; const cliEnv = { PATH: path, @@ -562,14 +566,19 @@ export const lifecycleSessionRecoveryScenario: Scenario = { }); } } finally { - await runCommand({ - 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); + try { + await runCommand({ + 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 }); + } finally { + // Never leak this fixture's socket root into later scenarios. + restoreEnv("TMUX_TMPDIR", previousTmuxRoot); + unregisterTmuxRoot(); + } } }, }; From 2d9c4cef9f5cd4e9b8a248325fe3e90b74305278 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 02:29:07 +0000 Subject: [PATCH 43/55] fix(supervisor): treat pidfd_open EINVAL for an exited group leader as absent On Linux 6.8, pidfd_open returns EINVAL rather than ESRCH for a PID that survives only as a process-group or session ID after its process exited. A recovery stop whose wrapper died on TERM then failed before sending KILL to the wrapper's TERM-ignoring child and reported unknown. Flags are zero, so EINVAL means no thread-group leader exists; skip that member and let the identity-checked liveness pass decide the outcome. Four recovery fake-caller tests failed on a 6.8 host without this change and pass with it. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 416225380ef509b894f688894052f4523890fb35) --- packages/lifecycle-supervisor/src/os.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index d9c708cc1..d739d344b 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -467,7 +467,11 @@ fn signal_member(expected: &Identity, signal: i32) -> Result<()> { 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) { + // Some kernels (observed on 6.8) return EINVAL, not ESRCH, for a PID + // kept alive only as a process-group or session ID after its process + // exited. Flags are zero, so EINVAL means no thread-group leader + // exists for that PID; liveness is still decided by `inspect` later. + if absent_error(&error) || error.raw_os_error() == Some(libc::EINVAL) { return Ok(()); } return Err(error); From bf664de47f9e88489151b10ce01204aab953a417 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:02:29 +0000 Subject: [PATCH 44/55] test(supervisor): pin pidfd_open EINVAL handling behind a seam Hosted runners use kernels newer than 6.8 and never return EINVAL here, so the four fake-caller recovery tests only exercise it on a 6.8 host. Move the open into pidfd_open() with a test-only errno injection and add unit tests with two real members: EINVAL or ESRCH from the first open skips it without a signal and recovery still delivers KILL to the survivor; any other errno refuses without signalling either. The comment now states when 6.8 returns EINVAL and that the absent result can only withhold a signal. Linux-only; Darwin code is unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/lifecycle-supervisor.md | 4 +- packages/lifecycle-supervisor/src/os.rs | 142 +++++++++++++++++++++--- 2 files changed, 130 insertions(+), 16 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 3221004d8..0e0f3cff3 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -125,7 +125,9 @@ refused. Stop can recover the recorded session after verifying the boot, leader identity and each remaining member's UID, birth and session. Recovery delivers TERM individually, grants a fresh five-second grace, then KILL to the captured incarnations and confirms absence. The capture remains authoritative if TERM ends the witness; -every signal still rechecks that member's PID, birth, UID and session (pidfd on Linux). +every signal still rechecks that member's PID, birth, UID and session (pidfd on Linux). A pidfd open that +reports the process missing (`ESRCH`, or `EINVAL` on Linux 6.8 when the PID survives only as a group or +session ID) skips that member without a signal; liveness is still decided by identity afterwards. Unobserved descendants never inherit this authority and retain an unknown outcome. Unverified identity never authorizes signals or a successful stop. Launch and stop share a bounded lock; a startup descriptor lease keeps admission locked through guard publication even if the project owner dies. diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index d739d344b..5ae6c5f3a 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -459,24 +459,45 @@ pub fn session_signal(session: i32, signal: i32) -> Result<()> { Ok(()) } +/// Open a pidfd for `pid`, or `None` when no process with that thread-group +/// leader exists any more. +/// +/// With zero flags, `pidfd_open` reports a missing process as `ESRCH` on newer +/// kernels, but as `EINVAL` on Linux 6.8 when the PID survives only as a +/// process-group or session ID (its process exited while members of its group +/// or session still run), because no thread-group leader exists for it. Both +/// mean "no process to signal". Treating them as absent can only withhold a +/// signal, never send one: identity-checked liveness decides the outcome later. +#[cfg(target_os = "linux")] +fn pidfd_open(pid: i32) -> Result> { + use std::os::fd::{FromRawFd, OwnedFd}; + #[cfg(test)] + if let Some(errno) = tests::injected_pidfd_open_error(pid) { + return pidfd_open_failure(io::Error::from_raw_os_error(errno)); + } + // SAFETY: pidfd_open returns a newly owned CLOEXEC descriptor. + let raw = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0u32) }; + if raw < 0 { + return pidfd_open_failure(io::Error::last_os_error()); + } + // SAFETY: a non-negative result is a descriptor this call now owns. + Ok(Some(unsafe { OwnedFd::from_raw_fd(raw as i32) })) +} + +#[cfg(target_os = "linux")] +fn pidfd_open_failure(error: io::Error) -> Result> { + if absent_error(&error) || error.raw_os_error() == Some(libc::EINVAL) { + return Ok(None); + } + Err(error) +} + 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(); - // Some kernels (observed on 6.8) return EINVAL, not ESRCH, for a PID - // kept alive only as a process-group or session ID after its process - // exited. Flags are zero, so EINVAL means no thread-group leader - // exists for that PID; liveness is still decided by `inspect` later. - if absent_error(&error) || error.raw_os_error() == Some(libc::EINVAL) { - return Ok(()); - } - return Err(error); - } - let fd = unsafe { OwnedFd::from_raw_fd(raw as i32) }; + let Some(fd) = pidfd_open(expected.pid)? else { + return Ok(()); + }; let Some(current) = inspect(expected.pid)? else { return Ok(()); }; @@ -866,6 +887,97 @@ pub fn timestamp() -> Result { #[cfg(test)] mod tests { + #[cfg(target_os = "linux")] + thread_local! { + static PIDFD_OPEN_ERROR: std::cell::Cell> = + const { std::cell::Cell::new(None) }; + } + + /// Test seam: the errno `pidfd_open` reports for one PID on this thread. + #[cfg(target_os = "linux")] + pub(super) fn injected_pidfd_open_error(pid: i32) -> Option { + PIDFD_OPEN_ERROR + .with(std::cell::Cell::get) + .and_then(|(target, errno)| (target == pid).then_some(errno)) + } + + /// Two real members: the first reports `errno` from its pidfd open, the + /// second is an ordinary survivor. Returns the recovery KILL result and + /// whether each member was killed. + #[cfg(target_os = "linux")] + fn kill_with_failed_first_open(errno: i32) -> (std::io::Result<()>, bool, bool) { + use std::os::unix::process::ExitStatusExt; + let spawn = || { + std::process::Command::new("/bin/sleep") + .arg("60") + .spawn() + .expect("spawn member") + }; + let (mut first, mut second) = (spawn(), spawn()); + let identity = |child: &std::process::Child| { + super::inspect(child.id() as i32) + .unwrap() + .expect("member identity") + }; + let recovery = super::Recovery { + session: 0, + members: vec![identity(&first), identity(&second)], + }; + PIDFD_OPEN_ERROR.with(|cell| cell.set(Some((first.id() as i32, errno)))); + let result = recovery.signal(libc::SIGKILL); + PIDFD_OPEN_ERROR.with(|cell| cell.set(None)); + let killed = |child: &mut std::process::Child| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status.signal() == Some(libc::SIGKILL); + } + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }; + let second_killed = killed(&mut second); + let first_killed = first.try_wait().unwrap().is_some(); + let _ = first.kill(); + let _ = first.wait(); + let _ = second.kill(); + let _ = second.wait(); + (result, first_killed, second_killed) + } + + /// Linux 6.8 reports EINVAL for a PID that survives only as a group or + /// session ID. That member is absent: it gets no signal, and recovery still + /// delivers KILL to the remaining captured member. Pinned on every kernel. + #[cfg(target_os = "linux")] + #[test] + fn pidfd_open_einval_is_absent_and_recovery_still_kills_survivor() { + let (result, first_killed, second_killed) = kill_with_failed_first_open(libc::EINVAL); + assert!(result.is_ok(), "{result:?}"); + assert!(!first_killed, "absent member was signalled"); + assert!(second_killed, "surviving member did not get KILL"); + } + + #[cfg(target_os = "linux")] + #[test] + fn pidfd_open_esrch_is_absent_and_recovery_still_kills_survivor() { + let (result, first_killed, second_killed) = kill_with_failed_first_open(libc::ESRCH); + assert!(result.is_ok(), "{result:?}"); + assert!(!first_killed); + assert!(second_killed); + } + + /// Any other open failure stays an error and authorizes no signal. + #[cfg(target_os = "linux")] + #[test] + fn pidfd_open_other_errors_refuse_without_signalling() { + let (result, first_killed, second_killed) = kill_with_failed_first_open(libc::EMFILE); + assert_eq!(result.unwrap_err().raw_os_error(), Some(libc::EMFILE)); + assert!(!first_killed); + assert!(!second_killed); + } + #[test] fn zombie_guard_birth_survives_discarded_exit_metadata() { let state = crate::storage::State { From 1fbf333371d68f85085cb4b70016feaf1c02c19d Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:09:21 +0000 Subject: [PATCH 45/55] test(supervisor): kill and reap pidfd seam members on any failure Co-Authored-By: Claude Opus 5.5 --- packages/lifecycle-supervisor/src/os.rs | 42 ++++++++++++++++--------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index 5ae6c5f3a..41fa44c69 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -901,6 +901,19 @@ mod tests { .and_then(|(target, errno)| (target == pid).then_some(errno)) } + /// A member process that is killed and reaped when dropped, so a failed + /// assertion or spawn cannot leak it. + #[cfg(target_os = "linux")] + struct Member(std::process::Child); + + #[cfg(target_os = "linux")] + impl Drop for Member { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + /// Two real members: the first reports `errno` from its pidfd open, the /// second is an ordinary survivor. Returns the recovery KILL result and /// whether each member was killed. @@ -908,14 +921,17 @@ mod tests { fn kill_with_failed_first_open(errno: i32) -> (std::io::Result<()>, bool, bool) { use std::os::unix::process::ExitStatusExt; let spawn = || { - std::process::Command::new("/bin/sleep") - .arg("60") - .spawn() - .expect("spawn member") + Member( + std::process::Command::new("/bin/sleep") + .arg("60") + .spawn() + .expect("spawn member"), + ) }; - let (mut first, mut second) = (spawn(), spawn()); - let identity = |child: &std::process::Child| { - super::inspect(child.id() as i32) + let mut first = spawn(); + let mut second = spawn(); + let identity = |member: &Member| { + super::inspect(member.0.id() as i32) .unwrap() .expect("member identity") }; @@ -923,13 +939,13 @@ mod tests { session: 0, members: vec![identity(&first), identity(&second)], }; - PIDFD_OPEN_ERROR.with(|cell| cell.set(Some((first.id() as i32, errno)))); + PIDFD_OPEN_ERROR.with(|cell| cell.set(Some((first.0.id() as i32, errno)))); let result = recovery.signal(libc::SIGKILL); PIDFD_OPEN_ERROR.with(|cell| cell.set(None)); - let killed = |child: &mut std::process::Child| { + let killed = |member: &mut Member| { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); loop { - if let Some(status) = child.try_wait().unwrap() { + if let Some(status) = member.0.try_wait().unwrap() { return status.signal() == Some(libc::SIGKILL); } if std::time::Instant::now() >= deadline { @@ -939,11 +955,7 @@ mod tests { } }; let second_killed = killed(&mut second); - let first_killed = first.try_wait().unwrap().is_some(); - let _ = first.kill(); - let _ = first.wait(); - let _ = second.kill(); - let _ = second.wait(); + let first_killed = first.0.try_wait().unwrap().is_some(); (result, first_killed, second_killed) } From 033358ac72c29ede3e6597b479ce8125bfce222e Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:17:45 +0000 Subject: [PATCH 46/55] fix(supervisor): frame every stop and status reply and count threaded leaders as live Every stop and status request now ends with exactly one framed receipt, also when it fails. A failure keeps the nonzero exit and reports outcome refused (invalid-request) or unknown with a reason: stop-lock-wait-expired, foreign-live-process, stop-incomplete, retired-owner-live or io-error. Before, a lock wait that expired, a refusal or an I/O error exited with a closed pipe, which a caller cannot tell from a crash. The .stop.lock wait for stop and launch is now 16 s, derived from the two TERM-to-KILL bounds a recovering stop can spend (about 14 s); the command lock keeps 10 s. On Linux, a thread-group leader whose main thread exited while worker threads run shows Z in /proc//stat. It was treated as a zombie, so recovery after guard loss neither captured nor signalled it and could report stopped (and admit a duplicate) while the workers survived. A leader now counts as a zombie only when no thread in /proc//task is live. Regression: main_thread_exit_with_live_worker_is_still_stopped (fails without the fix). Tests: tests that freeze the project supervisor or a guard right after start, or hold the stop lock, first wait until the fixture's .stop.lock is free. A launch writes its ready receipt just before it drops that lock and a guard keeps its startup lease until publication, so a frozen holder could make stop wait out its bound (the Mac failures of expired_target_does_not_abandon_other_recovery). eventually, wait, receive and exact are #[track_caller] so a timeout names its call site. Co-Authored-By: Claude Opus 5.5 --- docs/lifecycle-supervisor.md | 23 ++- packages/lifecycle-supervisor/README.md | 4 +- packages/lifecycle-supervisor/src/os.rs | 30 ++- packages/lifecycle-supervisor/src/storage.rs | 4 +- .../lifecycle-supervisor/src/supervisor.rs | 175 ++++++++++++------ .../lifecycle-supervisor/tests/fake_caller.rs | 174 +++++++++++++++++ 6 files changed, 347 insertions(+), 63 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 0e0f3cff3..95e0c8766 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -56,8 +56,25 @@ still report unknown. A failed or expired target does not abandon other admitted targets: stop drives them to their own outcomes before returning unknown. Replies contain `version`, `supervisorPid`, `processes`, and `outcome` (`ready`, -`observed`, `stopped`, or `unknown`). A stop whose confirmation times out emits +`observed`, `stopped`, `unknown` or `refused`). A stop whose confirmation times out emits `unknown` and exits nonzero. Callers must inspect both the receipt and exit status. + +Every `stop` and `status` request ends with exactly one framed receipt, also when it +fails. A failed request exits nonzero and carries a `reason`: + +| `reason` | `outcome` | Meaning | +|---|---|---| +| `invalid-request` | `refused` | The control frame or root was rejected; nothing was read or changed. | +| `stop-lock-wait-expired` | `unknown` | Another stop or a launch held `.stop.lock` for the whole wait (sixteen seconds); this request acted on nothing. | +| `foreign-live-process` | `unknown` | A receipt from another owner token still has live members. | +| `stop-incomplete` | `unknown` | Stop could not confirm that every member is gone. | +| `retired-owner-live` | `unknown` | Status for a retired owner whose members are not proven absent. | +| `io-error` | `unknown` | Any other local failure. | + +A successful reply has no `reason`. The sixteen-second stop-lock wait exceeds the +timers of a stop that recovers after late guard loss (about fourteen seconds), so a +waiter normally gets its own outcome; admission and census work run outside those +timers, which is why an expired wait still returns its own receipt. `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. @@ -125,7 +142,9 @@ refused. Stop can recover the recorded session after verifying the boot, leader identity and each remaining member's UID, birth and session. Recovery delivers TERM individually, grants a fresh five-second grace, then KILL to the captured incarnations and confirms absence. The capture remains authoritative if TERM ends the witness; -every signal still rechecks that member's PID, birth, UID and session (pidfd on Linux). A pidfd open that +every signal still rechecks that member's PID, birth, UID and session (pidfd on Linux). +On Linux a thread-group leader whose main thread exited while other threads run shows +`Z` in `/proc//stat`; it counts as live until no thread in `/proc//task` is. A pidfd open that reports the process missing (`ESRCH`, or `EINVAL` on Linux 6.8 when the PID survives only as a group or session ID) skips that member without a signal; liveness is still decided by identity afterwards. Unobserved descendants never inherit this authority and retain an unknown outcome. Unverified identity never authorizes signals or a successful diff --git a/packages/lifecycle-supervisor/README.md b/packages/lifecycle-supervisor/README.md index 138cbab5f..7bfa522a4 100644 --- a/packages/lifecycle-supervisor/README.md +++ b/packages/lifecycle-supervisor/README.md @@ -50,7 +50,9 @@ fallback session census detects surviving descendants. EOF descriptors leave the 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. +waits at most ten seconds for a previous owner to finish releasing its lock. Stop +and launch wait up to sixteen seconds for `.stop.lock`; a stop whose wait expires +still answers with an `unknown` receipt (reason `stop-lock-wait-expired`). 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 diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index 41fa44c69..e61767cc6 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -95,10 +95,38 @@ pub fn inspect(pid: i32) -> Result> { session, start, uid, - zombie: fields.first() == Some(&"Z") || fields.first() == Some(&"X"), + zombie: matches!(fields.first(), Some(&"Z" | &"X")) && !linux_live_thread(pid)?, })) } +/// A thread-group leader that called `pthread_exit` shows `Z` in +/// `/proc//stat` while its other threads keep running. Such a process is +/// alive: it still owns its PID and receives process-directed signals. Only a +/// group with no live thread left is a zombie. +#[cfg(target_os = "linux")] +fn linux_live_thread(pid: i32) -> Result { + let tasks = match fs::read_dir(format!("/proc/{pid}/task")) { + Ok(tasks) => tasks, + Err(error) if absent_error(&error) => return Ok(false), + Err(error) => return Err(error), + }; + for task in tasks { + let task = task?; + let stat = match fs::read(task.path().join("stat")) { + Ok(stat) => stat, + Err(error) if absent_error(&error) => continue, + Err(error) => return Err(error), + }; + if !matches!( + proc_stat_tail(&stat)?.split_whitespace().next(), + Some("Z" | "X") + ) { + return Ok(true); + } + } + Ok(false) +} + #[cfg(target_os = "linux")] fn linux_uid(pid: i32) -> Result> { let status = match fs::read(format!("/proc/{pid}/status")) { diff --git a/packages/lifecycle-supervisor/src/storage.rs b/packages/lifecycle-supervisor/src/storage.rs index 8a17d2514..e2d0ad6fa 100644 --- a/packages/lifecycle-supervisor/src/storage.rs +++ b/packages/lifecycle-supervisor/src/storage.rs @@ -117,9 +117,9 @@ impl Root { Ok(file) } - pub fn lock_wait(&self, name: &str) -> Result { + pub fn lock_wait(&self, name: &str, wait: std::time::Duration) -> Result { let file = self.file(name, libc::O_RDWR | libc::O_CREAT)?; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let deadline = std::time::Instant::now() + wait; loop { // SAFETY: flock only borrows the live owned descriptor. if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index 39bad47f1..85ea68fff 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -21,6 +21,17 @@ use std::{ const GRACE: Duration = Duration::from_secs(5); const IDLE_TICK: Duration = Duration::from_millis(500); const TICK: Duration = Duration::from_millis(20); +/// Time after KILL for a stop to confirm absence. +const CONFIRM_BOUND: Duration = Duration::from_secs(2); +/// A stop's timers allow two TERM-to-KILL bounds: the guard path, then orphan +/// recovery after a late guard loss. The wait outlasts those timers, so a +/// waiter normally gets its own outcome. Admission, census and signalling run +/// outside the timers, so it is not a hard bound: an expired wait returns an +/// `unknown` receipt with reason `stop-lock-wait-expired` and acts on nothing. +const STOP_LOCK_WAIT: Duration = + Duration::from_secs(2 * (GRACE.as_secs() + CONFIRM_BOUND.as_secs()) + 2); +/// Bounded wait for a previous command owner to release its lock. +const COMMAND_LOCK_WAIT: Duration = Duration::from_secs(10); const LINE_LIMIT: usize = 64 * 1024; #[derive(Serialize)] @@ -32,6 +43,9 @@ struct Receipt { processes: Vec, live_process_names: Vec, unknown_process_names: Vec, + /// Why a stop or status request did not complete; absent on success. + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'static str>, } impl Receipt { @@ -52,10 +66,106 @@ impl Receipt { processes, live_process_names, unknown_process_names, + reason: None, } } } +/// Answer one `stop` or `status` request. Every request that reaches this +/// point ends with exactly one framed receipt, including refusals and expired +/// waits, so a caller can tell an incomplete request from a crash. A failure +/// keeps the nonzero exit and reports outcome `refused` (no request was +/// admitted) or `unknown`, with a `reason` code. +fn control(action: &str) -> Result<()> { + let mut stdout = std::io::stdout(); + let admitted = protocol::read::(&mut std::io::stdin()).and_then(|request| { + protocol::validate_control(request.version, &request.root, &request.owner_token)?; + let root = Root::open(&request.root)?; + Ok((request, root)) + }); + let (request, root) = match admitted { + Ok(admitted) => admitted, + Err(error) => { + let mut receipt = Receipt::new(0, "refused", Vec::new()); + receipt.reason = Some("invalid-request"); + protocol::write(&mut stdout, &receipt)?; + return Err(error); + } + }; + let mut reason = "io-error"; + let result = control_effects(action, &request, &root, &mut reason); + // Receipts are atomically replaced files, so this read is safe without the + // lock, also after a lock wait expired. + let processes = root + .states() + .map(|states| { + states + .into_iter() + .filter(|state| state.owner_token == request.owner_token) + .collect() + }) + .unwrap_or_default(); + let outcome = match (&result, action) { + (Err(_), _) => "unknown", + (Ok(()), "stop") => "stopped", + (Ok(()), _) => "observed", + }; + let mut receipt = Receipt::new(0, outcome, processes); + if result.is_err() { + receipt.reason = Some(reason); + } + protocol::write(&mut stdout, &receipt)?; + result +} + +fn control_effects( + action: &str, + request: &Control, + root: &Root, + reason: &mut &'static str, +) -> Result<()> { + let _stop_lock = if action == "stop" { + match root.lock_wait(".stop.lock", STOP_LOCK_WAIT) { + Ok(lock) => Some(lock), + Err(error) => { + if error.kind() == std::io::ErrorKind::TimedOut { + // Another stop or a launch still holds the lock; act on nothing. + *reason = "stop-lock-wait-expired"; + } + return Err(error); + } + } + } 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. + // Guard-written terminal phases prove completed retirement; + // non-terminal receipts require native identity evidence. + *reason = "foreign-live-process"; + return Err(refused()); + } + } + if action == "stop" { + root.retire(&request.owner_token)?; + *reason = "stop-incomplete"; + return stop(&states); + } + if root.assert_not_retired(&request.owner_token).is_err() + && states + .iter() + .any(|state| !matches!(os::observe_state(state), os::Observation::Absent)) + { + *reason = "retired-owner-live"; + return Err(refused()); + } + Ok(()) +} + pub fn main() -> Result<()> { os::close_inherited()?; let args: Vec<_> = std::env::args_os().skip(1).collect(); @@ -65,57 +175,7 @@ pub fn main() -> Result<()> { 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. - // Guard-written terminal phases prove completed retirement; - // non-terminal receipts require native identity evidence. - return Err(refused()); - } - } - let result = if args[0] == "stop" { - root.retire(&request.owner_token)?; - stop(&states) - } else if root.assert_not_retired(&request.owner_token).is_err() - && states - .iter() - .any(|state| !matches!(os::observe_state(state), os::Observation::Absent)) - { - Err(refused()) - } else { - Ok(()) - }; - protocol::write( - &mut std::io::stdout(), - &Receipt::new( - 0, - if result.is_err() { - "unknown" - } else if args[0] == "stop" { - "stopped" - } else { - "observed" - }, - root.states()? - .into_iter() - .filter(|state| state.owner_token == request.owner_token) - .collect(), - ), - )?; - result - } + Some(action @ ("stop" | "status")) => control(action), _ => Err(refused()), } } @@ -135,7 +195,7 @@ fn run(request: Launch) -> Result<()> { let root = Root::open(&request.root)?; let _instance_lock = instance_lock(&root)?; os::census_preflight()?; - let launch_lock = root.lock_wait(".stop.lock")?; + let launch_lock = root.lock_wait(".stop.lock", STOP_LOCK_WAIT)?; root.assert_not_retired(&request.owner_token)?; // Validate every old claim before starting any new command. A guard survives // project-owner death; its command lock also closes concurrent restart races. @@ -199,7 +259,7 @@ fn run(request: Launch) -> Result<()> { detach_stdio()?; loop { if os::STOP.load(Ordering::Relaxed) { - match root.lock_wait(".stop.lock") { + match root.lock_wait(".stop.lock", STOP_LOCK_WAIT) { Ok(_lock) => { stop(&root.states()?)?; } @@ -264,7 +324,7 @@ fn rollback(children: &mut [Child]) { libc::kill(pid, libc::SIGTERM); } } - let deadline = Instant::now() + GRACE + Duration::from_secs(2); + let deadline = Instant::now() + GRACE + CONFIRM_BOUND; while Instant::now() < deadline { if children .iter_mut() @@ -282,7 +342,8 @@ 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_wait(&format!("{}.lock", request.process.name))?; + let _command_lock = + root.lock_wait(&format!("{}.lock", request.process.name), COMMAND_LOCK_WAIT)?; if let Some(previous) = root.load(&request.process.name)? && os::recorded_live(&previous, false)? { @@ -518,7 +579,7 @@ fn stop(states: &[State]) -> Result<()> { None => self.recover()?, Some(false) => return Ok(false), Some(true) => { - if start.elapsed() >= GRACE + Duration::from_secs(2) { + if start.elapsed() >= GRACE + CONFIRM_BOUND { return Err(refused()); } return Ok(true); @@ -530,7 +591,7 @@ fn stop(states: &[State]) -> Result<()> { return Ok(false); } let elapsed = self.orphan_term.ok_or_else(refused)?.elapsed(); - if elapsed >= GRACE + Duration::from_secs(2) { + if elapsed >= GRACE + CONFIRM_BOUND { return Err(refused()); } if elapsed >= GRACE { diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index ce7122580..d0d1f9f36 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -103,6 +103,27 @@ impl Fixture { } } + /// Take the fixture's stop lock once its current holder releases it. A + /// launch writes its ready receipt just before it drops this lock, and a + /// guard keeps its startup lease until publication, so the lock can still + /// be held briefly after `start` returns. + #[track_caller] + fn take_stop_lock(&self) -> fs::File { + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(self.root.join(".stop.lock")) + .unwrap(); + eventually(|| { + // SAFETY: flock only borrows this live descriptor. + (unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0) + .then_some(()) + }); + lock + } + fn state(&self, name: &str) -> Value { eventually(|| { fs::read(self.root.join(format!("{name}.json"))) @@ -286,6 +307,7 @@ fn send(writer: &mut impl Write, value: &Value) { writer.flush().unwrap(); } +#[track_caller] fn exact(reader: &mut (impl Read + AsRawFd), bytes: &mut [u8]) { let end = Instant::now() + TIMEOUT; let mut offset = 0; @@ -308,6 +330,7 @@ fn exact(reader: &mut (impl Read + AsRawFd), bytes: &mut [u8]) { } } +#[track_caller] fn receive(reader: &mut (impl Read + AsRawFd)) -> Value { let mut header = [0; 4]; exact(reader, &mut header); @@ -318,6 +341,7 @@ fn receive(reader: &mut (impl Read + AsRawFd)) -> Value { serde_json::from_slice(&bytes).unwrap() } +#[track_caller] fn wait(child: &mut Child, timeout: Duration) -> std::process::ExitStatus { let end = Instant::now() + timeout; loop { @@ -333,6 +357,7 @@ fn wait(child: &mut Child, timeout: Duration) -> std::process::ExitStatus { } } +#[track_caller] fn eventually(mut predicate: impl FnMut() -> Option) -> T { let end = Instant::now() + TIMEOUT; loop { @@ -1060,6 +1085,8 @@ fn guard_lost_during_stop(delay: Duration, blocked: bool) { } let (mut caller, _) = f.start(processes); let blocked_guard = blocked.then(|| pid(&f.state("blocked"), "guardPid")); + // Freeze only after launch and guard startup leases released the stop lock. + drop(f.take_stop_lock()); if let Some(guard) = blocked_guard { assert_eq!(unsafe { libc::kill(guard, libc::SIGSTOP) }, 0); } @@ -1179,6 +1206,108 @@ fn orphan_recovery_keeps_observed_child_after_term_ends_witness() { ); } +#[test] +fn stop_waiting_past_lock_bound_reports_unknown_without_acting() { + 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 worker = identity(pid(&state, "commandPid")).expect("command identity"); + // Hold the fixture's stop lock as a stuck stop would. + let lock = f.take_stop_lock(); + let start = Instant::now(); + let mut stop = command(&f.root, "stop").spawn().unwrap(); + send(stop.stdin.as_mut().unwrap(), &f.control(TOKEN)); + // The frame arrives only after the sixteen-second wait; the reader's own + // timeout is shorter, so start reading part of the way through. + thread::sleep(Duration::from_secs(10)); + let receipt = receive(stop.stdout.as_mut().unwrap()); + assert!(!wait(&mut stop, TIMEOUT).success(), "{receipt}"); + assert_eq!(receipt["outcome"], "unknown"); + assert_eq!(receipt["reason"], "stop-lock-wait-expired"); + assert!(start.elapsed() >= Duration::from_secs(16)); + assert!( + identity(worker.pid).as_ref() == Some(&worker), + "stop acted while waiting" + ); + assert_eq!(f.state("worker")["phase"], "running"); + drop(lock); + f.stop(); + assert!(wait(&mut caller, TIMEOUT).success()); +} + +#[test] +fn invalid_control_requests_still_get_a_framed_refusal() { + let f = Fixture::new(); + for action in ["stop", "status"] { + let (mut child, receipt) = f.request( + action, + &json!({"version": 99, "root": f.root, "ownerToken": TOKEN}), + ); + assert!(!wait(&mut child, TIMEOUT).success(), "{receipt}"); + assert_eq!(receipt["outcome"], "refused"); + assert_eq!(receipt["reason"], "invalid-request"); + assert_eq!(receipt["processes"], json!([])); + } +} + +/// Linux shows a leader whose main thread called `pthread_exit` as `Z` while +/// its worker threads run. It must count as live. After guard loss, recovery +/// signals captured members one by one, so a member misread as a zombie is +/// never captured: stop then reported absence while its worker survived. +#[cfg(target_os = "linux")] +#[test] +fn main_thread_exit_with_live_worker_is_still_stopped() { + for lose_guard in [false, true] { + let f = Fixture::new(); + let (mut caller, _) = f.start(vec![f.process("worker", "pthread-main-exit")]); + f.await_log("fixture-ready"); + let state = f.state("worker"); + let child = eventually(|| { + f.log().lines().find_map(|line| { + line.split("pthread-child ") + .nth(1) + .and_then(|pid| pid.parse::().ok()) + }) + }); + f.owned + .borrow_mut() + .push(identity(child).expect("pthread child identity")); + eventually(|| (process_state(child) == Some(b'Z') && live_task(child)).then_some(())); + if lose_guard { + drop(f.take_stop_lock()); + // SAFETY: the captured guard belongs to this fixture. + assert_eq!( + unsafe { libc::kill(pid(&state, "guardPid"), libc::SIGKILL) }, + 0 + ); + assert!(wait(&mut caller, TIMEOUT).success()); + } + let (mut stop, receipt) = f.request("stop", &f.control(TOKEN)); + assert!(wait(&mut stop, TIMEOUT).success(), "{receipt}"); + assert_eq!(receipt["outcome"], "stopped"); + eventually(|| (!live_task(child)).then_some(())); + if !lose_guard { + assert!(wait(&mut caller, TIMEOUT).success()); + } + } +} + +#[cfg(target_os = "linux")] +fn live_task(pid: i32) -> bool { + fs::read_dir(format!("/proc/{pid}/task")) + .into_iter() + .flatten() + .flatten() + .any(|task| { + fs::read_to_string(task.path().join("stat")) + .ok() + .and_then(|stat| stat.rsplit_once(") ").map(|(_, rest)| rest.bytes().next())) + .flatten() + .is_some_and(|state| state != b'Z' && state != b'X') + }) +} + fn process_is_zombie(pid: i32) -> bool { process_state(pid) == Some(b'Z') } @@ -1230,6 +1359,9 @@ fn unreaped_matching_guard_proves_legacy_session_ownership() { let mut caller = ForeignChild(caller); f.await_log("fixture-ready"); let mut state = f.state("worker"); + // Freeze the parent only after launch has released the stop lock; + // otherwise the frozen parent would hold it and stop could never run. + drop(f.take_stop_lock()); // Keep the parent from reaping its guard, then exercise legacy birth absence. assert_eq!( unsafe { libc::kill(caller.0.id() as i32, libc::SIGSTOP) }, @@ -1275,6 +1407,8 @@ fn guard_dying_during_stop_gets_a_fresh_authored_term_grace() { f.await_log("fixture-ready"); let guard = pid(&f.state("worker"), "guardPid"); let birth = identity(guard).unwrap(); + // Freeze only after launch and guard startup leases released the stop lock. + drop(f.take_stop_lock()); // Suspend this owned guard so its pending TERM cannot reach the command. assert_eq!(unsafe { libc::kill(guard, libc::SIGSTOP) }, 0); let mut stop = command(&f.root, "stop").spawn().unwrap(); @@ -1522,6 +1656,8 @@ fn unresponsive_guard_stop_remains_unknown() { f.await_log("fixture-ready"); let state = f.state("worker"); let guard = pid(&state, "guardPid"); + // Freeze only after launch and guard startup leases released the stop lock. + drop(f.take_stop_lock()); // 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)); @@ -1904,6 +2040,44 @@ fn fixture_process() { } println!("wrapper-child {child}"); } + #[cfg(target_os = "linux")] + "pthread-main-exit" => { + extern "C" fn worker(_: *mut libc::c_void) -> *mut libc::c_void { + loop { + // SAFETY: pause has no preconditions. + unsafe { + libc::pause(); + } + } + } + // The fork child is single-threaded, so its main thread is the + // thread-group leader. It starts one worker and ends only its main + // thread (as `pthread_exit` does); Linux then shows the leader as + // `Z` while the worker runs. + // SAFETY: the child calls only libc thread and exit functions and + // never returns into Rust/libtest state. + let child = unsafe { libc::fork() }; + assert!(child >= 0); + if child == 0 { + unsafe { + let mut thread: libc::pthread_t = std::mem::zeroed(); + if libc::pthread_create( + &mut thread, + std::ptr::null(), + worker, + std::ptr::null_mut(), + ) != 0 + { + libc::_exit(96); + } + // End only the main thread, as pthread_exit would, without + // its unwinder: the raw exit syscall is thread-scoped. + libc::syscall(libc::SYS_exit, 0); + libc::_exit(95); + } + } + println!("pthread-child {child}"); + } "hold" => (), _ => std::process::exit(99), } From b4317ec2713ac4b2eda5757fd70e74c0e420f369 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:20:34 +0000 Subject: [PATCH 47/55] fix(supervisor): never report success when the reply's receipt read fails Co-Authored-By: Claude Opus 5.5 --- .../lifecycle-supervisor/src/supervisor.rs | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index 85ea68fff..162c309fb 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -93,18 +93,23 @@ fn control(action: &str) -> Result<()> { } }; let mut reason = "io-error"; - let result = control_effects(action, &request, &root, &mut reason); + let mut result = control_effects(action, &request, &root, &mut reason); // Receipts are atomically replaced files, so this read is safe without the - // lock, also after a lock wait expired. - let processes = root - .states() - .map(|states| { - states - .into_iter() - .filter(|state| state.owner_token == request.owner_token) - .collect() - }) - .unwrap_or_default(); + // lock, also after a lock wait expired. A failed read never reports + // success: it keeps an earlier error, or becomes `io-error` itself. + let processes = match root.states() { + Ok(states) => states + .into_iter() + .filter(|state| state.owner_token == request.owner_token) + .collect(), + Err(error) => { + if result.is_ok() { + reason = "io-error"; + result = Err(error); + } + Vec::new() + } + }; let outcome = match (&result, action) { (Err(_), _) => "unknown", (Ok(()), "stop") => "stopped", From 3fc81d0577ebb16ff761355f67afd39ca40a4960 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:29:40 +0000 Subject: [PATCH 48/55] fix(lifecycle): let incremental add wait for the server's whole stop-lock bound The server waits up to the 16-second stop-lock bound before it launches, but the add forwarder read its reply with the ordinary 10-second frame deadline. Under 10 to 16 seconds of contention the forwarder reported a refusal while the server still launched the command. The forwarder now reads the reply within the stop-lock bound plus one frame deadline. Regression: add_reply_outlasts_a_stop_lock_held_past_the_frame_deadline (fails with the old deadline). Co-Authored-By: Claude Opus 5.5 --- .../lifecycle-supervisor/src/incremental.rs | 8 ++++- packages/lifecycle-supervisor/src/protocol.rs | 11 +++++- .../lifecycle-supervisor/tests/incremental.rs | 36 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/lifecycle-supervisor/src/incremental.rs b/packages/lifecycle-supervisor/src/incremental.rs index a620ce585..20d800718 100644 --- a/packages/lifecycle-supervisor/src/incremental.rs +++ b/packages/lifecycle-supervisor/src/incremental.rs @@ -132,7 +132,13 @@ pub(crate) fn add(request: Launch) -> Result<()> { return Err(refused()); } protocol::write(&mut stream, &request)?; - let receipt: serde_json::Value = protocol::read(&mut stream)?; + // The server may wait the full stop-lock bound before it launches, then + // needs one ordinary frame deadline for its own work. Giving up sooner + // would report failure for an add the server still completes. + let receipt: serde_json::Value = protocol::read_within( + &mut stream, + crate::supervisor::STOP_LOCK_WAIT + protocol::DEADLINE, + )?; if receipt["version"] != 1 || !receipt["processes"].is_array() { return Err(refused()); } diff --git a/packages/lifecycle-supervisor/src/protocol.rs b/packages/lifecycle-supervisor/src/protocol.rs index 2f051c70e..c76a92d43 100644 --- a/packages/lifecycle-supervisor/src/protocol.rs +++ b/packages/lifecycle-supervisor/src/protocol.rs @@ -111,7 +111,16 @@ impl Process { /// 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; + read_within(input, DEADLINE) +} + +/// `read` with an explicit deadline, for a reply that follows a bounded wait +/// on the other side (for example a stop-lock wait before an incremental add). +pub fn read_within( + input: &mut (impl Read + AsRawFd), + within: Duration, +) -> Result { + let deadline = Instant::now() + within; let mut header = [0; 4]; exact(input, &mut header, deadline)?; let length = u32::from_be_bytes(header) as usize; diff --git a/packages/lifecycle-supervisor/tests/incremental.rs b/packages/lifecycle-supervisor/tests/incremental.rs index 269eb457f..0ca8afc10 100644 --- a/packages/lifecycle-supervisor/tests/incremental.rs +++ b/packages/lifecycle-supervisor/tests/incremental.rs @@ -279,6 +279,42 @@ fn owner_death_keeps_native_status_and_recovery_stop() { )); } +/// Add waits up to the stop-lock bound (16 s) on the server side. The +/// forwarder must keep waiting for that reply instead of reporting failure +/// after the ordinary 10-second frame deadline while the server still launches. +#[test] +fn add_reply_outlasts_a_stop_lock_held_past_the_frame_deadline() { + let mut fixture = Fixture::new(); + fixture.serve(fixture.process("helper", "hold")); + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(fixture.root.join(".stop.lock")) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(15); + // SAFETY: flock only borrows this live descriptor. + while unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + assert!( + Instant::now() < deadline, + "stop lock stayed busy after serve" + ); + std::thread::sleep(Duration::from_millis(20)); + } + let mut add = fixture.command("add").spawn().unwrap(); + send( + add.stdin.as_mut().unwrap(), + &fixture.launch(fixture.process("later", "hold")), + ); + std::thread::sleep(Duration::from_secs(12)); + drop(lock); + let receipt = receive(add.stdout.as_mut().unwrap()); + assert!(wait(&mut add).success(), "{receipt}"); + assert_eq!(receipt["outcome"], "ready"); + assert!(fixture.root.join("later.json").exists()); +} + #[test] fn completed_stop_cannot_be_followed_by_add_to_old_owner() { let mut fixture = Fixture::new(); From 63614d6c3cd987a89d8099db59f282f965217798 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:34:12 +0000 Subject: [PATCH 49/55] test(lifecycle): retry ETXTBSY when spawning a just-copied supervisor Hosted Linux failed atomic_executable_replacement_between_serve_and_add_ keeps_guard_spawn_usable with ETXTBSY: another test thread forked while fs::copy still held the new binary open for writing, and that child kept the descriptor until its exec. Retry only that error, for up to 5 s. Co-Authored-By: Claude Opus 5.5 --- .../lifecycle-supervisor/tests/incremental.rs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/lifecycle-supervisor/tests/incremental.rs b/packages/lifecycle-supervisor/tests/incremental.rs index 0ca8afc10..a9db8b5af 100644 --- a/packages/lifecycle-supervisor/tests/incremental.rs +++ b/packages/lifecycle-supervisor/tests/incremental.rs @@ -118,6 +118,25 @@ impl Drop for Fixture { } } +/// Spawn a binary this test has just written. On Linux another test thread +/// can fork while the copy's write descriptor is still open; that child holds +/// the descriptor until it execs, and exec of the new file then fails with +/// ETXTBSY. The window is short, so retry only that error, briefly. +fn spawn_fresh_executable(command: &mut Command) -> Child { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match command.spawn() { + Ok(child) => return child, + Err(error) + if error.raw_os_error() == Some(libc::ETXTBSY) && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(20)); + } + Err(error) => panic!("spawn fresh executable: {error}"), + } + } +} + fn send(output: &mut impl Write, value: &Value) { let bytes = serde_json::to_vec(value).unwrap(); output @@ -558,7 +577,7 @@ fn atomic_executable_replacement_between_serve_and_add_keeps_guard_spawn_usable( let installed = fixture.base.join("installed-supervisor"); let replacement = fixture.base.join("replacement-supervisor"); fs::copy(env!("CARGO_BIN_EXE_hack-lifecycle-supervisor"), &installed).unwrap(); - let mut owner = fixture.command_binary("serve", &installed).spawn().unwrap(); + let mut owner = spawn_fresh_executable(&mut fixture.command_binary("serve", &installed)); send( owner.stdin.as_mut().unwrap(), &fixture.launch(fixture.process("first", "hold")), From 60d737b59ddebddb7f052afc375a45852de7a83a Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:57:09 +0000 Subject: [PATCH 50/55] fix(supervisor): reread a receipt replaced while it was being opened Root::file opened a name and then required nlink == 1. A guard replaces its receipt atomically (temporary file, then rename), so a stop or status listing receipts at that moment could open the old inode after it was unlinked, see nlink 0 and refuse. The stop then failed with an io-error receipt. That is the Intel macOS CI failure of unresponsive_guard_stop_remains_unknown and, by the same path, the cleanup-stop failure of expired_target_does_not_abandon_other_recovery: both resume a frozen guard and stop at once while the guard rewrites its receipt. An unlinked inode now means "reopen the name"; any other link count is still refused. Regression: storage::tests::receipt_reads_survive_concurrent_atomic_ replacement lists receipts against a concurrent writer for 3 s; it fails after about 2,900 reads without the fix. Failure reasons are more specific: stop-lock-failed, receipt-read-failed and retire-failed now separate the steps that io-error covered. Co-Authored-By: Claude Opus 5.5 --- docs/lifecycle-supervisor.md | 5 + packages/lifecycle-supervisor/src/storage.rs | 134 ++++++++++++++---- .../lifecycle-supervisor/src/supervisor.rs | 14 +- 3 files changed, 125 insertions(+), 28 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 95e0c8766..d021bde12 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -69,6 +69,9 @@ fails. A failed request exits nonzero and carries a `reason`: | `foreign-live-process` | `unknown` | A receipt from another owner token still has live members. | | `stop-incomplete` | `unknown` | Stop could not confirm that every member is gone. | | `retired-owner-live` | `unknown` | Status for a retired owner whose members are not proven absent. | +| `stop-lock-failed` | `unknown` | Taking `.stop.lock` failed for a reason other than the wait expiring. | +| `receipt-read-failed` | `unknown` | The process receipts could not be read. | +| `retire-failed` | `unknown` | Stop could not durably retire the owner token; nothing was signalled. | | `io-error` | `unknown` | Any other local failure. | A successful reply has no `reason`. The sixteen-second stop-lock wait exceeds the @@ -143,6 +146,8 @@ leader identity and each remaining member's UID, birth and session. Recovery delivers TERM individually, grants a fresh five-second grace, then KILL to the captured incarnations and confirms absence. The capture remains authoritative if TERM ends the witness; every signal still rechecks that member's PID, birth, UID and session (pidfd on Linux). +Readers that open a receipt while a guard atomically replaces it reopen the name, so a +concurrent update never reads as a refusal. On Linux a thread-group leader whose main thread exited while other threads run shows `Z` in `/proc//stat`; it counts as live until no thread in `/proc//task` is. A pidfd open that reports the process missing (`ESRCH`, or `EINVAL` on Linux 6.8 when the PID survives only as a group or diff --git a/packages/lifecycle-supervisor/src/storage.rs b/packages/lifecycle-supervisor/src/storage.rs index e2d0ad6fa..3108dfa1b 100644 --- a/packages/lifecycle-supervisor/src/storage.rs +++ b/packages/lifecycle-supervisor/src/storage.rs @@ -81,31 +81,41 @@ impl Root { 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()); + // A receipt is replaced atomically (temporary file, then rename). An open + // that races that rename can return the replaced inode, which is already + // unlinked (nlink 0). Reopen the name to read its current inode; any + // other link count is still refused. The bound only stops a livelock. + for _ in 0..64 { + // 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()?; + if metadata.nlink() == 0 { + continue; + } + // SAFETY: geteuid has no preconditions. + if !metadata.is_file() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.nlink() != 1 + || metadata.mode() & 0o777 != 0o600 + { + return Err(refused()); + } + return Ok(file); } - Ok(file) + Err(refused()) } pub fn lock(&self, name: &str) -> Result { @@ -241,3 +251,79 @@ impl Root { Ok(states) } } + +#[cfg(test)] +mod tests { + use super::{Root, State}; + use std::os::unix::fs::DirBuilderExt; + + fn state(phase: &str) -> State { + State { + version: 1, + name: "worker".into(), + owner_token: "synthetic".into(), + guard_pid: 44, + process_group_id: 44, + guard_start_time: 100, + boot_id: String::new(), + uid: 501, + command_pid: None, + command_start_time: None, + phase: phase.into(), + exit_code: None, + signal: None, + } + } + + /// A guard atomically replaces its receipt while a stop or status lists + /// receipts. A reader that opened the old inode just before the rename sees + /// it unlinked; that must read as the current receipt, never as a refusal. + #[test] + fn receipt_reads_survive_concurrent_atomic_replacement() { + let base = std::env::temp_dir().join(format!( + "storage-replace-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::DirBuilder::new() + .mode(0o700) + .create(&base) + .unwrap(); + let root = Root::open(&base).unwrap(); + root.save(&state("running")).unwrap(); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let writer = { + let base = base.clone(); + let stop = stop.clone(); + std::thread::spawn(move || { + let root = Root::open(&base).unwrap(); + let mut flip = false; + while !stop.load(std::sync::atomic::Ordering::Relaxed) { + flip = !flip; + root.save(&state(if flip { "stopping" } else { "running" })) + .unwrap(); + } + }) + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + let mut reads = 0u64; + let mut failure = None; + while std::time::Instant::now() < deadline { + match root.states() { + Ok(states) => assert_eq!(states.len(), 1), + Err(error) => { + failure = Some(error); + break; + } + } + reads += 1; + } + stop.store(true, std::sync::atomic::Ordering::Relaxed); + writer.join().unwrap(); + let _ = std::fs::remove_dir_all(&base); + assert!(failure.is_none(), "read {reads} failed: {failure:?}"); + } +} diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index 162c309fb..e06499614 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -104,7 +104,7 @@ fn control(action: &str) -> Result<()> { .collect(), Err(error) => { if result.is_ok() { - reason = "io-error"; + reason = "receipt-read-failed"; result = Err(error); } Vec::new() @@ -133,10 +133,12 @@ fn control_effects( match root.lock_wait(".stop.lock", STOP_LOCK_WAIT) { Ok(lock) => Some(lock), Err(error) => { - if error.kind() == std::io::ErrorKind::TimedOut { + *reason = if error.kind() == std::io::ErrorKind::TimedOut { // Another stop or a launch still holds the lock; act on nothing. - *reason = "stop-lock-wait-expired"; - } + "stop-lock-wait-expired" + } else { + "stop-lock-failed" + }; return Err(error); } } @@ -144,7 +146,9 @@ fn control_effects( None }; let mut states = Vec::new(); + *reason = "receipt-read-failed"; for state in root.states()? { + *reason = "io-error"; if state.owner_token == request.owner_token { states.push(state); } else if os::recorded_live(&state, false)? { @@ -155,7 +159,9 @@ fn control_effects( return Err(refused()); } } + *reason = "io-error"; if action == "stop" { + *reason = "retire-failed"; root.retire(&request.owner_token)?; *reason = "stop-incomplete"; return stop(&states); From 9ce821463212a9f2c589a642a6192552f8f8c72e Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:59:51 +0000 Subject: [PATCH 51/55] test(supervisor): give the receipt replacement test the display_name field Co-Authored-By: Claude Opus 5.5 --- packages/lifecycle-supervisor/src/storage.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lifecycle-supervisor/src/storage.rs b/packages/lifecycle-supervisor/src/storage.rs index 69a38bd82..f49364c4d 100644 --- a/packages/lifecycle-supervisor/src/storage.rs +++ b/packages/lifecycle-supervisor/src/storage.rs @@ -295,6 +295,7 @@ mod tests { State { version: 1, name: "worker".into(), + display_name: None, owner_token: "synthetic".into(), guard_pid: 44, process_group_id: 44, From 88f85466ab7cae23501030eeb3c20bc608e73625 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 04:40:23 +0000 Subject: [PATCH 52/55] test(e2e): register the host-process scenario's tmux root with the socket guard Same cause as lifecycle-session-recovery (2fb3e0eac): lifecycle-host- process points TMUX_TMPDIR at its fixture root, which next's tmux socket guard refuses as unregistered. Its cleanup threw before restoring TMUX_TMPDIR, and the harness's later Docker leak check then failed on the deleted root (docker-e2e shard 4/4). Register the fixture root for the scenario, and always restore and unregister in an inner finally. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/scenarios/lifecycle-host-process.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/e2e/scenarios/lifecycle-host-process.ts b/tests/e2e/scenarios/lifecycle-host-process.ts index 4b624a5f7..a5a88d020 100644 --- a/tests/e2e/scenarios/lifecycle-host-process.ts +++ b/tests/e2e/scenarios/lifecycle-host-process.ts @@ -3,6 +3,7 @@ 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 { registerPrivateTmuxRoot } from "../../helpers/tmux-socket-guard.ts"; import { createMonorepoFixture } from "../fixture.ts"; import { expect, expectExit, runCommand, type Scenario } from "../harness.ts"; @@ -63,6 +64,9 @@ export const lifecycleHostProcessScenario: Scenario = { } const previousTmuxRoot = process.env.TMUX_TMPDIR; + // The run's tmux socket guard admits only registered private roots; this + // scenario owns its fixture root (mkdtemp, 0700) for its own tmux server. + const unregisterTmuxRoot = registerPrivateTmuxRoot(ctx.tempRoot); process.env.TMUX_TMPDIR = ctx.tempRoot; const cli: typeof ctx.cli = (invocation) => ctx.cli({ @@ -336,8 +340,16 @@ export const lifecycleHostProcessScenario: Scenario = { try { await downBestEffort({ ctx: { ...ctx, cli }, fixture }); } finally { - await runCommand({ argv: ["tmux", "kill-server"], cwd: fixture.root }); - restoreEnv("TMUX_TMPDIR", previousTmuxRoot); + try { + await runCommand({ + argv: ["tmux", "kill-server"], + cwd: fixture.root, + }); + } finally { + // Never leak this fixture's socket root into later scenarios. + restoreEnv("TMUX_TMPDIR", previousTmuxRoot); + unregisterTmuxRoot(); + } } } }, From ca0fa30779793c6c0db262506899adb211a584eb Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:38:32 +0000 Subject: [PATCH 53/55] fix(supervisor): append log records without a lock so no guard waits on a sibling Every guard took a blocking flock on the shared lifecycle.log around each record. A guard stopped (SIGSTOP, a debugger, a hung write) while holding it stalled every sibling guard's loop in flock: no output, no exit detection, no stop handling. Captured on macOS as the 9/100 failure of expired_target_does_not_abandon_other_recovery (worker guard blocked in Lines::flush -> flock while the frozen blocked guard held the lock). Each record is now one write on the O_APPEND descriptor with no lock. The kernel places each append atomically, so whole records from different guards never interleave on a local file system; only a short write (for example a full disk) can split one. The unused flock retry helper and its unit test are removed. Regressions: held_log_lock_never_stalls_a_guard holds the log flock from outside for the whole run and requires output to reach the log and stop to answer stopped within the normal bound (fails with the old lock); concurrent_guards_append_whole_records_without_a_lock checks two guards bursting 64 KiB records leave only whole, unmixed records. Docs state the log's ordering and integrity and list every lock, who can hold it across a blocking call, and the bound. Co-Authored-By: Claude Opus 5.5 --- docs/lifecycle-supervisor.md | 24 +++++++ packages/lifecycle-supervisor/README.md | 2 +- packages/lifecycle-supervisor/src/os.rs | 39 ----------- .../lifecycle-supervisor/src/supervisor.rs | 14 ++-- .../lifecycle-supervisor/tests/fake_caller.rs | 69 +++++++++++++++++++ 5 files changed, 101 insertions(+), 47 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index d021bde12..637dbce34 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -100,6 +100,30 @@ 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. +Each record is written with one `write` on an `O_APPEND` descriptor and no lock. On a +local file system the kernel places each append atomically at the end of the file, so +whole records from different guards never interleave; records from different guards +appear in the order their appends happened. A short write (for example a full disk) +can split one record. No guard waits for another guard to log, so a stopped or hung +guard cannot stall a sibling guard's output, exit detection or stop handling. + +## Locks and waits + +A guard stopped at any instruction (SIGSTOP, a debugger) can hold only what it already +had. No sibling guard waits on it, and every request that can wait on it has a bound: + +| Lock | Held by | Held across a blocking call? | Who waits, and the bound | +|---|---|---|---| +| `.instance.lock` | The project supervisor (`run`), for its lifetime | Yes, its whole run | Another `run` for the root: retries 1 s, then refused. Guards, `stop` and `status` never take it. | +| `.stop.lock` | `run` from admission until its ready receipt; each new guard (inherited on fd 2) until it publishes and detaches; `stop` for its duration; the project supervisor while it stops on a signal | Yes. A guard frozen before it publishes keeps the lease until it is continued or killed | `stop` and launches wait up to 16 s, then answer `unknown` with reason `stop-lock-wait-expired` and act on nothing. `run` waits up to 10 s for each guard's receipt, then rolls back. | +| `.lock` | The guard of that command, for its lifetime | Yes | Only a new guard for the same command name: up to 10 s, then refused. Never a sibling, `stop` or `status`. | +| Receipts, retirement marker, `lifecycle.log` | No lock | No | Receipts are replaced atomically; a reader that races a replacement reopens the name. Log records are single appends (above). | + +`status` takes no lock. `stop` handles each target separately: a frozen live guard costs +its own target the five-second grace plus the two-second confirmation bound (and the +same again if the guard is lost), then that target reports `unknown` while the others +complete. + 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 diff --git a/packages/lifecycle-supervisor/README.md b/packages/lifecycle-supervisor/README.md index 7bfa522a4..fce258562 100644 --- a/packages/lifecycle-supervisor/README.md +++ b/packages/lifecycle-supervisor/README.md @@ -60,7 +60,7 @@ replaces stdio with `/dev/null`. Supervisor death therefore cannot let stop pass 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. +is independent of the descriptor limit. Log records are single unlocked `O_APPEND` writes, so no guard waits on a sibling to log. 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 diff --git a/packages/lifecycle-supervisor/src/os.rs b/packages/lifecycle-supervisor/src/os.rs index e61767cc6..2d1c3f26d 100644 --- a/packages/lifecycle-supervisor/src/os.rs +++ b/packages/lifecycle-supervisor/src/os.rs @@ -857,26 +857,6 @@ fn readable_self(members: &[Identity]) -> Result<()> { 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) }; @@ -1057,25 +1037,6 @@ mod tests { assert!(super::recorded_guard_matches(&state, &identity)); } - #[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()); diff --git a/packages/lifecycle-supervisor/src/supervisor.rs b/packages/lifecycle-supervisor/src/supervisor.rs index e06499614..075979dac 100644 --- a/packages/lifecycle-supervisor/src/supervisor.rs +++ b/packages/lifecycle-supervisor/src/supervisor.rs @@ -746,13 +746,13 @@ impl Lines { }; 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. - os::flock_retry(log.as_raw_fd(), libc::LOCK_EX)?; - let result = log.write_all(record.as_bytes()); - let unlocked = os::flock_retry(log.as_raw_fd(), libc::LOCK_UN); - result?; - unlocked?; + // One write per complete record on an O_APPEND descriptor, with no + // lock: the kernel positions and writes each append atomically, so + // whole records from sibling guards do not interleave on a local file + // system. A guard never waits on another guard to log, so a stopped or + // hung sibling cannot stall this guard's loop (STOP, exit, census). + // Only a short write (for example a full disk) splits a record. + log.write_all(record.as_bytes())?; self.pending.clear(); Ok(()) } diff --git a/packages/lifecycle-supervisor/tests/fake_caller.rs b/packages/lifecycle-supervisor/tests/fake_caller.rs index d0d1f9f36..a37a5d649 100644 --- a/packages/lifecycle-supervisor/tests/fake_caller.rs +++ b/packages/lifecycle-supervisor/tests/fake_caller.rs @@ -136,6 +136,7 @@ impl Fixture { fs::read_to_string(self.root.join("lifecycle.log")).unwrap_or_default() } + #[track_caller] fn await_log(&self, needle: &str) { eventually(|| self.log().contains(needle).then_some(())); } @@ -529,6 +530,46 @@ fn output_bursts_and_long_lines_include_partial_tail() { } } +/// Without a log lock, each record is one O_APPEND write. Two guards bursting +/// at once, including 64 KiB records far above PIPE_BUF, must still leave only +/// whole records: every line parses, belongs to one guard and is not mixed. +#[test] +fn concurrent_guards_append_whole_records_without_a_lock() { + let f = Fixture::new(); + let (mut caller, _) = f.start(vec![ + f.process("alpha", "burst"), + f.process("beta", "burst"), + ]); + assert!(wait(&mut caller, TIMEOUT).success()); + let text = f.log(); + for name in ["alpha", "beta"] { + let own: Vec<_> = text + .lines() + .filter(|line| line.split('\t').nth(1) == Some(name)) + .collect(); + assert_eq!( + own.iter().filter(|l| l.contains("\tburst-record-")).count(), + 2000 + ); + let xs: usize = own + .iter() + .filter_map(|line| line.splitn(4, '\t').nth(3)) + .filter(|message| message.starts_with('X')) + .map(|message| { + assert!(message.bytes().all(|b| b == b'X'), "mixed record"); + message.len() + }) + .sum(); + assert_eq!(xs, 300_000); + } + for line in text.lines() { + let fields: Vec<_> = line.splitn(4, '\t').collect(); + assert_eq!(fields.len(), 4, "split record: {line:.80}"); + assert!(matches!(fields[1], "alpha" | "beta"), "{line:.80}"); + assert!(fields[0].ends_with('Z')); + } +} + #[test] fn zombies_do_not_hold_liveness() { let f = Fixture::new(); @@ -1308,6 +1349,34 @@ fn live_task(pid: i32) -> bool { }) } +/// A guard stopped while it held a lock on the shared log (SIGSTOP between +/// lock and unlock) must not stall sibling guards. The test holds an exclusive +/// flock on the log from outside for the whole run, as such a frozen guard +/// would: a guard's output must still reach the log and its project must +/// still stop with a framed `stopped` receipt within the normal bounds. +#[test] +fn held_log_lock_never_stalls_a_guard() { + use std::os::unix::fs::OpenOptionsExt; + let f = Fixture::new(); + let log = fs::OpenOptions::new() + .append(true) + .create(true) + .mode(0o600) + .open(f.root.join("lifecycle.log")) + .unwrap(); + // SAFETY: flock only borrows this live descriptor. + assert_eq!(unsafe { libc::flock(log.as_raw_fd(), libc::LOCK_EX) }, 0); + let (mut caller, _) = f.start(vec![f.process("worker", "hold")]); + f.await_log("fixture-ready"); + let start = Instant::now(); + let (mut stop, receipt) = f.request("stop", &f.control(TOKEN)); + assert!(wait(&mut stop, TIMEOUT).success(), "{receipt}"); + assert_eq!(receipt["outcome"], "stopped"); + assert!(start.elapsed() < Duration::from_secs(7)); + assert!(wait(&mut caller, TIMEOUT).success()); + drop(log); +} + fn process_is_zombie(pid: i32) -> bool { process_state(pid) == Some(b'Z') } From 15bb1d01262824dd7db806d0c48de94db8d026b8 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:40:09 +0000 Subject: [PATCH 54/55] docs(supervisor): separate stop and launch lock-wait outcomes; name short-write causes Co-Authored-By: Claude Opus 5.5 --- docs/lifecycle-supervisor.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index 637dbce34..d3920c32b 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -103,19 +103,21 @@ there is no total ordering guarantee between stdout and stderr. Each record is written with one `write` on an `O_APPEND` descriptor and no lock. On a local file system the kernel places each append atomically at the end of the file, so whole records from different guards never interleave; records from different guards -appear in the order their appends happened. A short write (for example a full disk) -can split one record. No guard waits for another guard to log, so a stopped or hung +appear in the order their appends happened. A short write (a full disk, a file-size +limit, or a signal after partial progress) is completed by a second write and can split +one record. No guard waits for another guard to log, so a stopped or hung guard cannot stall a sibling guard's output, exit detection or stop handling. ## Locks and waits A guard stopped at any instruction (SIGSTOP, a debugger) can hold only what it already -had. No sibling guard waits on it, and every request that can wait on it has a bound: +had. No sibling guard waits on it, and every request that can wait on it has a bound. +The bounds below limit each wait, not the total duration of a request: | Lock | Held by | Held across a blocking call? | Who waits, and the bound | |---|---|---|---| | `.instance.lock` | The project supervisor (`run`), for its lifetime | Yes, its whole run | Another `run` for the root: retries 1 s, then refused. Guards, `stop` and `status` never take it. | -| `.stop.lock` | `run` from admission until its ready receipt; each new guard (inherited on fd 2) until it publishes and detaches; `stop` for its duration; the project supervisor while it stops on a signal | Yes. A guard frozen before it publishes keeps the lease until it is continued or killed | `stop` and launches wait up to 16 s, then answer `unknown` with reason `stop-lock-wait-expired` and act on nothing. `run` waits up to 10 s for each guard's receipt, then rolls back. | +| `.stop.lock` | `run` from admission until its ready receipt; each new guard (inherited on fd 2) until it publishes and detaches; `stop` for its duration; the project supervisor while it stops on a signal | Yes. A guard frozen before it publishes keeps the lease until it is continued or killed | `stop` waits up to 16 s, then answers `unknown` with reason `stop-lock-wait-expired` and acts on nothing. A launch (`run`) waits up to 16 s, then exits nonzero without a ready receipt and starts nothing. `run` waits up to 10 s for each guard's receipt, then rolls back. | | `.lock` | The guard of that command, for its lifetime | Yes | Only a new guard for the same command name: up to 10 s, then refused. Never a sibling, `stop` or `status`. | | Receipts, retirement marker, `lifecycle.log` | No lock | No | Receipts are replaced atomically; a reader that races a replacement reopens the name. Log records are single appends (above). | From a9bc571ee14195999d0a65c49aae942174535bba Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:45:05 +0000 Subject: [PATCH 55/55] docs(lifecycle): list serve, add and status lock waits in the supervisor lock table Co-Authored-By: Claude Opus 5.5 --- docs/lifecycle-supervisor.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/lifecycle-supervisor.md b/docs/lifecycle-supervisor.md index b306faa97..4d1938f9f 100644 --- a/docs/lifecycle-supervisor.md +++ b/docs/lifecycle-supervisor.md @@ -124,12 +124,12 @@ The bounds below limit each wait, not the total duration of a request: | Lock | Held by | Held across a blocking call? | Who waits, and the bound | |---|---|---|---| -| `.instance.lock` | The project supervisor (`run`), for its lifetime | Yes, its whole run | Another `run` for the root: retries 1 s, then refused. Guards, `stop` and `status` never take it. | -| `.stop.lock` | `run` from admission until its ready receipt; each new guard (inherited on fd 2) until it publishes and detaches; `stop` for its duration; the project supervisor while it stops on a signal | Yes. A guard frozen before it publishes keeps the lease until it is continued or killed | `stop` waits up to 16 s, then answers `unknown` with reason `stop-lock-wait-expired` and acts on nothing. A launch (`run`) waits up to 16 s, then exits nonzero without a ready receipt and starts nothing. `run` waits up to 10 s for each guard's receipt, then rolls back. | +| `.instance.lock` | The project supervisor (`run` or `serve`), for its lifetime | Yes, its whole run | Another `run` or `serve` for the root: up to 10 s, then refused. Guards, `add`, `stop` and `status` never take it. | +| `.stop.lock` | `run` or `serve` from admission until its ready receipt; `add` while it admits one command; each new guard (inherited on fd 2) until it publishes and detaches; `stop` for its duration; the project supervisor while it stops on a signal | Yes. A guard frozen before it publishes keeps the lease until it is continued or killed | `stop` waits up to 16 s, then answers `unknown` with reason `stop-lock-wait-expired` and acts on nothing. `status` waits up to 16 s only when the lock file exists (it never creates it), with the same framed answer on expiry. `add` waits up to 16 s; its forwarder reads the reply for up to 26 s. A launch (`run`) waits up to 16 s, then exits nonzero without a ready receipt and starts nothing. `run` waits up to 10 s for each guard's receipt, then rolls back. | | `.lock` | The guard of that command, for its lifetime | Yes | Only a new guard for the same command name: up to 10 s, then refused. Never a sibling, `stop` or `status`. | | Receipts, retirement marker, `lifecycle.log` | No lock | No | Receipts are replaced atomically; a reader that races a replacement reopens the name. Log records are single appends (above). | -`status` takes no lock. `stop` handles each target separately: a frozen live guard costs +`status` takes only the bounded stop-lock wait above. `stop` handles each target separately: a frozen live guard costs its own target the five-second grace plus the two-second confirmation bound (and the same again if the guard is lost), then that target reports `unknown` while the others complete.