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 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-27 17:07:25 -07:00
co-authored by Claude Code
parent 5a85050230
commit 031e170d3e
2 changed files with 50 additions and 4 deletions
+21 -4
View File
@@ -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;
}
}
+29
View File
@@ -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 });
}
});
});