diff --git a/skill/scripts/live/roots.mjs b/skill/scripts/live/roots.mjs index 7ac4ac812..1ea95afd4 100644 --- a/skill/scripts/live/roots.mjs +++ b/skill/scripts/live/roots.mjs @@ -296,19 +296,33 @@ function readPointerEntries(repoRoot) { */ function hasLiveServer(appRoot) { let pid; + let port; try { const info = JSON.parse(fs.readFileSync(path.join(appRoot, '.impeccable', 'live', 'server.json'), 'utf-8')); if (!info || typeof info.pid !== 'number') return false; pid = info.pid; + port = Number(info.port); process.kill(pid, 0); } catch (err) { // EPERM: the process exists but is not signalable by this user. if (err?.code !== 'EPERM') return false; } - if (process.platform === 'win32') return true; // no cheap portable command check + // Liveness alone misclassifies a REUSED pid (helper died without removing + // server.json, the OS handed the pid to something else, even another node + // process). The decisive signal is the recorded PORT: a real helper is + // listening on it, a pid squatter is not. + if (Number.isInteger(port) && port > 0 && process.platform !== 'win32') { + try { + execFileSync('bash', ['-c', `exec 3<>/dev/tcp/127.0.0.1/${port}`], { timeout: 1500, stdio: 'ignore' }); + return true; + } catch { + return false; + } + } + if (process.platform === 'win32') return true; // no cheap portable probe try { const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf-8' }); - return /\b(node|bun|live-server)\b/.test(command); + return /live-server|\b(node|bun)\b/.test(command); } catch { return false; } diff --git a/tests/live-roots.test.mjs b/tests/live-roots.test.mjs index 98c686a9e..b65535b73 100644 --- a/tests/live-roots.test.mjs +++ b/tests/live-roots.test.mjs @@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; +import { createServer } from 'node:net'; import { fileURLToPath } from 'node:url'; import { discoverAppCandidates, @@ -198,7 +199,7 @@ describe('review regressions: walk bounds', () => { }); describe('review regressions: multi-app pointer', () => { - it('prefers the app whose live server is running over the last boot', () => { + it('prefers the app whose live server is running over the last boot', async () => { const repo = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-roots-multi-'))); try { mkdirSync(join(repo, '.git'), { recursive: true }); @@ -211,13 +212,22 @@ describe('review regressions: multi-app pointer', () => { writeRootsManifest(a); writeRootsManifest(b); // B booted last: a naive pointer now points at B - // A's helper server is the one alive (this test process's pid). - write(repo, 'siteA/.impeccable/live/server.json', JSON.stringify({ pid: process.pid, port: 1, token: 't' })); - write(repo, 'siteB/.impeccable/live/server.json', JSON.stringify({ pid: 999999999, port: 2, token: 't' })); + // A's helper server is the one alive: a real listener on a real port + // (the liveness check probes the recorded port, so a bare pid is not + // enough to count as running). + const srv = createServer(); + await new Promise((resolve) => srv.listen(0, '127.0.0.1', resolve)); + try { + const livePort = srv.address().port; + write(repo, 'siteA/.impeccable/live/server.json', JSON.stringify({ pid: process.pid, port: livePort, token: 't' })); + write(repo, 'siteB/.impeccable/live/server.json', JSON.stringify({ pid: 999999999, port: 2, token: 't' })); - const resolved = resolveLiveRoots(repo); - assert.equal(resolved.source, 'pointer'); - assert.equal(resolved.manifest.appRoot, join(repo, 'siteA')); + const resolved = resolveLiveRoots(repo); + assert.equal(resolved.source, 'pointer'); + assert.equal(resolved.manifest.appRoot, join(repo, 'siteA')); + } finally { + await new Promise((resolve) => srv.close(resolve)); + } } finally { rmSync(repo, { recursive: true, force: true }); }