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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ jobs:
dist/**/*.log
logs/tests/**/*.log
logs/tests/adapters/failures/**/*.json
test-results.json
if-no-files-found: warn

- name: Upload coverage reports
Expand Down
46 changes: 35 additions & 11 deletions tests/unit/cli/stdio-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ describe('STDIO Command Handler', () => {
let mockServerFactory: ReturnType<typeof vi.fn>;
let mockExitProcess: ReturnType<typeof vi.fn>;
let mockServer: DebugMcpServer;
const originalProcessOn = process.on;
let capturedProcessListeners: Map<string, Function[]>;

function makeFakeStdin() {
const stdin = new EventEmitter() as unknown as NodeJS.ReadStream & {
resume: ReturnType<typeof vi.fn>;
emit: (event: string, ...args: unknown[]) => boolean;
};
(stdin as unknown as { resume: unknown }).resume = vi.fn();
return stdin;
}

beforeEach(() => {
// Create mock logger
Expand All @@ -36,6 +47,26 @@ describe('STDIO Command Handler', () => {

// Create mock exit function
mockExitProcess = vi.fn();

// Capture process listeners WITHOUT attaching them (issue #159): a
// successful handleStdioCommand registers real SIGTERM/SIGINT/exit
// listeners that would otherwise leak into the vitest fork worker.
capturedProcessListeners = new Map();
process.on = vi.fn(function (this: NodeJS.Process, event: string, listener: (...args: unknown[]) => void) {
const list = capturedProcessListeners.get(event) ?? [];
list.push(listener);
capturedProcessListeners.set(event, list);
return this;
}) as unknown as typeof process.on;
});

afterEach(() => {
process.on = originalProcessOn;
// Fire captured SIGTERM handlers once: each closes over the 60s keepAlive
// interval and clears it; the exit goes to the mocked exitProcess.
for (const listener of capturedProcessListeners.get('SIGTERM') ?? []) {
listener();
}
});

it('should start server successfully in stdio mode', async () => {
Expand All @@ -47,7 +78,8 @@ describe('STDIO Command Handler', () => {
await handleStdioCommand(options, {
logger: mockLogger,
serverFactory: mockServerFactory,
exitProcess: mockExitProcess
exitProcess: mockExitProcess,
stdin: makeFakeStdin()
});

// Verify log level was set
Expand Down Expand Up @@ -79,7 +111,8 @@ describe('STDIO Command Handler', () => {
await handleStdioCommand(options, {
logger: mockLogger,
serverFactory: mockServerFactory,
exitProcess: mockExitProcess
exitProcess: mockExitProcess,
stdin: makeFakeStdin()
});

// Verify log level was not changed
Expand Down Expand Up @@ -154,15 +187,6 @@ describe('STDIO Command Handler', () => {
vi.unstubAllEnvs();
});

function makeFakeStdin() {
const stdin = new EventEmitter() as unknown as NodeJS.ReadStream & {
resume: ReturnType<typeof vi.fn>;
emit: (event: string, ...args: unknown[]) => boolean;
};
(stdin as unknown as { resume: unknown }).resume = vi.fn();
return stdin;
}

it('exits 0 when stdin ends in host mode (MCP client disconnected)', async () => {
const stdin = makeFakeStdin();

Expand Down
75 changes: 37 additions & 38 deletions tests/unit/proxy/dap-proxy-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ function createMockLogger(): ILogger {
};
}

/* ------------------------------------------------------------------ */
/* Safety net (issue #159) */
/* ------------------------------------------------------------------ */

// No code path in this file may hard-exit the fork worker: ProxyRunner.start()
// arms a real 10s init timeout that calls process.exit(1). Describes that
// assert on exit re-spy process.exit and get this same mock instance back.
// Restoration is centralized in tests/vitest.setup.ts (restoreAllMocks), which
// runs after file-local afterEach hooks have awaited runner.stop().
beforeEach(() => {
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
});

/* ------------------------------------------------------------------ */
/* ProxyRunner */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -430,7 +443,6 @@ describe('ProxyRunner IPC communication', () => {
await new Promise(r => setTimeout(r, 10));

expect(exitSpy).toHaveBeenCalledWith(0);
exitSpy.mockRestore();
});

it('error handler logs IPC channel error', async () => {
Expand Down Expand Up @@ -505,8 +517,6 @@ describe('ProxyRunner stdin communication', () => {
);
expect(shutdownSpy).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(0);

exitSpy.mockRestore();
});

it('stop() closing the stdin interface does not exit the process', async () => {
Expand All @@ -523,8 +533,6 @@ describe('ProxyRunner stdin communication', () => {
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('stdin closed')
);

exitSpy.mockRestore();
});
});

Expand Down Expand Up @@ -581,8 +589,6 @@ describe('ProxyRunner heartbeat and init timeout', () => {
expect(fakeSend).toHaveBeenCalledWith(
expect.objectContaining({ type: 'ipc-heartbeat-tick', counter: 2 })
);

exitSpy.mockRestore();
});

it('shuts down and exits(1) when heartbeat tick send fails', async () => {
Expand Down Expand Up @@ -611,8 +617,6 @@ describe('ProxyRunner heartbeat and init timeout', () => {
exitSpy.mockClear();
await vi.advanceTimersByTimeAsync(15000);
expect(exitSpy).not.toHaveBeenCalled();

exitSpy.mockRestore();
});

it('init timeout fires after 10 seconds', async () => {
Expand All @@ -627,8 +631,6 @@ describe('ProxyRunner heartbeat and init timeout', () => {
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('No initialization received')
);

exitSpy.mockRestore();
});

it('init timeout does not fire before 10 seconds', async () => {
Expand All @@ -640,8 +642,6 @@ describe('ProxyRunner heartbeat and init timeout', () => {

vi.advanceTimersByTime(9999);
expect(exitSpy).not.toHaveBeenCalled();

exitSpy.mockRestore();
});
});

Expand All @@ -656,18 +656,41 @@ describe('ProxyRunner.setupGlobalErrorHandlers', () => {
const originalSend = process.send;
let processOnSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
let capturedHandlers: Record<string, Function>;
const guardedEvents = ['uncaughtException', 'unhandledRejection', 'SIGTERM', 'SIGINT'] as const;
let listenerBaseline: Map<string, Function[]>;

beforeEach(() => {
deps = createMockDependencies();
logger = createMockLogger();
delete (process as any).send;
processOnSpy = vi.spyOn(process, 'on');
listenerBaseline = new Map(
guardedEvents.map((event) => [event, process.rawListeners(event) as Function[]])
);
// Capture handlers WITHOUT attaching them: a real uncaughtException /
// SIGTERM listener that outlives the test calls process.exit() and can
// hard-kill the vitest fork worker (issue #159).
capturedHandlers = {};
processOnSpy = vi.spyOn(process, 'on').mockImplementation(function (this: any, event: string, fn: any) {
capturedHandlers[event] = fn;
return this;
});
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
});

afterEach(async () => {
processOnSpy.mockRestore();
exitSpy.mockRestore();
// Belt-and-braces: remove anything that still reached the real process.
for (const event of guardedEvents) {
const evt: string = event;
const baseline = listenerBaseline.get(evt) ?? [];
for (const listener of process.rawListeners(evt)) {
if (!baseline.includes(listener as Function)) {
process.removeListener(evt, listener as (...args: unknown[]) => void);
}
}
}
if (originalSend) {
process.send = originalSend;
} else {
Expand All @@ -690,12 +713,6 @@ describe('ProxyRunner.setupGlobalErrorHandlers', () => {
});

it('uncaughtException handler sends error and calls shutdown', async () => {
const capturedHandlers: Record<string, Function> = {};
processOnSpy.mockImplementation(function (this: any, event: string, fn: any) {
capturedHandlers[event] = fn;
return this;
});

runner = new ProxyRunner(deps, logger);
const shutdownFn = vi.fn().mockResolvedValue(undefined);
const getSessionId = vi.fn().mockReturnValue('sess-1');
Expand All @@ -713,12 +730,6 @@ describe('ProxyRunner.setupGlobalErrorHandlers', () => {
});

it('unhandledRejection handler sends error but does not exit', () => {
const capturedHandlers: Record<string, Function> = {};
processOnSpy.mockImplementation(function (this: any, event: string, fn: any) {
capturedHandlers[event] = fn;
return this;
});

runner = new ProxyRunner(deps, logger);
const shutdownFn = vi.fn().mockResolvedValue(undefined);
const getSessionId = vi.fn().mockReturnValue(null);
Expand All @@ -734,12 +745,6 @@ describe('ProxyRunner.setupGlobalErrorHandlers', () => {
});

it('SIGTERM handler shuts down and exits with 0', async () => {
const capturedHandlers: Record<string, Function> = {};
processOnSpy.mockImplementation(function (this: any, event: string, fn: any) {
capturedHandlers[event] = fn;
return this;
});

runner = new ProxyRunner(deps, logger);
const shutdownFn = vi.fn().mockResolvedValue(undefined);

Expand All @@ -753,12 +758,6 @@ describe('ProxyRunner.setupGlobalErrorHandlers', () => {
});

it('SIGINT handler shuts down and exits with 0', async () => {
const capturedHandlers: Record<string, Function> = {};
processOnSpy.mockImplementation(function (this: any, event: string, fn: any) {
capturedHandlers[event] = fn;
return this;
});

runner = new ProxyRunner(deps, logger);
const shutdownFn = vi.fn().mockResolvedValue(undefined);

Expand Down
10 changes: 9 additions & 1 deletion tests/unit/proxy/dap-proxy-dependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ describe('setupGlobalErrorHandlers', () => {
exitSpy.mockRestore();
});

it('unhandledRejection handler sends error message', () => {
it('unhandledRejection handler sends error, shuts down, and exits(1)', async () => {
const capturedHandlers: Record<string, Function> = {};
const spy = vi.spyOn(process, 'on').mockImplementation(function (this: any, event: string, fn: any) {
capturedHandlers[event] = fn;
Expand All @@ -204,6 +204,14 @@ describe('setupGlobalErrorHandlers', () => {
expect.objectContaining({ type: 'error', sessionId: 'unknown' })
);

// The handler schedules shutdownFn().finally(() => process.exit(1)) as a
// microtask chain. Settle it BEFORE restoring the exit spy — restoring
// first lets a REAL exit(1) fire between tests, which can hard-kill the
// vitest fork worker (issue #159).
await new Promise(r => setTimeout(r, 0));
expect(shutdownFn).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);

spy.mockRestore();
exitSpy.mockRestore();
});
Expand Down
54 changes: 0 additions & 54 deletions tests/unit/proxy/proxy-manager-test-setup.ts

This file was deleted.

Loading
Loading