From a25706950fa4e98b2e0d2d49c1e849474893defb Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 16 Oct 2026 18:09:04 +0000 Subject: [PATCH] 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 --- __tests__/mcp-initialize.test.ts | 38 +++++++++++++++++++++++++- __tests__/mcp-status-freshness.test.ts | 8 ++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 7dc90ae2d..f155ba973 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -101,6 +101,26 @@ function waitFor( }); } +/** + * 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 { + 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); + }); +}); diff --git a/__tests__/mcp-status-freshness.test.ts b/__tests__/mcp-status-freshness.test.ts index c78844db5..7ce72868a 100644 --- a/__tests__/mcp-status-freshness.test.ts +++ b/__tests__/mcp-status-freshness.test.ts @@ -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 }); });