mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 21:22:56 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user