Files
codegraph/__tests__/daemon-candidates.ts
T
Colby MchenryandClaude Opus 5.5 3be906fcf4 test: stop index-daemon-rebuild leaving a suspended daemon on Windows (#2629)
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>
2026-10-17 05:14:14 +00:00

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 });
}