From 039df77e06c0724ef1e73b6349d7cf7df2058a14 Mon Sep 17 00:00:00 2001 From: bompus Date: Thu, 15 Oct 2026 16:05:05 -0600 Subject: [PATCH 1/2] test: control daemon disconnect ordering and report shutdown state --- __tests__/file-tmpdir.setup.ts | 5 +-- .../fixtures/daemon-disconnect-order.cjs | 34 +++++++++++++++++++ __tests__/mcp-daemon.test.ts | 9 +++-- __tests__/runtime-control.test.ts | 4 +-- 4 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 __tests__/fixtures/daemon-disconnect-order.cjs diff --git a/__tests__/file-tmpdir.setup.ts b/__tests__/file-tmpdir.setup.ts index 01d0fe02ce..05962409cf 100644 --- a/__tests__/file-tmpdir.setup.ts +++ b/__tests__/file-tmpdir.setup.ts @@ -16,16 +16,17 @@ import { afterAll } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { rmTempDir } from './rm-temp'; const saved = { TMPDIR: process.env.TMPDIR, TMP: process.env.TMP, TEMP: process.env.TEMP }; const fileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'f')); // os.tmpdir() reads TMPDIR on POSIX and TMP/TEMP on Windows, on every call. process.env.TMPDIR = process.env.TMP = process.env.TEMP = fileDir; -afterAll(() => { +afterAll(async () => { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } - fs.rmSync(fileDir, { recursive: true, force: true }); + await rmTempDir(fileDir); }); diff --git a/__tests__/fixtures/daemon-disconnect-order.cjs b/__tests__/fixtures/daemon-disconnect-order.cjs new file mode 100644 index 0000000000..5554d3f52e --- /dev/null +++ b/__tests__/fixtures/daemon-disconnect-order.cjs @@ -0,0 +1,34 @@ +// Only the detached daemon in the early-disconnect integration test loads this +// patch. Keep its initial idle deadline out of proxy startup; the real timer +// still arms when the last registered client disconnects. +if (process.env.CODEGRAPH_DAEMON_INTERNAL === '1') { + const { Daemon } = require('../../dist/mcp/daemon.js'); + const started = new WeakSet(); + const arm = Daemon.prototype.armIdleTimer; + Daemon.prototype.armIdleTimer = function () { + if (!started.has(this)) { + started.add(this); + return; + } + return arm.call(this); + }; + const state = (daemon, event) => { + process.stderr.write(`[disconnect-test] ${event} clients=${daemon.getClientCount()} sockets=${daemon.sockets.size} stopping=${daemon.stopping} resources=${process.getActiveResourcesInfo().join(',')}\n`); + }; + const connect = Daemon.prototype.handleConnection; + Daemon.prototype.handleConnection = function (socket) { + connect.call(this, socket); + state(this, 'connection'); + socket.once('close', () => setImmediate(() => state(this, 'socket-close'))); + }; + const drop = Daemon.prototype.dropClient; + Daemon.prototype.dropClient = function (session) { + drop.call(this, session); + state(this, 'client-close'); + }; + const stop = Daemon.prototype.stop; + Daemon.prototype.stop = function (reason) { + state(this, `stop ${reason}`); + return stop.call(this, reason); + }; +} diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index 5818b3dd1c..0cdcc6272a 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -325,7 +325,10 @@ describe('Shared MCP daemon (issue #411)', () => { }, 20000); it('a disconnect before client hello leaves no phantom session and the idle reaper exits (#1356)', async () => { + const preload = path.resolve(__dirname, 'fixtures/daemon-disconnect-order.cjs').split(path.sep).join('/'); const server = spawnServer(tempDir, { + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --require ${JSON.stringify(preload)}`, + CODEGRAPH_MCP_DEBUG: '1', CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '800', CODEGRAPH_DAEMON_MAX_IDLE_MS: '0', CODEGRAPH_DAEMON_CLIENT_SWEEP_MS: '0', @@ -351,11 +354,13 @@ describe('Shared MCP daemon (issue #411)', () => { }); raw.destroy(); server.child.stdin.end(); - expect(await waitProcessExit(pid, 8000)).toBe(true); + const exited = await waitProcessExit(pid, 8000); + if (!exited) startupFailure(new Error(`daemon ${pid} did not exit after disconnect`)); + expect(exited).toBe(true); expect(readDaemonLog(realRoot)).toContain('Shutting down (idle timeout; clients=0)'); expect(fs.existsSync(path.join(realRoot, '.codegraph', 'writer.pid'))).toBe(false); } finally { raw.destroy(); } - }, 20000); + }, 45_000); it('two invocations share ONE detached daemon; both attach as proxies', async () => { const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; diff --git a/__tests__/runtime-control.test.ts b/__tests__/runtime-control.test.ts index d6ec503af6..2f9c7eb441 100644 --- a/__tests__/runtime-control.test.ts +++ b/__tests__/runtime-control.test.ts @@ -539,7 +539,7 @@ describe('ordinary watcher startup', () => { const launch = spySpawn(); expect(await startRuntimeWatcher(root, options())).toMatchObject({ pid: message.pid, watching: true }); expect(launch).not.toHaveBeenCalled(); - }); + }, 30_000); it.each(['uncertain', 'same-pid-successor', 'open-successor'])( 'preserves a %s writer appearing after daemon election before activation', async event => { @@ -591,7 +591,7 @@ describe('ordinary watcher startup', () => { if (event === 'uncertain') expect(state.writer).toBe('uncertain'); else expect(JSON.parse(state.writer).pid).toBe(candidate.pid); if (event === 'open-successor') expect(state.closedOnRejection).toBe(true); - }); + }, 30_000); it.each([false, true])('retains a partial database and preserves successors on initialization failure (successor=%s)', successor => { const result = spawnSync(process.execPath, ['-e', ` From 2f1b42fe8fc75d6c75396a8884236ac382cbded2 Mon Sep 17 00:00:00 2001 From: bompus Date: Thu, 15 Oct 2026 16:22:33 -0600 Subject: [PATCH 2/2] test: cover watcher startup in remaining lifecycle deadlines --- __tests__/runtime-control.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/__tests__/runtime-control.test.ts b/__tests__/runtime-control.test.ts index 2f9c7eb441..400dd0da24 100644 --- a/__tests__/runtime-control.test.ts +++ b/__tests__/runtime-control.test.ts @@ -415,7 +415,7 @@ describe('ordinary watcher startup', () => { try { expect(reader.getNodesByKind('function').map(n => n.name)).toEqual(['initializedOnce']); } finally { reader.close(); } expect(readWriterLock(root)).toMatchObject({ pid: winners[0]!.pid, mode: 'daemon', ready: true }); - }); + }, 30_000); it.each(['success', 'failure', 'successor', 'previous-successor'] as const)( 'waits for the real daemon refresh and preserves ownership on %s', async (outcome) => { @@ -646,7 +646,7 @@ describe('ordinary watcher startup', () => { expect(readWriterLock(root)).toMatchObject({ pid: winner.pid, mode: 'daemon', ready: true }); expect(reserveRuntimeWriter(root, process.pid)).toBeNull(); await checkRuntimeReady(root, winner, 1000, { requireWatcher: true }); - }); + }, 30_000); it.skipIf(!builtinPatchingReachesImporters).each(['promotion', 'older', 'invalid-writer'].flatMap(event => [true, false].map(initialized => ({ event, initialized }))))( 'preserves a $event owner after precheck before election (initialized=$initialized)', async ({ event, initialized }) => { @@ -675,7 +675,7 @@ describe('ordinary watcher startup', () => { expect(actors).toHaveLength(1); expect(fs.readFileSync(protectedFile!, 'utf8')).toBe(before!); if (!initialized) expect(fs.existsSync(path.join(root, '.codegraph/codegraph.db'))).toBe(false); - }, + }, 30_000, ); it.skipIf(!builtinPatchingReachesImporters)('times out with a disabled real watcher without terminating a possibly shared daemon', async () => { @@ -691,7 +691,7 @@ describe('ordinary watcher startup', () => { expect(actors).toHaveLength(1); expect(actors[0]!.exitCode).toBeNull(); expect(readWriterLock(root)?.pid).toBe(actors[0]!.pid); - }); + }, 30_000); }); describe('preserving MCP launch policy', () => {