test: wait for what holds an MCP test's dir before removing it (#2624)

Two MCP test files sometimes failed under full-suite load on Windows with
EBUSY in their afterEach, and then left their temp dir behind:

- mcp-status-freshness: every case leaves an index-freshness measurement
  worker still ending, and the deadline case's worker is terminated in the
  middle of its measurement. Until it exits, the worker can hold the index
  or the project dir, and the removal raced that exit (EBUSY on
  codegraph.db or on the dir, ENOTEMPTY on .codegraph). The afterEach now
  awaits endFreshnessMeasurements(), the server's own shutdown wait, before
  it closes the index and removes the dir.
- mcp-initialize: after the killed server's 'close', the dir itself can
  stay busy a moment longer (EBUSY rmdir). It freed within about 80 ms,
  with no process left under the server. A bounded `maxRetries` doesn't
  help: on Node 22, fs.rmSync never retries EBUSY on a directory and throws
  at once. So removeWhenReleased() retries the removal itself, for up to
  5 s, and a Windows-only test holds a dir as a child's cwd to prove the
  helper waits.

Under CPU load (14 of 16 cores busy, two runs at once), the status
teardown failed 20 times in 240 without the wait and 0 times in 240 with
it. Alternating real runs of both files failed in 5 of 18 on main and 0 of
10 with the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-16 18:09:04 +00:00
committed by GitHub
parent 2344ce0722
commit a25706950f
2 changed files with 43 additions and 3 deletions
+37 -1
View File
@@ -101,6 +101,26 @@ function waitFor<T>(
});
}
/**
* Remove `dir` once nothing holds it. Even after the killed server's 'close',
* Windows can keep the dir itself busy a moment longer under load, and
* `fs.rmSync`'s `maxRetries` never retries EBUSY on a directory: it throws at
* once. A dir still held at the deadline fails the test.
*/
async function removeWhenReleased(dir: string, timeoutMs = 5000): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
fs.rmSync(dir, { recursive: true, force: true });
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code ?? '';
if (!['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code) || Date.now() > deadline) throw error;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
}
describe('MCP initialize handshake (issue #172)', () => {
let tempDir: string;
let child: ChildProcessWithoutNullStreams | null = null;
@@ -118,7 +138,7 @@ describe('MCP initialize handshake (issue #172)', () => {
await closed;
}
child = null;
fs.rmSync(tempDir, { recursive: true, force: true });
await removeWhenReleased(tempDir);
});
it('responds to initialize quickly when no .codegraph exists in cwd', async () => {
@@ -187,3 +207,19 @@ describe('MCP initialize handshake (issue #172)', () => {
expect(prompts.result.prompts).toEqual([]);
}, 15000);
});
describe('MCP initialize teardown', () => {
// On Windows a dir another process holds (here as its cwd) refuses removal
// with EBUSY; the teardown waits until it is let go.
it.runIf(process.platform === 'win32')('removes a dir once the process holding it has exited', async () => {
const held = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-held-'));
const holder = spawn(process.execPath, ['-e', "process.stdout.write('ready'); setTimeout(() => {}, 500)"], {
cwd: held,
stdio: ['ignore', 'pipe', 'ignore'],
});
await once(holder.stdout, 'data'); // running, so its cwd is open
expect(() => fs.rmdirSync(held)).toThrow(/EBUSY/);
await removeWhenReleased(held);
expect(fs.existsSync(held)).toBe(false);
});
});
+6 -2
View File
@@ -5,7 +5,7 @@ import * as path from 'path';
import { execFileSync } from 'child_process';
import { Worker } from 'worker_threads';
import CodeGraph from '../src/index';
import { measurePendingChanges } from '../src/mcp/index-freshness';
import { endFreshnessMeasurements, measurePendingChanges } from '../src/mcp/index-freshness';
import { ToolHandler } from '../src/mcp/tools';
describe('MCP status freshness (#1959)', () => {
@@ -28,7 +28,11 @@ describe('MCP status freshness (#1959)', () => {
handler = new ToolHandler(cg);
});
afterEach(() => {
afterEach(async () => {
// Every case leaves a measurement worker still ending, which can still
// hold the index or the project dir: on Windows under load the removal
// then failed with EBUSY. Wait until they are gone.
await endFreshnessMeasurements();
try { cg.close(); } catch { /* ignore */ }
fs.rmSync(root, { recursive: true, force: true });
});