diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1e88873 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Test +on: + pull_request: + push: + branches: [main] + +jobs: + vitest: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + # Match flake.nix (nodejs_22) so the CI test runtime tracks the + # Nix-built runtime and versions don't skew. + node-version: 22 + cache: npm + - name: Install test-only binaries (absent on ubuntu-latest) + # shells.test.ts exercises real zsh/fish shell integration; and + # restart-guardrail spawns a `claude --resume` session — its guard keys + # purely on the stored argv, so a no-op stub named `claude` is enough to + # create the session under test. + run: | + sudo apt-get update + sudo apt-get install -y zsh fish + printf '#!/bin/sh\nexec sleep 300\n' | sudo tee /usr/local/bin/claude >/dev/null + sudo chmod +x /usr/local/bin/claude + - run: npm ci + # The vitest suite exercises the compiled CLI/daemon (dist/cli.js, + # dist/server.js), so build before running the tests. + - run: npm run build + - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fa29e0..b61ba98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,30 @@ ## Unreleased -### Non-permanent sessions clean themselves up at exit (BREAKING) - -A session that is not `strategy=permanent` now removes its own registry entry — metadata, events file, socket, pid — as its daemon shuts down, once its child process has terminated. Previously the entry lingered until an external `pty gc` sweep noticed it (or until the 24-hour dead-session TTL). Cleanup is now caused by the exit rather than discovered later, which removes both the sweep's polling interval and the window in which a finished session is still listed in `pty ls`. - -- **Applies to clean exits and crashes alike** — both mean the command is finished. -- **New `keep` tag.** `keep=true` exempts a session from reaping, by both the exit-time path and `pty gc`'s sweep, so its metadata/`lastLines`/events survive for inspection until an explicit `pty rm`. Any value other than `false`/`0`/`no`/`off` counts as set. Settable at spawn (`--tag keep=true`) or on a running session (`pty tag keep=true`) — the exit path re-reads tags from disk, so pinning a session you are about to debug works. -- **Exempt:** `keep`, `strategy=permanent` (its supervisor reconciles against the dead session's metadata), external `pty kill`/SIGTERM/SIGINT (stop-and-keep, still deliberately distinct from `pty rm`), and `vanished` sessions (the daemon was SIGKILLed, so no cleanup code ran). -- **`pty gc`'s sweep becomes a backstop.** Its remaining prey is `vanished` sessions — the one case exit-time cleanup structurally cannot cover. `GcResult` gains `kept: string[]`, and `pty gc` prints `Kept (keep tag): ` so a retained dead session does not look like a gc bug. `pty gc`'s other duties (orphan-kill, abandoned-reap, permanent respawn) are unchanged. -- **`--ephemeral` is now a narrower override**, not the only way to get cleanup: it reaps on *any* shutdown, including `pty kill` and including `strategy=permanent`. `keep` wins over it. - -**Migration.** Anything that touched a non-permanent session after it finished must now tag it `keep=true`. Previously such callers had until the next gc tick; they now have no window at all. Affected: `pty peek` (saved output), `pty stats`, `pty restart ` and `pty attach`'s restart prompt (both now report `not found` for a session that reaped itself), `pty tag` on a dead session, and any reader of `.json` / `.events.jsonl` on disk. `strategy=permanent` sessions are unaffected. +### Configurable exit-time cleanup for finished sessions + +Whether a finished non-permanent session reaps itself at exit, or is preserved +(kept listed + `pty peek`-able until `pty gc` sweeps it), is now configurable via +the **`PTY_REAP_ON_EXIT`** environment variable — a network/global knob the +daemon inherits, so it can be set fleet-wide. `false`/`0`/`no`/`off` → preserve; +unset or anything else → **reap** (the shipped default). Two per-session flags +override the configured default either way: + +- **New `keep` tag.** `keep=true` forces **preserve** regardless of the + configured default, and exempts the session from `pty gc`'s sweep too, so its + metadata/`lastLines`/events survive until an explicit `pty rm`. Any value + other than `false`/`0`/`no`/`off` counts as set. Settable at spawn + (`--tag keep=true`) or on a running session (`pty tag keep=true`) — the + exit path and gc both re-read tags from disk. Wins over `--ephemeral`. +- **`--ephemeral` forces reap** regardless of the configured default, on *any* + shutdown including `pty kill` and including `strategy=permanent`. `keep` wins + over it. (Behavior change for existing `-e` callers: a `pty kill`'d ephemeral + session is now reaped rather than kept.) +- **`pty gc` reports kept sessions.** `GcResult` gains `kept: string[]`, and + `pty gc` prints `Kept (keep tag): ` so a retained dead session does not + look like a gc bug. gc remains the owner of finished-session cleanup — it + sweeps preserved/`vanished` non-permanent sessions; its other duties + (orphan-kill, abandoned-reap, permanent respawn) are unchanged. ## 0.12.0 diff --git a/README.md b/README.md index 8fdacf9..7b5d65f 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ pty run -d -- node server.js # start in the background pty run -a -- node server.js # create or attach if already running pty run -e -- npm test # ephemeral: reap even on `pty kill` / permanent pty run --tag owner=forge -- node srv.js # tag a session with metadata -pty run --tag keep=true -- npm test # keep the session listed after it exits +pty run --tag keep=true -- npm test # keep it even past a gc sweep, until you rm it pty run --cwd /path -- node server.js # run in a specific directory pty rename my-label # inside a session: add/change its displayName @@ -98,7 +98,7 @@ pty emit user.deploy.started # emit a user event (inside a session) pty emit myserver user.build.finished --json '{"ok":true}' # with JSON payload pty emit myserver user.note --text "checkpoint reached" # with a text payload -pty restart myserver # restart an exited session (needs `keep=true` if non-permanent) +pty restart myserver # restart an exited session (must have been preserved) pty kill myserver # terminate a running session pty rm myserver # remove an exited session's metadata pty gc # reconcile: kill orphan children, respawn permanents, sweep vanished @@ -149,35 +149,41 @@ Use `pty run -d` to explicitly create a background session from inside another s ### Session lifecycle and cleanup -A session that is **not** `strategy=permanent` removes itself when its command -finishes. The daemon deletes its own registry entry — metadata, events file, -socket, pid — as the last thing it does before exiting. Cleanup is caused by the -exit rather than discovered by a later sweep, so a finished session never sits in -`pty ls` waiting to be noticed. +What happens to a **non-`strategy=permanent`** session when its command finishes +is **configurable**, via the `PTY_REAP_ON_EXIT` environment variable — a +network/global knob (the daemon inherits it, so setting it once configures a +whole fleet): -This applies whether the command exited cleanly or crashed: both mean the session -is finished. Four cases are deliberately exempt: +- **`reap` (shipped default).** The daemon removes its own registry entry — + metadata, events, socket, pid — as it shuts down, so a finished session does + not linger in `pty ls`. +- **`preserve` (`PTY_REAP_ON_EXIT=false`).** The finished session is kept: its + final screen and registry entry stay listed and `pty peek`-able until + `pty gc`'s sweep reclaims it — so a crash you want to inspect is still there. -| Case | Why it is kept | +Two per-session flags override the configured default either way: + +| Flag | Effect | |---|---| -| tag `keep=true` | You asked to inspect it. Metadata, `lastLines`, and events survive until you `pty rm` it. | -| tag `strategy=permanent` | Its supervisor reconciles against the dead session's metadata to respawn it. | -| `pty kill` (and any external SIGTERM/SIGINT) | The command did not finish — someone stopped it, nearly always to go look at it. `kill` is stop-and-keep; `rm` is the one that removes. | -| `status=vanished` | The daemon itself was SIGKILLed/OOM-killed, so no cleanup code ever ran. Nothing inside the process can cover this case. | +| tag `keep=true` | Force **preserve**, and survive even a `pty gc` sweep — metadata/`lastLines`/events last until you `pty rm` it. Wins over everything, including `--ephemeral`. | +| `pty run -e` (`--ephemeral`) | Force **reap**, on *any* shutdown incl. `pty kill` and `strategy=permanent`. `keep` still wins over it. | + +`strategy=permanent` sessions are always preserved (their supervisor reconciles +against the dead metadata to respawn them). `pty kill` also preserves — it is +stop-and-keep, deliberately distinct from `pty rm`. ```sh -pty run -d -- npm test # gone from `pty ls` the moment it finishes -pty run -d --tag keep=true -- npm test # sticks around afterwards so you can read it -pty tag mybuild keep=true # ...or pin a session that is still running -pty rm mybuild # explicit removal beats keep +pty run -d -- npm test # shipped default: reaped when it finishes +PTY_REAP_ON_EXIT=false pty run -d -- npm test # preserved: peekable until gc sweeps it +pty run -d --tag keep=true -- npm test # force-keep, even past a gc sweep, until you rm it +pty run -d -e -- npm test # ephemeral: reaps on any shutdown, leaves no trace +pty rm mybuild # explicit removal beats keep ``` -Because of the `vanished` row, `pty gc`'s sweep is a **backstop, not the primary -path** — see [Auto-running gc](#auto-running-gc). Vanished sessions are also -reclaimed lazily by the 24-hour dead-session TTL on any `pty list`. - -To reap a session on *any* shutdown, including `pty kill` and including -`strategy=permanent`, use `pty run -e` (`--ephemeral`). `keep` still wins over it. +`pty gc`'s sweep reclaims preserved-and-finished (and `vanished`) non-permanent +sessions — see [Auto-running gc](#auto-running-gc). Dead sessions are also +reclaimed lazily by the 24-hour dead-session TTL on any `pty list`. `keep=true` +and `strategy=permanent` are exempt from both. ### Events @@ -331,7 +337,7 @@ Cycles (A→B, B→A) resolve deterministically by name-sorted iteration: whiche `pty gc` is a one-shot reconciliation pass. The intended deployment is to run it on a short interval so permanent sessions come back quickly and orphans get cleaned promptly. The CLI ships an install helper for macOS: -Sweeping finished sessions is **no longer** a reason to run gc on a timer — non-permanent sessions [reap themselves at exit](#session-lifecycle-and-cleanup). What is left for the sweep is `vanished` sessions, whose daemon was killed outright and so never ran its own cleanup; those are also reclaimed lazily by the 24-hour dead-session TTL on any `pty list`. The interval now buys you respawn latency for permanents and orphan-kill promptness, not `pty ls` hygiene. +Whether finished sessions need the sweep depends on [`PTY_REAP_ON_EXIT`](#session-lifecycle-and-cleanup): under the shipped `reap` default they self-clean at exit, so the sweep's finished-session duty is mostly `vanished` sessions (daemon killed outright, so it never ran its own cleanup) plus anything left listed by `preserve` mode. Those are also reclaimed lazily by the 24-hour dead-session TTL on any `pty list`. So the interval primarily buys you respawn latency for permanents and orphan-kill promptness — and, in `preserve` mode, `pty ls` hygiene. `keep=true` and `strategy=permanent` sessions are exempt. ```sh pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.compoundingtech.pty.gc.plist diff --git a/src/server.ts b/src/server.ts index f4576e3..1f80206 100644 --- a/src/server.ts +++ b/src/server.ts @@ -27,6 +27,7 @@ import { writeMetadata, readMetadata, shouldReapAtExit, + reapOnExitDefault, type SessionMetadata, } from "./sessions.ts"; import { EventWriter, clearEvents, EventType, type EventRecord } from "./events.ts"; @@ -1033,8 +1034,9 @@ if (process.argv[1]?.endsWith("/server.js")) { // reasons, and only one of them is an "exit": // // - the child process terminated on its own (cleanly or by crashing) - // — the session is finished, and finished non-permanent sessions are - // garbage. Reap. + // — the session is finished; whether it self-reaps is the config + // default (`reapOnExitDefault`, via `PTY_REAP_ON_EXIT`), unless a + // per-session `keep`/`--ephemeral` overrides it. // - someone stopped the daemon from outside (`pty kill` → SIGTERM, // SIGINT, or the spawner watchdog reclaiming a leaked daemon) — the // child had not finished; an operator interrupted it, almost always @@ -1049,7 +1051,9 @@ if (process.argv[1]?.endsWith("/server.js")) { function reapAtExit(): boolean { if (externalKill && !isEphemeral) return false; const tags = readMetadata(config.name)?.tags ?? config.tags; - return shouldReapAtExit(tags, isEphemeral); + // `PTY_REAP_ON_EXIT` (network/global config) sets the default when no + // per-session `keep`/`--ephemeral` override applies; shipped default reaps. + return shouldReapAtExit(tags, isEphemeral, reapOnExitDefault()); } // Hard deadline for a graceful shutdown before the daemon force-exits. The diff --git a/src/sessions.ts b/src/sessions.ts index a30830b..27c5f33 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -430,36 +430,55 @@ export function isKeepRequested(tags?: Record): boolean { /** Should the daemon remove its own registry entry as it shuts down? * - * This is the policy behind exit-time cleanup. A non-permanent session - * that dies is garbage the moment it dies — cleaning up as part of its own - * lifecycle removes both the polling interval of an external sweep and the - * window in which a dead session is still listed. Precedence, highest - * first: + * Exit-time reaping is CONFIGURABLE. `defaultReap` is the config default (see + * `reapOnExitDefault` — the `PTY_REAP_ON_EXIT` network/global knob), and two + * per-session flags override it either way. Precedence, highest first: * - * 1. `keep` — the explicit "don't reap me" exemption always wins, even - * over `--ephemeral`. Its entire purpose is retaining a dead - * session's logs and scrollback for debugging. - * 2. `--ephemeral` — the historic explicit opt-in. Still forces a reap - * even for a permanent session, which is what it did before this - * policy existed; unchanged so no existing caller regresses. - * 3. `strategy=permanent` — never self-reaps. Its supervisor (convoy, - * or `pty gc`'s respawn step) reads the metadata of the dead session - * to respawn it. Self-reaping would destroy the very record the - * supervisor reconciles against. - * 4. Everything else — non-permanent, so reap. + * 1. `keep` — force PRESERVE. Always wins, even over `--ephemeral`, and also + * exempts the session from `pty gc`'s sweep. Retains a dead session's + * logs and scrollback for debugging past even a gc pass. + * 2. `--ephemeral` — force REAP. Reaps as the session shuts down (the + * aggressive opt-in), even for a `strategy=permanent` session, so a + * caller that wants no trace left gets it regardless of the config + * default. + * 3. `strategy=permanent` — force PRESERVE. Its supervisor / `pty gc`'s + * respawn step reconciles against the dead session's metadata, so + * reaping it would destroy the record the respawn needs. + * 4. `defaultReap` — the config default when none of the above apply. + * `true` reaps a finished non-permanent session at exit; `false` + * PRESERVES it (its metadata lingers, peekable, until `pty gc`'s sweep + * reclaims it). * - * Note what is *not* representable here: a session whose daemon was itself - * SIGKILL'd (`status=vanished`) never runs this code, because the process - * that would run it is the one that died. Vanished sessions still require - * an external sweep. */ + * A session whose daemon was SIGKILL'd (`status=vanished`) never runs this + * code and is reclaimed by gc's sweep. */ export function shouldReapAtExit( tags: Record | undefined, ephemeral: boolean, + // Optional so existing 2-arg callers (relay/layout/supervisors read this to + // answer "is this session exempt from reaping?") keep working AND get the + // correct env-driven default without having to thread it themselves. + defaultReap: boolean = reapOnExitDefault(), ): boolean { if (isKeepRequested(tags)) return false; if (ephemeral) return true; if (tags?.strategy === "permanent") return false; - return true; + return defaultReap; +} + +/** Resolve the config default for exit-time reaping from the environment. + * + * `PTY_REAP_ON_EXIT` is the network/global config knob: the daemon reads its + * own env (which the launching network sets), so setting it fleet-wide + * configures the default for every session — mirroring the env-var config + * style pty already uses for `PTY_SHUTDOWN_DEADLINE_MS`. `false`/`0`/`no`/`off` + * → PRESERVE; unset or anything else → REAP (the shipped default). Per-session + * `keep` / `--ephemeral` override this default either way. */ +export function reapOnExitDefault( + env: NodeJS.ProcessEnv = process.env, +): boolean { + const raw = env.PTY_REAP_ON_EXIT; + if (raw === undefined) return true; + return !KEEP_FALSEY.has(raw.trim().toLowerCase()); } /** Look up a session by either its stable `name` (immutable id) or its diff --git a/tests/exit-reap.test.ts b/tests/exit-reap.test.ts index 6e2c038..2b886f9 100644 --- a/tests/exit-reap.test.ts +++ b/tests/exit-reap.test.ts @@ -1,15 +1,17 @@ -// Exit-time cleanup of dead non-permanent sessions. +// Exit-time cleanup of finished sessions — CONFIGURABLE. // -// A non-permanent session that finishes is garbage the moment it finishes. -// Rather than leaving its registry entry for a later `pty gc` sweep to -// notice, the daemon removes it as part of its own shutdown — so removal is -// CAUSED by the exit instead of discovered afterwards. That deletes both the -// sweep's polling interval and the window in which a dead session is still -// listed in `pty ls`. +// Whether a finished non-permanent session reaps itself at exit, or is preserved +// (kept listed + peekable until `pty gc` sweeps it), is the config default set +// by the `PTY_REAP_ON_EXIT` network/global knob (see `reapOnExitDefault`). The +// SHIPPED default is REAP. Two per-session flags override the default either +// way: `keep=true` forces preserve (and also exempts from gc), `--ephemeral` +// forces reap. // -// This file pins the whole policy, including its deliberate exemptions. The -// exemptions matter as much as the reap: each one is a case where the dead -// session's metadata/scrollback is still load-bearing for somebody. +// This file pins the whole policy: the reap default (the first describe runs +// with PTY_REAP_ON_EXIT unset — the global test setup scrubs any ambient +// value), the preserve mode (PTY_REAP_ON_EXIT=false), and every per-session +// exemption — each a case where the dead session's metadata/scrollback is still +// load-bearing for somebody. import { describe, it, expect, afterEach, afterAll } from "vitest"; import * as fs from "node:fs"; @@ -47,7 +49,7 @@ async function startDaemon( name: string, command: string, args: string[] = [], - opts: { tags?: Record; ephemeral?: boolean } = {}, + opts: { tags?: Record; ephemeral?: boolean; env?: Record } = {}, ): Promise { const config = JSON.stringify({ name, command, args, displayCommand: command, @@ -58,7 +60,7 @@ async function startDaemon( const child = spawn(nodeBin, [serverModule], { detached: true, stdio: ["ignore", "ignore", "pipe"], - env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir, ...(opts.env ?? {}) }, }); let stderr = ""; child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); @@ -194,6 +196,46 @@ describe("exit-time reap: sessions that clean themselves up", () => { }, 20000); }); +describe("config default = preserve (PTY_REAP_ON_EXIT=false)", () => { + const PRESERVE = { env: { PTY_REAP_ON_EXIT: "false" } }; + + it("preserves a finished non-permanent session (peekable until gc)", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + const pid = await startDaemon(dir, name, "true", [], PRESERVE); + + await waitForDaemonExit(pid); + await new Promise((r) => setTimeout(r, 1000)); + // With the preserve default the exit path does NOT reap: the metadata + // survives so the session stays listed + peekable. + expect(sessionFiles(dir, name).some((f) => f.endsWith(".json"))).toBe(true); + }, 20000); + + it("still lets `pty gc` sweep the preserved session (gc owns cleanup)", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + const pid = await startDaemon(dir, name, "true", [], PRESERVE); + await waitForDaemonExit(pid); + await new Promise((r) => setTimeout(r, 1000)); + expect(sessionFiles(dir, name).some((f) => f.endsWith(".json"))).toBe(true); + + const gc = runCli(dir, "gc"); + expect(gc.stdout).toContain(`Removed: ${name}`); + expect(sessionFiles(dir, name)).toEqual([]); + }, 25000); + + it("--ephemeral still forces a reap even under the preserve default", async () => { + // The per-session override wins over the config default in the reap + // direction, mirroring how `keep` wins in the preserve direction. + const dir = makeSessionDir(); + const name = uniqueName(); + const pid = await startDaemon(dir, name, "true", [], { ...PRESERVE, ephemeral: true }); + + await waitForDaemonExit(pid); + expect(await waitForGone(dir, name)).toEqual([]); + }, 20000); +}); + describe("exit-time reap: exemptions", () => { it("retains a session tagged keep=true", async () => { // `keep` exists precisely to hold a dead session's logs and scrollback diff --git a/tests/help.test.ts b/tests/help.test.ts index 12bedd6..ad5d979 100644 --- a/tests/help.test.ts +++ b/tests/help.test.ts @@ -17,10 +17,13 @@ const COMMANDS = [ ]; // Aliases that must resolve to the same help. const ALIASES = ["a", "ls", "remove"]; -// Dispatch `case` labels that are NOT per-subcommand commands (no focused help -// expected): the interactive TUI, and the global help/version verbs+flags. +// Dispatch `case` labels that are NOT per-subcommand commands (no focused +// `Usage: pty …` + example help expected): the interactive TUI, the global +// help/version verbs+flags, and utility generators that ship their own +// lightweight usage (`completions` prints `usage: pty completions `). const NON_COMMAND_CASES = new Set([ "interactive", "i", "help", "--help", "-h", "version", "--version", "-v", "-V", + "completions", ]); function help(cmd: string) { diff --git a/tests/parity-node-reference.test.ts b/tests/parity-node-reference.test.ts index b326130..bdb34ff 100644 --- a/tests/parity-node-reference.test.ts +++ b/tests/parity-node-reference.test.ts @@ -55,6 +55,7 @@ async function startDaemon( name: string, command: string, args: string[] = [], + env: Record = {}, ): Promise { const config = JSON.stringify({ name, command, args, displayCommand: command, @@ -63,7 +64,7 @@ async function startDaemon( const child = spawn(nodeBin, [serverModule], { detached: true, stdio: ["ignore", "ignore", "pipe"], - env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir, ...env }, }); let stderr = ""; child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); @@ -115,14 +116,23 @@ afterEach(() => { }); // --------------------------------------------------------------------------- -// #1 — post-exit peek preserves the final screen +// #1 — post-exit peek, under BOTH exit-behavior modes // --------------------------------------------------------------------------- -describe("parity #1: post-exit peek preserves the final screen", () => { - it("returns the exact final viewport after the session exits, idempotently", async () => { +// Exit-time reaping is configurable (`PTY_REAP_ON_EXIT`; shipped default REAP). +// The parity contract for post-exit peek therefore has TWO reference modes both +// implementations must match: +// * preserve (PTY_REAP_ON_EXIT=false): the finished session is kept, and peek +// returns the exact final viewport, idempotently. +// * reap (default): the finished session removes itself, and peek reports it +// is gone (exit non-zero / "not found"), with no registry entry left. +const PRESERVE_ENV = { PTY_REAP_ON_EXIT: "false" }; + +describe("parity #1: post-exit peek — preserve vs reap modes", () => { + it("preserve mode: returns the exact final viewport after exit, idempotently", async () => { const dir = makeSessionDir(); const name = uniqueName(); // Three lines, DONE with no trailing newline, then exit 7. - await startDaemon(dir, name, "sh", ["-c", 'printf "LINE_A\\nLINE_B\\nDONE"; exit 7']); + await startDaemon(dir, name, "sh", ["-c", 'printf "LINE_A\\nLINE_B\\nDONE"; exit 7'], PRESERVE_ENV); await sleep(1200); // let it exit + persist the final screen const first = runCli(dir, ["peek", "--plain", name]); @@ -145,10 +155,10 @@ describe("parity #1: post-exit peek preserves the final screen", () => { expect(found.exitCode).toBe(7); }, 20000); - it("non-plain (ANSI) peek preserves the same content after exit", async () => { + it("preserve mode: non-plain (ANSI) peek preserves the same content after exit", async () => { const dir = makeSessionDir(); const name = uniqueName(); - await startDaemon(dir, name, "sh", ["-c", 'printf "ALPHA\\nBETA"; exit 0']); + await startDaemon(dir, name, "sh", ["-c", 'printf "ALPHA\\nBETA"; exit 0'], PRESERVE_ENV); await sleep(1200); const ansi = runCli(dir, ["peek", name]); @@ -156,6 +166,21 @@ describe("parity #1: post-exit peek preserves the final screen", () => { expect(ansi.stdout).toContain("ALPHA"); expect(ansi.stdout).toContain("BETA"); }, 20000); + + it("reap mode (default): the finished session reaps itself — peek reports it gone", async () => { + const dir = makeSessionDir(); + const name = uniqueName(); + // No PTY_REAP_ON_EXIT → shipped default REAP: the daemon removes its own + // registry entry as it exits, so there is nothing left to peek. + await startDaemon(dir, name, "sh", ["-c", 'printf "GONE"; exit 0']); + await sleep(1200); + + const peek = runCli(dir, ["peek", "--plain", name]); + expect(peek.status).not.toBe(0); + // And the registry no longer lists it. + const list = JSON.parse(runCli(dir, ["list", "--json"]).stdout); + expect(list.find((s: any) => s.name === name)).toBeUndefined(); + }, 20000); }); // --------------------------------------------------------------------------- diff --git a/tests/setup/isolate-env.ts b/tests/setup/isolate-env.ts index 0e9c796..37046f6 100644 --- a/tests/setup/isolate-env.ts +++ b/tests/setup/isolate-env.ts @@ -19,3 +19,8 @@ delete process.env.PTY_ROOT; delete process.env.PTY_SESSION; delete process.env.PTY_SESSION_DIR; +// Also scrub the exit-reap config knob: the ambient network may set +// PTY_REAP_ON_EXIT, and lifecycle tests must exercise a deterministic default +// (unset → the shipped `reap` default). Tests that want the other mode pass +// PTY_REAP_ON_EXIT explicitly per-child. +delete process.env.PTY_REAP_ON_EXIT;