mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 12:00:02 +08:00
A full suite run on Windows left a `serve --mcp --path %TEMP%\cg-rebuild-…` process behind. Its parent was gone, and its one thread was suspended with 0 CPU used. It would never exit, and it held the checkout's dist. libuv creates every detached child suspended on Windows and resumes it only at the end of `uv_spawn`. A launcher killed inside that call never resumes its daemon. The daemon never runs, so it writes no daemon.pid, and nothing can find or stop it. The rebuild test's teardown killed its launcher at once. The test stops the daemon on purpose, so by then the launcher is serving calls in-process with a retry timer running. When the retry comes due during the last call, the launcher spawns its daemon as soon as that call answers, just as the teardown begins. Under load the two coincide. The teardown now: - closes each launcher's stdin and waits for it to exit. A graceful exit can't land inside the synchronous spawn. - records the daemons each launcher spawns (#1773's recorder) and stops them until all have exited. One spawned as its launcher exited may still be starting, and stopDaemonAt leaves a daemon running until it answers. The recorder's comment now says that a launcher killed mid-spawn records nothing. CODEGRAPH_DAEMON_RETRY_MS=2500 puts the retry inside the last call on a quiet box. Under it, main's test left a daemon in 6 of 12 runs: 2 suspended ones, plus 4 live ones whose fixture then failed to delete. This test left nothing in 12 runs. Of the mutants, killing launchers again, a single stopDaemonAt and no spawn recorder each leaked. The other daemon test files left nothing in 2 runs each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
65 lines
2.7 KiB
TypeScript
65 lines
2.7 KiB
TypeScript
/**
|
|
* Teardown for tests whose `serve --mcp` launchers race to spawn the shared
|
|
* daemon (#1773). Each launcher that finds no daemon spawns a detached
|
|
* candidate; one wins the lock and the rest exit once they see it. Stopping the
|
|
* winner while a loser is still starting lets that loser take over the fixture
|
|
* being removed, and on Windows a live process whose working directory is the
|
|
* fixture makes the removal fail with EBUSY or EPERM. So the teardown waits for
|
|
* the losers, by pid, before it stops the winner.
|
|
*/
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const SPAWN_RECORDER = path.resolve(__dirname, 'fixtures/record-detached-spawns.cjs');
|
|
|
|
/** Where launchers started for `fixture` record their candidates: beside it, never inside. */
|
|
export function spawnLogFor(fixture: string): string {
|
|
return path.join(path.dirname(fixture), `${path.basename(fixture)}.daemon-spawns`);
|
|
}
|
|
|
|
/** Node arguments and environment that make a launcher record its candidates. */
|
|
export function recordSpawns(fixture: string): { args: string[]; env: NodeJS.ProcessEnv } {
|
|
return { args: ['--require', SPAWN_RECORDER], env: { CG_TEST_SPAWN_LOG: spawnLogFor(fixture) } };
|
|
}
|
|
|
|
/** The pids of every candidate the launchers started for `fixture` recorded. */
|
|
export function recordedPids(fixture: string): number[] {
|
|
let raw = '';
|
|
try { raw = fs.readFileSync(spawnLogFor(fixture), 'utf8'); } catch { return []; }
|
|
return raw.split('\n').map(Number).filter((pid) => Number.isSafeInteger(pid) && pid > 0);
|
|
}
|
|
|
|
function isAlive(pid: number): boolean {
|
|
try { process.kill(pid, 0); return true; } catch (err) {
|
|
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve once every recorded candidate except the current lock holder has
|
|
* exited. Call it after the launchers have exited, so no new candidate can
|
|
* appear. The losers leave on their own; this never signals one, so a loser
|
|
* that stays alive fails the teardown instead of being hidden.
|
|
*/
|
|
export async function settleLosingCandidates(
|
|
fixture: string,
|
|
holder: () => number | null | undefined,
|
|
timeoutMs = 30_000,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
for (;;) {
|
|
const current = holder();
|
|
const pending = recordedPids(fixture).filter((pid) => pid !== current && isAlive(pid));
|
|
if (pending.length === 0) return;
|
|
if (Date.now() > deadline) {
|
|
throw new Error(`Daemon candidates ${pending.join(', ')} were still running with ${current ?? 'no process'} holding the lock`);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
}
|
|
|
|
/** Remove the spawn record once the fixture's processes are gone. */
|
|
export function removeSpawnLog(fixture: string): void {
|
|
fs.rmSync(spawnLogFor(fixture), { force: true });
|
|
}
|