mcporter/tests/daemon-client-timeout.test.ts
2026-05-28 16:45:31 +01:00

174 lines
5.6 KiB
TypeScript

import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { makeShortTempDir } from './fixtures/test-helpers.js';
const timeoutRecords: Array<{ method: string; timeout: number }> = [];
class MockSocket extends EventEmitter {
currentTimeout = 0;
setTimeout(ms: number): this {
this.currentTimeout = ms;
return this;
}
write(data: string, cb?: (err?: Error | null) => void): boolean {
const payload = JSON.parse(data.toString());
timeoutRecords.push({ method: payload.method, timeout: this.currentTimeout });
const response = buildResponse(payload.method, payload.id);
setTimeout(() => {
this.emit('data', JSON.stringify(response));
this.emit('end');
}, responseDelayMs);
cb?.();
return true;
}
end(cb?: () => void): this {
cb?.();
return this;
}
destroy(): this {
return this;
}
}
let responseDelayMs = 5;
let activeConfigPath = path.resolve('mcporter.config.json');
let activeSocketPath = '';
const createConnection = vi.fn(() => {
const socket = new MockSocket();
setTimeout(() => socket.emit('connect'), 0);
return socket;
});
let previousDaemonTimeout: string | undefined;
let previousDaemonDir: string | undefined;
let tmpDaemonDir: string | undefined;
vi.mock('node:net', () => ({
createConnection,
default: { createConnection },
}));
vi.mock('../src/daemon/launch.js', () => ({
launchDaemonDetached: vi.fn(),
}));
const { DaemonClient, resolveDaemonPaths } = await import('../src/daemon/client.js');
function buildResponse(method: string, id: string) {
if (method === 'status') {
return {
id,
ok: true,
result: {
pid: process.pid,
startedAt: Date.now(),
configPath: activeConfigPath,
socketPath: activeSocketPath,
servers: [],
},
};
}
return {
id,
ok: true,
result: { ok: true },
};
}
describe('DaemonClient timeouts', () => {
beforeEach(async () => {
timeoutRecords.length = 0;
responseDelayMs = 5;
previousDaemonTimeout = process.env.MCPORTER_DAEMON_TIMEOUT_MS;
previousDaemonDir = process.env.MCPORTER_DAEMON_DIR;
tmpDaemonDir = await makeShortTempDir('daemon-timeout');
process.env.MCPORTER_DAEMON_DIR = tmpDaemonDir;
delete process.env.MCPORTER_DAEMON_TIMEOUT_MS;
});
afterEach(async () => {
if (previousDaemonTimeout === undefined) {
delete process.env.MCPORTER_DAEMON_TIMEOUT_MS;
} else {
process.env.MCPORTER_DAEMON_TIMEOUT_MS = previousDaemonTimeout;
}
if (previousDaemonDir === undefined) {
delete process.env.MCPORTER_DAEMON_DIR;
} else {
process.env.MCPORTER_DAEMON_DIR = previousDaemonDir;
}
if (tmpDaemonDir) {
await fs.rm(tmpDaemonDir, { recursive: true, force: true });
}
});
it('defaults to 30s per request', async () => {
const configPath = 'mcporter.config.json';
await writeFreshMetadata(configPath);
const client = new DaemonClient({ configPath, configExplicit: true });
await client.callTool({ server: 'foo', tool: 'bar' });
const statusRecord = timeoutRecords.find((entry) => entry.method === 'status');
const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool');
expect(statusRecord?.timeout).toBe(30_000);
expect(callRecord?.timeout).toBe(30_000);
});
it('honors MCPORTER_DAEMON_TIMEOUT_MS override', async () => {
process.env.MCPORTER_DAEMON_TIMEOUT_MS = '4500';
const configPath = 'mcporter.config.json';
await writeFreshMetadata(configPath);
const client = new DaemonClient({ configPath, configExplicit: true });
await client.callTool({ server: 'foo', tool: 'bar' });
const statusRecord = timeoutRecords.find((entry) => entry.method === 'status');
const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool');
expect(statusRecord?.timeout).toBe(4_500);
expect(callRecord?.timeout).toBe(4_500);
});
it('honors per-call timeout overrides', async () => {
const configPath = 'mcporter.config.json';
await writeFreshMetadata(configPath);
const client = new DaemonClient({ configPath, configExplicit: true });
await client.callTool({ server: 'foo', tool: 'bar', timeoutMs: 12_345 });
const statusRecord = timeoutRecords.find((entry) => entry.method === 'status');
const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool');
expect(statusRecord?.timeout).toBe(12_345);
expect(callRecord?.timeout).toBe(12_345);
});
it('clamps daemon status preflight timeout for tiny per-call timeouts', async () => {
const configPath = 'mcporter.config.json';
await writeFreshMetadata(configPath);
const client = new DaemonClient({ configPath, configExplicit: true });
await client.callTool({ server: 'foo', tool: 'bar', timeoutMs: 1 });
const statusRecord = timeoutRecords.find((entry) => entry.method === 'status');
const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool');
expect(statusRecord?.timeout).toBe(1_000);
expect(callRecord?.timeout).toBe(1);
});
});
async function writeFreshMetadata(configPath: string): Promise<void> {
activeConfigPath = path.resolve(configPath);
const paths = resolveDaemonPaths(configPath);
activeSocketPath = paths.socketPath;
await fs.mkdir(path.dirname(paths.metadataPath), { recursive: true });
await fs.writeFile(
paths.metadataPath,
JSON.stringify({
pid: process.pid,
socketPath: paths.socketPath,
configPath,
configLayers: [{ path: activeConfigPath, mtimeMs: null }],
startedAt: Date.now(),
}),
'utf8'
);
}