mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* fix(mcp): prevent orphan processes by handling stdin close/end and startup race condition
Three gaps in stdin EOF handling:
1. Startup race: parent can die before `process.stdin.on("end", ...)` is
registered, so the event is missed entirely.
2. Missing "close" event: when pipe is forcibly closed (parent SIGKILL),
"close" fires without "end" on some platforms.
3. Transport layer did not propagate stdin termination to its onclose
callback.
Fixes:
- Check readableEnded/destroyed in start() before registering listeners.
- Register stdin end+close listeners in CompatibleStdioServerTransport.
- Add _closed guard for idempotent close().
- Throw if start() is called after close().
- Add process.stdin.on("close") in server.ts alongside existing handlers.
- Add 5 regression tests.
* fix(mcp): register stdin shutdown before server connect
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import { PassThrough } from 'node:stream';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { CompatibleStdioServerTransport } from '../../src/mcp/compatible-stdio-transport.js';
|
|
|
|
function onceMessage(transport: CompatibleStdioServerTransport): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
transport.onmessage = (message) => resolve(message);
|
|
transport.onerror = (error) => reject(error);
|
|
});
|
|
}
|
|
|
|
function trackClose(transport: CompatibleStdioServerTransport): {
|
|
onclose: ReturnType<typeof vi.fn>;
|
|
closed: Promise<void>;
|
|
} {
|
|
const onclose = vi.fn();
|
|
const closed = new Promise<void>((resolve) => {
|
|
transport.onclose = () => {
|
|
onclose();
|
|
resolve();
|
|
};
|
|
});
|
|
return { onclose, closed };
|
|
}
|
|
|
|
describe('CompatibleStdioServerTransport', () => {
|
|
let stdin: PassThrough;
|
|
let stdout: PassThrough;
|
|
let transport: CompatibleStdioServerTransport;
|
|
|
|
beforeEach(() => {
|
|
stdin = new PassThrough();
|
|
stdout = new PassThrough();
|
|
transport = new CompatibleStdioServerTransport(stdin, stdout);
|
|
});
|
|
|
|
it('parses Content-Length framed initialize requests', async () => {
|
|
await transport.start();
|
|
const messagePromise = onceMessage(transport);
|
|
const body = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'codex', version: '0.1' },
|
|
},
|
|
});
|
|
|
|
stdin.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
|
|
|
|
await expect(messagePromise).resolves.toMatchObject({
|
|
method: 'initialize',
|
|
params: { clientInfo: { name: 'codex' } },
|
|
});
|
|
});
|
|
|
|
it('parses newline-delimited initialize requests', async () => {
|
|
await transport.start();
|
|
const messagePromise = onceMessage(transport);
|
|
stdin.write(
|
|
`${JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'cursor', version: '0.1' },
|
|
},
|
|
})}\n`,
|
|
);
|
|
|
|
await expect(messagePromise).resolves.toMatchObject({
|
|
method: 'initialize',
|
|
params: { clientInfo: { name: 'cursor' } },
|
|
});
|
|
});
|
|
|
|
it('responds with Content-Length framing after Content-Length input', async () => {
|
|
await transport.start();
|
|
const body = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'codex', version: '0.1' },
|
|
},
|
|
});
|
|
|
|
const messagePromise = onceMessage(transport);
|
|
stdin.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\n\n${body}`);
|
|
await messagePromise;
|
|
|
|
const chunks: Buffer[] = [];
|
|
stdout.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
|
|
await transport.send({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
|
|
expect(raw).toMatch(/^Content-Length: \d+\r\n\r\n/);
|
|
expect(raw).toContain('"ok":true');
|
|
});
|
|
|
|
it('reports malformed Content-Length headers once without looping forever', async () => {
|
|
await transport.start();
|
|
const onError = vi.fn();
|
|
transport.onerror = onError;
|
|
|
|
stdin.write('Content-Length:\r\n\r\n{}');
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
|
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
|
});
|
|
|
|
it('recovers after discarding a malformed Content-Length frame', async () => {
|
|
await transport.start();
|
|
const onError = vi.fn();
|
|
transport.onerror = onError;
|
|
|
|
stdin.write('Content-Length:\r\n\r\n{}');
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
|
|
const body = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'recovery-client', version: '0.1' },
|
|
},
|
|
});
|
|
const messagePromise = onceMessage(transport);
|
|
stdin.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
|
|
|
|
await expect(messagePromise).resolves.toMatchObject({
|
|
method: 'initialize',
|
|
params: { clientInfo: { name: 'recovery-client' } },
|
|
});
|
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// ─── Security hardening regressions ──────────────────────────────
|
|
|
|
it('rejects Content-Length values exceeding the buffer cap', async () => {
|
|
await transport.start();
|
|
const onError = vi.fn();
|
|
transport.onerror = onError;
|
|
|
|
// 20 MB — exceeds the 10 MB MAX_BUFFER_SIZE
|
|
stdin.write('Content-Length: 20971520\r\n\r\n{}');
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
|
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
expect(onError.mock.calls[0]?.[0]?.message).toMatch(/exceeds maximum/i);
|
|
});
|
|
|
|
it('errors when read buffer exceeds maximum size in newline mode', async () => {
|
|
await transport.start();
|
|
const onError = vi.fn();
|
|
transport.onerror = onError;
|
|
|
|
// Send a JSON-starting chunk (triggers newline mode) with no newline,
|
|
// then keep appending until we exceed the 10 MB cap
|
|
const chunkSize = 1024 * 1024; // 1 MB
|
|
const chunk = Buffer.alloc(chunkSize, 0x61); // 'a' repeated
|
|
// First byte must be '{' to trigger newline framing detection
|
|
const first = Buffer.from('{' + 'a'.repeat(chunkSize - 1));
|
|
stdin.write(first);
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
stdin.write(chunk);
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
|
|
expect(onError).toHaveBeenCalled();
|
|
const hasMaxSizeError = onError.mock.calls.some(
|
|
(call) => call[0] instanceof Error && /maximum size/i.test(call[0].message),
|
|
);
|
|
expect(hasMaxSizeError).toBe(true);
|
|
});
|
|
|
|
it('handles many consecutive empty lines without stack overflow', async () => {
|
|
await transport.start();
|
|
const onError = vi.fn();
|
|
transport.onerror = onError;
|
|
|
|
// First, seed the framing mode with a valid newline-delimited message
|
|
const seed = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'seed', version: '0.1' },
|
|
},
|
|
});
|
|
const seedPromise = onceMessage(transport);
|
|
stdin.write(seed + '\n');
|
|
await seedPromise;
|
|
|
|
// Now send 15K empty lines followed by a real message — this would
|
|
// stack-overflow with the old recursive readNewlineMessage
|
|
const followup = JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method: 'notifications/initialized',
|
|
params: {},
|
|
});
|
|
|
|
const messagePromise = onceMessage(transport);
|
|
stdin.write('\n'.repeat(15_000) + followup + '\n');
|
|
|
|
await expect(messagePromise).resolves.toMatchObject({
|
|
method: 'notifications/initialized',
|
|
});
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects send() after transport is closed', async () => {
|
|
await transport.start();
|
|
await transport.close();
|
|
|
|
await expect(transport.send({ jsonrpc: '2.0', id: 1, result: { ok: true } })).rejects.toThrow(
|
|
/closed/i,
|
|
);
|
|
});
|
|
|
|
it('does not detect content-length framing from short ambiguous prefix', async () => {
|
|
await transport.start();
|
|
const onError = vi.fn();
|
|
transport.onerror = onError;
|
|
|
|
// Write only "cont" — fewer than 14 bytes, should NOT trigger
|
|
// content-length detection. Transport should wait for more data.
|
|
stdin.write(Buffer.from('cont'));
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
|
|
// No message and no error — transport is waiting for more data
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('responds with newline framing after newline input', async () => {
|
|
await transport.start();
|
|
const messagePromise = onceMessage(transport);
|
|
stdin.write(
|
|
`${JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'cursor', version: '0.1' },
|
|
},
|
|
})}\n`,
|
|
);
|
|
await messagePromise;
|
|
|
|
const chunks: Buffer[] = [];
|
|
stdout.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
|
|
await transport.send({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
|
|
expect(raw).toBe('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n');
|
|
});
|
|
|
|
// ─── stdin lifecycle behavior ──────────────────────────────
|
|
|
|
it('stdin end closes transport and calls onclose exactly once', async () => {
|
|
const { onclose, closed } = trackClose(transport);
|
|
|
|
await transport.start();
|
|
stdin.push(null);
|
|
|
|
await closed;
|
|
expect(onclose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('stdin close closes transport and calls onclose exactly once', async () => {
|
|
const { onclose, closed } = trackClose(transport);
|
|
|
|
await transport.start();
|
|
stdin.destroy();
|
|
|
|
await closed;
|
|
expect(onclose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('close() is idempotent: calling close twice calls onclose once', async () => {
|
|
const onclose = vi.fn();
|
|
transport.onclose = onclose;
|
|
|
|
await transport.start();
|
|
await transport.close();
|
|
await transport.close();
|
|
|
|
expect(onclose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('start() immediately closes if stdin is already ended/destroyed before listeners register', async () => {
|
|
const endedStdin = new PassThrough();
|
|
endedStdin.push(null);
|
|
|
|
const t = new CompatibleStdioServerTransport(endedStdin, new PassThrough());
|
|
const { onclose, closed } = trackClose(t);
|
|
|
|
await t.start();
|
|
|
|
await closed;
|
|
expect(onclose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('start() after close throws', async () => {
|
|
await transport.start();
|
|
await transport.close();
|
|
|
|
await expect(transport.start()).rejects.toThrow(/close/i);
|
|
});
|
|
});
|