From 031e170d3e17f2c0fb918e7ec34ebe741f245d2e Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 27 Jul 2026 17:07:25 -0700 Subject: [PATCH] fix: harden live-server liveness against pid reuse greptile-apps[bot] repro: a helper that died without removing server.json leaves a pid the OS can hand to an unrelated process, which kill(pid, 0) classifies as a running server and routes repo-root helpers onto the stale app. The liveness check now also requires the pid's command line to look like a node process (ps-based, platform-guarded), removing reuse by arbitrary processes; the residual node-reuse case is covered by the multi-app warning and the --target escape hatch. This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live/roots.mjs | 25 +++++++++++++++++++++---- tests/live-roots.test.mjs | 29 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/skill/scripts/live/roots.mjs b/skill/scripts/live/roots.mjs index bdca69d75..7ac4ac812 100644 --- a/skill/scripts/live/roots.mjs +++ b/skill/scripts/live/roots.mjs @@ -27,6 +27,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { resolveProjectRoot } from '../context.mjs'; const ROOTS_MANIFEST_VERSION = 1; @@ -284,16 +285,32 @@ function readPointerEntries(repoRoot) { } } -/** True when the app's live helper server is recorded and its pid is alive. */ +/** + * True when the app's live helper server is recorded and its pid is alive. + * A liveness signal alone misclassifies a REUSED pid (helper died without + * removing server.json, the OS handed the pid to something else), so the + * process's command line must also look like a node process; that removes + * reuse by arbitrary processes. A pid reused by another node process remains + * a residual false positive, which the multi-app warning and --target + * escape hatch cover. + */ function hasLiveServer(appRoot) { + let pid; try { const info = JSON.parse(fs.readFileSync(path.join(appRoot, '.impeccable', 'live', 'server.json'), 'utf-8')); if (!info || typeof info.pid !== 'number') return false; - process.kill(info.pid, 0); - return true; + pid = info.pid; + process.kill(pid, 0); } catch (err) { // EPERM: the process exists but is not signalable by this user. - return err?.code === 'EPERM'; + if (err?.code !== 'EPERM') return false; + } + if (process.platform === 'win32') return true; // no cheap portable command check + try { + const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf-8' }); + return /\b(node|bun|live-server)\b/.test(command); + } catch { + return false; } } diff --git a/tests/live-roots.test.mjs b/tests/live-roots.test.mjs index 4f81d4482..98c686a9e 100644 --- a/tests/live-roots.test.mjs +++ b/tests/live-roots.test.mjs @@ -302,3 +302,32 @@ describe('review regressions: helper --target', () => { } }); }); + +describe('review regressions: pid reuse', () => { + it('does not classify a non-node process reusing the recorded pid as a live server', () => { + const repo = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-pidreuse-'))); + try { + mkdirSync(join(repo, '.git'), { recursive: true }); + for (const name of ['appA', 'appB']) { + write(repo, `${name}/vite.config.js`, 'export default {};'); + } + const a = resolveRoots({ cwd: repo, targetPath: join(repo, 'appA/vite.config.js') }).manifest; + const b = resolveRoots({ cwd: repo, targetPath: join(repo, 'appB/vite.config.js') }).manifest; + writeRootsManifest(a); + writeRootsManifest(b); // B booted last. + + // B's helper died; its pid was reused by a non-node process (launchd / + // init: pid 1 is alive on every unix and is never a node process). + write(repo, 'siteB-unused.txt', ''); + write(repo, 'appB/.impeccable/live/server.json', JSON.stringify({ pid: 1, port: 2, token: 't' })); + // A holds the interrupted session the user is recovering. + write(repo, 'appA/.impeccable/live/sessions/aa11bb22.snapshot.json', + JSON.stringify({ id: 'aa11bb22', phase: 'variants_ready' })); + + const resolved = resolveLiveRoots(repo); + assert.equal(resolved.manifest.appRoot, join(repo, 'appA')); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +});