From b3a27173feff4ecd74e2ae03955214d386277905 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:06:17 -0700 Subject: [PATCH] =?UTF-8?q?fix(browse):=20absorb=20#2414=20residuals=20?= =?UTF-8?q?=E2=80=94=20EPERM-alive=20liveness=20+=20Windows-dead=20test=20?= =?UTF-8?q?tripwires=20(re-derived)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-derive of PR #2414 (SYKhayyat) onto current main. Most of the PR already landed in earlier waves: the tick-derived RESPAWN_GUARD_WINDOW_MS, the spawnTerminalAgent windowsHide flag, the process-liveness regression tests, and the browse/test import.meta.path sweep are all on main. Two pieces remained: 1. isProcessAlive EPERM semantics (error-handling.ts): on the signal-0 path, EPERM means the process EXISTS but we lack rights to signal it — that is ALIVE. Returning false made callers that validate liveness before killing (killAgentByRecord, the terminal-agent watchdog) skip the kill and respawn around a survivor — the self-reinforcing one-leak-per-tick chain from #2414/#2295. Matters for cross-user PID checks. 2. Six test/ files ADDED SINCE the PR reintroduced the exact Windows bug its second commit fixed: `new URL(import.meta.url).pathname` yields `/C:/Users/...` on Windows, so path.resolve prepends the cwd drive and every tripwire ENOENTs instead of asserting anything (egress-receipt, egress-lib, egress-receipt-wiring, gstack-egress-cli, pty-skill-seeding-wiring, skill-census). All six now use import.meta.path — Bun's absolute native path, identical arity. The remaining #2414 piece — replacing the Windows tasklist probe with signal-0 — lands as its own commit (#1952) on top of this shape. Tests: the 6 touched test files 47 pass; process-liveness-windows + error-handling 13 pass. Re-derived from PR #2414 by @SYKhayyat. Fixes the residual of #2295. Co-authored-by: SYKhayyat Co-Authored-By: Claude Fable 5 --- browse/src/error-handling.ts | 14 +++++++++++--- test/egress-lib.test.ts | 2 +- test/egress-receipt-wiring.test.ts | 2 +- test/egress-receipt.test.ts | 2 +- test/gstack-egress-cli.test.ts | 2 +- test/pty-skill-seeding-wiring.test.ts | 2 +- test/skill-census.test.ts | 2 +- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/browse/src/error-handling.ts b/browse/src/error-handling.ts index 3be921fa2..90bfb17f2 100644 --- a/browse/src/error-handling.ts +++ b/browse/src/error-handling.ts @@ -36,7 +36,15 @@ export function safeKill(pid: number, signal: NodeJS.Signals | number): void { } } -/** Check if a PID is alive. Pure boolean probe — returns false for ALL errors. */ +/** + * Check if a PID is alive. Pure boolean probe — never throws. + * + * EPERM means the process EXISTS but we lack rights to signal it. That is + * alive — returning false there makes callers that validate liveness before + * killing (killAgentByRecord, the terminal-agent watchdog) skip the kill and + * respawn around a survivor, leaking one process per watchdog tick (#2414, + * #2295). + */ export function isProcessAlive(pid: number): boolean { if (IS_WINDOWS) { try { @@ -52,7 +60,7 @@ export function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; - } catch { - return false; + } catch (err: any) { + return err?.code === 'EPERM'; } } diff --git a/test/egress-lib.test.ts b/test/egress-lib.test.ts index 2cfb51c45..faa0f63b7 100644 --- a/test/egress-lib.test.ts +++ b/test/egress-lib.test.ts @@ -17,7 +17,7 @@ import * as os from 'os'; import * as path from 'path'; import { listReceipts, sha256Hex } from '../lib/egress-receipt'; -const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); +const ROOT = path.resolve(import.meta.path, '..', '..'); const LIB = path.join(ROOT, 'bin', 'gstack-egress-lib.sh'); const received: string[] = []; diff --git a/test/egress-receipt-wiring.test.ts b/test/egress-receipt-wiring.test.ts index 484a1e1ff..0ac0ae67e 100644 --- a/test/egress-receipt-wiring.test.ts +++ b/test/egress-receipt-wiring.test.ts @@ -28,7 +28,7 @@ import { describe, test, expect } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; -const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); +const ROOT = path.resolve(import.meta.path, '..', '..'); function read(rel: string): string { return fs.readFileSync(path.join(ROOT, rel), 'utf-8'); diff --git a/test/egress-receipt.test.ts b/test/egress-receipt.test.ts index 64e6f3928..233d7695d 100644 --- a/test/egress-receipt.test.ts +++ b/test/egress-receipt.test.ts @@ -31,7 +31,7 @@ import { writeReceipt, } from '../lib/egress-receipt'; -const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); +const ROOT = path.resolve(import.meta.path, '..', '..'); let home: string; diff --git a/test/gstack-egress-cli.test.ts b/test/gstack-egress-cli.test.ts index 4881de80f..6d9f19f8d 100644 --- a/test/gstack-egress-cli.test.ts +++ b/test/gstack-egress-cli.test.ts @@ -19,7 +19,7 @@ import { writeReceipt, } from '../lib/egress-receipt'; -const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); +const ROOT = path.resolve(import.meta.path, '..', '..'); const BIN = path.join(ROOT, 'bin', 'gstack-egress'); let home: string; diff --git a/test/pty-skill-seeding-wiring.test.ts b/test/pty-skill-seeding-wiring.test.ts index 01f5999ce..bd715a9b4 100644 --- a/test/pty-skill-seeding-wiring.test.ts +++ b/test/pty-skill-seeding-wiring.test.ts @@ -17,7 +17,7 @@ import { describe, test, expect } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; -const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); +const ROOT = path.resolve(import.meta.path, '..', '..'); /** A PTY send whose payload starts with a slash command (`/name`, optionally * followed by `\r`, whitespace, or the closing quote). A second slash right diff --git a/test/skill-census.test.ts b/test/skill-census.test.ts index b9bb21578..f2ea7e5c8 100644 --- a/test/skill-census.test.ts +++ b/test/skill-census.test.ts @@ -11,7 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { frontmatterName, skillCensus } from './helpers/skill-census'; -const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..'); +const ROOT = path.resolve(import.meta.path, '..', '..'); const census = skillCensus(ROOT); describe('skillCensus', () => {