Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions __tests__/file-tmpdir.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
34 changes: 34 additions & 0 deletions __tests__/fixtures/daemon-disconnect-order.cjs
Original file line number Diff line number Diff line change
@@ -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);
};
}
9 changes: 7 additions & 2 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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' };
Expand Down
12 changes: 6 additions & 6 deletions __tests__/runtime-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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', `
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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', () => {
Expand Down
Loading