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 { 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' ); }