This repository has no description
0

Configure Feed

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

fix(tests): reap leaked pty daemons at vitest teardown (#52)

* fix(tests): reap leaked pty daemons at vitest teardown

Tests spawn pty daemons into mkdtempSync-based PTY_SESSION_DIRs. The
daemons detach by design (that's how `pty attach` after a crash works)
and vitest exits without reaping them, so every test run leaked a few
ppid=1 pty daemons + their cat leaves. Over days across pty +
pty-relay, this drove /dev/ttys past kern.tty.ptmx_max=511, blocking
new spawns entirely (`posix_spawnp failed`).

Fix: add a vitest globalSetup module that
1. mkdtempSync's a per-run root under /tmp (short prefix avoids the
macOS 103-byte AF_UNIX path limit — /var/folders/.../pv-XXX would
push nested test socket paths over),
2. exports PTY_VITEST_RUN_ROOT + overrides TMPDIR so every test's
os.tmpdir() call resolves into the run root (zero per-test changes
across the 43 files that call os.tmpdir()),
3. on teardown, `lsof +D $runRoot` enumerates every pid holding a
file or socket under the tree, SIGTERMs them, waits 2s, SIGKILLs
survivors, then rm -rfs the run root.

Verified on full suite (86 files, 1197 passed, 21 skipped, 0 failed):
daemons before = daemons after; ttys before = ttys after; teardown
reports "SIGTERM N leaked pids" for the daemons that would previously
have leaked.

* fix(tests): also sweep unix sockets in teardown — `lsof +D` silent-miss

`lsof +D <dir>` on macOS walks the filesystem tree but does not index
unix-socket-name entries. A process holding ONLY a bound listen socket
inside <dir> is invisible to that sweep. That describes the pty daemon
exactly: it holds its .sock file in the session dir, but no other open
files or cwd.

Reproducer:
mkdir -p /tmp/probe/x && node -e '
const s=require("net").createServer();
s.listen("/tmp/probe/x/leak.sock", () => setInterval(()=>{},1e9));
' &
lsof -Fpn +D /tmp/probe # → empty
lsof -Fpn -U | grep probe # → finds it

Add a second sweep: `lsof -Fpn -U` and match sockets by name prefix
(runRoot/ and /private<runRoot>/ — macOS symlinks /tmp -> /private/tmp
and the daemon's socket name resolves to the /private twin). Union the
pids with the +D sweep, then SIGTERM/SIGKILL as before.

Verified: on tests/nesting-prevention.test.ts the previous teardown
reported "SIGTERM 3 leaked pids"; with the -U sweep added it reports 6.
The 3 the +D-only sweep missed were the ones holding only sockets.
Full suite still 1197 passed / 21 skipped / 0 failed; daemon + tty
counts before == after (delta 0).

Credit: pty-relay-claude flagged the silent miss while implementing the
mirror fix in pty-relay (PR #25).

authored by

Nathan and committed by
GitHub
(Jul 3, 2026, 10:20 AM +0200) 448824a6 b88d9fe7

+115
+114
tests/setup/vitest-global.ts
··· 1 + import { execSync } from "node:child_process"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + 6 + let runRoot: string | undefined; 7 + 8 + function shortBaseTmp(): string { 9 + return process.platform === "win32" ? os.tmpdir() : "/tmp"; 10 + } 11 + 12 + export async function setup(): Promise<void> { 13 + runRoot = fs.mkdtempSync(path.join(shortBaseTmp(), `pv-`)); 14 + process.env.PTY_VITEST_RUN_ROOT = runRoot; 15 + process.env.TMPDIR = runRoot; 16 + process.stderr.write(`[vitest-global] runRoot=${runRoot}\n`); 17 + } 18 + 19 + function collectPidsHoldingUnder(root: string): Set<number> { 20 + const pids = new Set<number>(); 21 + // macOS symlinks /tmp -> /private/tmp; a socket bound to /tmp/pv-X/sock 22 + // reports as /private/tmp/pv-X/sock in `lsof -U`. 23 + const prefixes = [root + "/", "/private" + root + "/"]; 24 + 25 + // Sweep 1: files or directories under root (cwd, open files, mmaps). 26 + // Misses processes whose ONLY tie to root is a bound listen socket. 27 + let dTree = ""; 28 + try { 29 + dTree = execSync(`lsof -Fpn +D "${root}" 2>/dev/null || true`, { 30 + encoding: "utf8", 31 + maxBuffer: 32 * 1024 * 1024, 32 + }); 33 + } catch { 34 + /* lsof exits non-zero on no matches */ 35 + } 36 + for (const line of dTree.split("\n")) { 37 + if (line.startsWith("p")) { 38 + const n = Number(line.slice(1)); 39 + if (Number.isFinite(n) && n !== process.pid && n > 1) pids.add(n); 40 + } 41 + } 42 + 43 + // Sweep 2: unix-domain sockets. `+D` does not index socket-name entries, 44 + // so a daemon holding only its listen socket inside root is invisible to 45 + // sweep 1. Walk `lsof -U` and match sockets by name prefix. 46 + let uSockets = ""; 47 + try { 48 + uSockets = execSync(`lsof -Fpn -U 2>/dev/null || true`, { 49 + encoding: "utf8", 50 + maxBuffer: 64 * 1024 * 1024, 51 + }); 52 + } catch { 53 + /* same */ 54 + } 55 + let curPid: number | undefined; 56 + for (const line of uSockets.split("\n")) { 57 + if (line.startsWith("p")) { 58 + const n = Number(line.slice(1)); 59 + curPid = Number.isFinite(n) ? n : undefined; 60 + } else if (line.startsWith("n") && curPid !== undefined) { 61 + const name = line.slice(1); 62 + if (prefixes.some((p) => name.startsWith(p))) { 63 + if (curPid !== process.pid && curPid > 1) pids.add(curPid); 64 + } 65 + } 66 + } 67 + return pids; 68 + } 69 + 70 + export async function teardown(): Promise<void> { 71 + if (!runRoot) return; 72 + const root = runRoot; 73 + 74 + const pids = collectPidsHoldingUnder(root); 75 + 76 + const pidArr = Array.from(pids); 77 + if (pidArr.length > 0) { 78 + process.stderr.write(`[vitest-global] SIGTERM ${pidArr.length} leaked pids\n`); 79 + for (const pid of pidArr) { 80 + try { 81 + process.kill(pid, "SIGTERM"); 82 + } catch { 83 + // already gone 84 + } 85 + } 86 + await new Promise((r) => setTimeout(r, 2000)); 87 + const survivors: number[] = []; 88 + for (const pid of pidArr) { 89 + try { 90 + process.kill(pid, 0); 91 + survivors.push(pid); 92 + } catch { 93 + // exited 94 + } 95 + } 96 + if (survivors.length > 0) { 97 + process.stderr.write(`[vitest-global] SIGKILL ${survivors.length} survivors\n`); 98 + for (const pid of survivors) { 99 + try { 100 + process.kill(pid, "SIGKILL"); 101 + } catch { 102 + // gone between poll and kill 103 + } 104 + } 105 + await new Promise((r) => setTimeout(r, 500)); 106 + } 107 + } 108 + 109 + try { 110 + fs.rmSync(root, { recursive: true, force: true, maxRetries: 3 }); 111 + } catch { 112 + // best-effort; something inside may still be busy briefly 113 + } 114 + }
+1
vitest.config.ts
··· 5 5 exclude: [ 6 6 "node_modules/**", 7 7 ], 8 + globalSetup: ["./tests/setup/vitest-global.ts"], 8 9 }, 9 10 });