174 lines
5.6 KiB
TypeScript
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'
|
|
);
|
|
}
|