This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(spawn): opt-in PTY_SPAWNER_PID watchdog to prevent orphaned daemons (#39)

`spawnDaemon` produces a detached daemon process that has no mechanism to
self-terminate when its spawner exits without calling `disconnect()` /
`kill()`. Because `detached: true` puts the daemon in its own session,
the kernel sends no signal when the spawner dies — the daemon is
reparented to init and survives forever.

In practice this leaks daemons whenever a short-lived script, test
harness, or scoped resource (e.g. `@overeng/pty-effect`) spawns a daemon
and exits. We observed hundreds of `dist/server.js` processes
accumulating multi-GiB RSS over days. See effect-utils#677.

Add a new `bindToSpawnerLifetime` option (opt-in, default off). When set,
`spawn.ts` injects `PTY_SPAWNER_PID=<pid>` into the daemon's env, and the
daemon polls `process.kill(pid, 0)` every 5 s. ESRCH triggers a clean
shutdown via the existing `cleanShutdown(0)` path. An invalid or
already-dead PID at startup causes immediate shutdown.

Long-lived supervisors that want daemons to outlive them simply omit
the option — full backwards compatibility.

Refs: overengineeringstudio/effect-utils#677

authored by

AI assistant of @schickling and committed by
GitHub
(Jun 11, 2026, 4:08 PM EDT) 443717fc adcec22a

+261 -1
+3
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Spawn lifecycle 6 + - **`spawnDaemon({ bindToSpawnerLifetime: true })` ties the daemon's lifetime to the spawner.** Off by default, so existing callers see no change. When enabled, `spawn.ts` sets `PTY_SPAWNER_PID=<spawner pid>` in the daemon's env; `server.ts` polls that PID every 5 s and calls `cleanShutdown(0)` once it's gone (and exits immediately if the spawner is already dead at startup). Fixes the orphan-daemon class of bug where a short-lived spawner (script, test harness, scoped Effect resource) exits without calling `disconnect()` / `kill()` and leaves the daemon reparented to init forever — observed in the wild as hundreds of `@myobie/pty/dist/server.js` processes accumulating multi-GiB RSS over days. Long-lived supervisors that want the daemon to outlive them simply omit the option. Implemented via env var rather than a runtime API so the daemon (a separate process) needs no IPC. See [overengineeringstudio/effect-utils#677](https://github.com/overengineeringstudio/effect-utils/issues/677) for the full write-up. 7 + 5 8 ### Interactive TUI 6 9 - **Home list auto-refreshes when sessions change underneath it** (closes #26). Previously the overview was a snapshot taken on screen entry — new sessions, exits, tag changes, and rename events didn't appear until the user navigated away and back. Now the home screen polls `listSessions()` once per second while it's the active view, pushing the result through the `sessions` signal so the framework's reactive render picks it up. Polling pauses while the user is attached / spawning a new session and resumes on return; the interval is `unref()`-ed so it never holds the event loop alive on its own (the TUI exits cleanly when the user quits or stdin closes). Polling was chosen over `fs.watch` / `EventFollower` because cross-process file-watching through the node-pty child layer is unreliable on macOS — polling is simpler, platform-independent, and cheap (one `readdir` + per-file `stat` per second). 7 10 - **Filter and selection persist across attach/detach** (closes #27). Previously, the overview's filter input and selection were cleared every time the user attached to a session and came back — `doAttach` (and the remote-spawn / remote-attach paths) explicitly reset `filterField` to empty on `onDetach` / `onExit`. That made jumping in and out of sessions feel disorienting. Now the four return paths preserve both. The selection still clamps when the underlying list shrinks (a session that exited and got reaped while the user was attached), so it never points off the end. The clear-filter-on-spawn behavior was the same code path; it's also gone, which means `+ Create new session...` followed by detach lands you back on your filtered view too.
+46
src/server.ts
··· 896 896 } 897 897 } 898 898 899 + /** How often the spawner-PID watchdog checks for liveness. 5s is fast enough 900 + * that a leaked daemon is reclaimed promptly without producing meaningful 901 + * CPU load. */ 902 + const SPAWNER_POLL_INTERVAL_MS = 5000; 903 + 904 + /** Returns true if `pid` refers to a live process this user can signal. 905 + * `kill(pid, 0)` is the standard POSIX liveness probe — sends no signal, 906 + * only validates the target. ESRCH means dead; EPERM means alive but 907 + * unsignalable (still "alive" for our purposes). */ 908 + function isProcessAlive(pid: number): boolean { 909 + try { 910 + process.kill(pid, 0); 911 + return true; 912 + } catch (err) { 913 + return (err as NodeJS.ErrnoException).code === "EPERM"; 914 + } 915 + } 916 + 917 + function installSpawnerWatchdog(cleanShutdown: (code: number) => Promise<never>): void { 918 + const raw = process.env.PTY_SPAWNER_PID; 919 + if (!raw) return; 920 + const pid = Number(raw); 921 + if (!Number.isInteger(pid) || pid <= 1) return; 922 + if (!isProcessAlive(pid)) { 923 + // Already dead by the time we boot — exit before clients can connect. 924 + void cleanShutdown(0); 925 + return; 926 + } 927 + const interval = setInterval(() => { 928 + if (isProcessAlive(pid)) return; 929 + clearInterval(interval); 930 + void cleanShutdown(0); 931 + }, SPAWNER_POLL_INTERVAL_MS); 932 + // Don't keep the event loop alive just for this poll. 933 + interval.unref?.(); 934 + } 935 + 899 936 /** Entry point when this file is run as the daemon process. */ 900 937 if (process.argv[1]?.endsWith("/server.js")) { 901 938 const config = JSON.parse(process.env.PTY_SERVER_CONFIG ?? "{}"); ··· 934 971 935 972 process.on("SIGTERM", () => cleanShutdown(0)); 936 973 process.on("SIGINT", () => cleanShutdown(0)); 974 + 975 + // Spawner-PID watchdog (opt-in via PTY_SPAWNER_PID). 976 + // 977 + // `detached: true` puts the daemon in its own session, so the kernel sends 978 + // no signal when the spawner exits — and the daemon ends up reparented to 979 + // init, surviving forever. When the spawner sets PTY_SPAWNER_PID, we poll 980 + // for its liveness and call cleanShutdown() once it's gone. Off when the 981 + // env var is absent, so existing callers see no behaviour change. 982 + installSpawnerWatchdog(cleanShutdown); 937 983 }
+18 -1
src/spawn.ts
··· 65 65 * bundled-context fallback) — the CLI handles its own runtime selection. 66 66 */ 67 67 launcher?: { command: string; args?: string[] }; 68 + /** Bind the daemon's lifetime to this process. When true, the daemon polls 69 + * for the spawner's PID every few seconds and shuts down cleanly once the 70 + * spawner is gone — preventing orphaned daemons reparented to init when 71 + * the spawner exits without calling `disconnect()` / `kill()`. 72 + * 73 + * Off by default to preserve the historical "daemon outlives spawner" 74 + * semantics relied on by long-lived supervisors. Opt in when the caller 75 + * is the sole owner of the daemon (e.g., short-lived scripts, test 76 + * harnesses, `@overeng/pty-effect` scopes). 77 + * 78 + * Implemented via the `PTY_SPAWNER_PID` env var read by the daemon. */ 79 + bindToSpawnerLifetime?: boolean; 68 80 /** Time in ms to wait for the daemon's Unix socket to appear before 69 81 * giving up. Defaults to 30000 (30s) — generous enough for heavy 70 82 * startups like `claude --resume` of a large session, while still ··· 135 147 136 148 const launcherCmd = options.launcher?.command ?? process.execPath; 137 149 const launcherArgs = options.launcher?.args ?? []; 150 + // PTY_SPAWNER_PID lets the daemon poll for spawner liveness and shut down 151 + // when its spawner is gone. Off by default — opt in via 152 + // `bindToSpawnerLifetime` when the caller owns the daemon's lifetime. 153 + const env: Record<string, string> = { ...process.env, PTY_SERVER_CONFIG: config }; 154 + if (options.bindToSpawnerLifetime) env.PTY_SPAWNER_PID = String(process.pid); 138 155 const child = spawn(launcherCmd, [...launcherArgs, serverModule], { 139 156 detached: true, 140 157 stdio: ["ignore", "ignore", "pipe"], 141 - env: { ...process.env, PTY_SERVER_CONFIG: config }, 158 + env, 142 159 }); 143 160 144 161 let stderrOutput = "";
+194
tests/spawner-pid-watchdog.test.ts
··· 1 + import { describe, it, expect, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, spawnSync } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 11 + 12 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-watchdog-")); 13 + afterAll(() => { 14 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 15 + }); 16 + 17 + function isAlive(pid: number): boolean { 18 + try { 19 + process.kill(pid, 0); 20 + return true; 21 + } catch (err) { 22 + return (err as NodeJS.ErrnoException).code === "EPERM"; 23 + } 24 + } 25 + 26 + async function waitUntilDead(pid: number, timeoutMs: number): Promise<boolean> { 27 + const start = Date.now(); 28 + while (Date.now() - start < timeoutMs) { 29 + if (!isAlive(pid)) return true; 30 + await new Promise((r) => setTimeout(r, 100)); 31 + } 32 + return false; 33 + } 34 + 35 + function waitForFile(p: string, timeoutMs: number): Promise<void> { 36 + const start = Date.now(); 37 + return new Promise((resolve, reject) => { 38 + const tick = () => { 39 + try { 40 + fs.statSync(p); 41 + resolve(); 42 + return; 43 + } catch {} 44 + if (Date.now() - start > timeoutMs) { 45 + reject(new Error(`Timeout waiting for ${p}`)); 46 + return; 47 + } 48 + setTimeout(tick, 50); 49 + }; 50 + tick(); 51 + }); 52 + } 53 + 54 + /** 55 + * Start a daemon directly (no spawner), with an explicit PTY_SPAWNER_PID 56 + * env var pointing at `spawnerPid`. Returns the daemon PID. 57 + */ 58 + async function startDaemonWithSpawnerPid( 59 + sessionDir: string, 60 + name: string, 61 + spawnerPid: number | undefined, 62 + ): Promise<number> { 63 + const config = JSON.stringify({ 64 + name, 65 + command: "sleep", 66 + args: ["3600"], 67 + displayCommand: "sleep 3600", 68 + cwd: os.tmpdir(), 69 + rows: 24, 70 + cols: 80, 71 + }); 72 + 73 + const env: Record<string, string> = { 74 + ...(process.env as Record<string, string>), 75 + PTY_SERVER_CONFIG: config, 76 + PTY_SESSION_DIR: sessionDir, 77 + }; 78 + if (spawnerPid !== undefined) env.PTY_SPAWNER_PID = String(spawnerPid); 79 + else delete env.PTY_SPAWNER_PID; 80 + 81 + const child = spawn(nodeBin, [serverModule], { 82 + detached: true, 83 + stdio: ["ignore", "ignore", "pipe"], 84 + env, 85 + }); 86 + child.unref(); 87 + 88 + await waitForFile(path.join(sessionDir, `${name}.sock`), 5000); 89 + return child.pid!; 90 + } 91 + 92 + describe("PTY_SPAWNER_PID watchdog", () => { 93 + it("shuts down the daemon when the spawner PID dies", async () => { 94 + // Stand-in spawner: an idle node process we control. We start the 95 + // daemon while it's alive, then kill the spawner and observe shutdown. 96 + const spawner = spawn(nodeBin, ["-e", "setInterval(() => {}, 1_000_000)"], { 97 + stdio: "ignore", 98 + }); 99 + const spawnerPid = spawner.pid!; 100 + const sessionDir = fs.mkdtempSync(path.join(testRoot, "d-")); 101 + const name = `wd-${Math.random().toString(36).slice(2, 6)}`; 102 + 103 + const daemonPid = await startDaemonWithSpawnerPid(sessionDir, name, spawnerPid); 104 + expect(isAlive(daemonPid)).toBe(true); 105 + 106 + // Kill the spawner and wait for it to fully exit. 107 + process.kill(spawnerPid, "SIGKILL"); 108 + await new Promise<void>((resolve) => spawner.on("exit", () => resolve())); 109 + expect(isAlive(spawnerPid)).toBe(false); 110 + 111 + // Watchdog polls every 5s; allow up to ~10s for the daemon to notice. 112 + const died = await waitUntilDead(daemonPid, 12_000); 113 + if (!died) { 114 + try { process.kill(daemonPid, "SIGTERM"); } catch {} 115 + } 116 + expect(died).toBe(true); 117 + }, 20_000); 118 + 119 + it("exits immediately if the spawner PID is already dead at startup", async () => { 120 + // Spawn-and-reap a child to obtain a pid that's guaranteed dead. 121 + const dead = spawnSync(nodeBin, ["-e", ""], { stdio: "ignore" }); 122 + const deadPid = dead.pid!; 123 + expect(isAlive(deadPid)).toBe(false); 124 + 125 + const sessionDir = fs.mkdtempSync(path.join(testRoot, "d-")); 126 + const name = `wd-dead-${Math.random().toString(36).slice(2, 6)}`; 127 + 128 + // Don't waitForFile here — the daemon may exit before the socket appears. 129 + const config = JSON.stringify({ 130 + name, 131 + command: "sleep", 132 + args: ["3600"], 133 + displayCommand: "sleep 3600", 134 + cwd: os.tmpdir(), 135 + rows: 24, 136 + cols: 80, 137 + }); 138 + const child = spawn(nodeBin, [serverModule], { 139 + detached: true, 140 + stdio: ["ignore", "ignore", "pipe"], 141 + env: { 142 + ...(process.env as Record<string, string>), 143 + PTY_SERVER_CONFIG: config, 144 + PTY_SESSION_DIR: sessionDir, 145 + PTY_SPAWNER_PID: String(deadPid), 146 + }, 147 + }); 148 + child.unref(); 149 + 150 + const died = await waitUntilDead(child.pid!, 8_000); 151 + if (!died) { 152 + try { process.kill(child.pid!, "SIGTERM"); } catch {} 153 + } 154 + expect(died).toBe(true); 155 + }, 15_000); 156 + 157 + it("ignores invalid PTY_SPAWNER_PID values (no behaviour change)", async () => { 158 + const sessionDir = fs.mkdtempSync(path.join(testRoot, "d-")); 159 + const name = `wd-bad-${Math.random().toString(36).slice(2, 6)}`; 160 + 161 + const config = JSON.stringify({ 162 + name, 163 + command: "sleep", 164 + args: ["3600"], 165 + displayCommand: "sleep 3600", 166 + cwd: os.tmpdir(), 167 + rows: 24, 168 + cols: 80, 169 + }); 170 + const child = spawn(nodeBin, [serverModule], { 171 + detached: true, 172 + stdio: ["ignore", "ignore", "pipe"], 173 + env: { 174 + ...(process.env as Record<string, string>), 175 + PTY_SERVER_CONFIG: config, 176 + PTY_SESSION_DIR: sessionDir, 177 + PTY_SPAWNER_PID: "not-a-pid", 178 + }, 179 + }); 180 + child.unref(); 181 + 182 + try { 183 + await waitForFile(path.join(sessionDir, `${name}.sock`), 5000); 184 + expect(isAlive(child.pid!)).toBe(true); 185 + // Daemon should still be alive a moment later — invalid PID disables 186 + // the watchdog rather than shutting down. 187 + await new Promise((r) => setTimeout(r, 500)); 188 + expect(isAlive(child.pid!)).toBe(true); 189 + } finally { 190 + try { process.kill(child.pid!, "SIGTERM"); } catch {} 191 + await waitUntilDead(child.pid!, 3000); 192 + } 193 + }, 10_000); 194 + });