fix: reconcile daemon lifecycle starts
This commit is contained in:
parent
552fcb1f60
commit
f4f209317f
@ -6,6 +6,10 @@
|
||||
|
||||
- Recover cleanly from renamed OAuth server entries, invalid refresh tokens, and stale dynamic client registrations without reusing unrelated same-URL credentials.
|
||||
|
||||
### CLI
|
||||
|
||||
- Reconcile keep-alive daemon metadata with the responding process and serialize daemon startup across parallel clients, preventing duplicate orphaned daemons. (Issue #191, thanks @dtmsyi)
|
||||
|
||||
## [0.11.3] - 2026-05-21
|
||||
|
||||
- Fall back to `~/.mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` points at an empty mcporter config directory, preventing embedders from accidentally hiding the user server registry. (Issue #184, thanks @ChrisBot2026)
|
||||
|
||||
@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { listConfigLayerPaths } from '../config/path-discovery.js';
|
||||
import { withFileLock } from '../fs-json.js';
|
||||
import { getDaemonMetadataPath, getDaemonSocketPath } from './paths.js';
|
||||
import type {
|
||||
CallToolParams,
|
||||
@ -23,6 +24,7 @@ export interface DaemonClientOptions {
|
||||
}
|
||||
|
||||
const DEFAULT_DAEMON_TIMEOUT_MS = 30_000;
|
||||
const MIN_DAEMON_STATUS_TIMEOUT_MS = 1_000;
|
||||
|
||||
export interface DaemonPaths {
|
||||
readonly key: string;
|
||||
@ -83,14 +85,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async status(): Promise<StatusResult | null> {
|
||||
try {
|
||||
return (await this.sendRequest<StatusResult>('status', {})) as StatusResult;
|
||||
} catch (error) {
|
||||
if (isTransportError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return await this.readVerifiedStatus();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
@ -105,7 +100,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private async invoke<T = unknown>(method: DaemonRequestMethod, params: unknown, timeoutMs?: number): Promise<T> {
|
||||
await this.ensureDaemon();
|
||||
await this.ensureDaemon(timeoutMs);
|
||||
try {
|
||||
return (await this.sendRequest<T>(method, params, timeoutMs)) as T;
|
||||
} catch (error) {
|
||||
@ -117,47 +112,87 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureDaemon(): Promise<void> {
|
||||
const configState = await this.checkConfigState();
|
||||
private async ensureDaemon(timeoutMs?: number): Promise<void> {
|
||||
const statusTimeoutMs = resolveDaemonStatusTimeout(timeoutMs);
|
||||
const metadata = await readDaemonMetadata(this.metadataPath);
|
||||
const configState = await this.checkConfigState(metadata);
|
||||
if (configState === 'stale') {
|
||||
await this.stop().catch(() => {});
|
||||
await this.restartDaemon();
|
||||
await this.restartDaemon({ reason: 'stale-config', expectedPid: metadata?.pid });
|
||||
return;
|
||||
}
|
||||
if (configState === 'fresh') {
|
||||
return;
|
||||
if (await this.isResponsive(statusTimeoutMs)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.startDaemon();
|
||||
await this.waitForReady();
|
||||
await this.startDaemon({ preflightTimeoutMs: statusTimeoutMs });
|
||||
}
|
||||
|
||||
private async restartDaemon(): Promise<void> {
|
||||
await this.startDaemon();
|
||||
await this.waitForReady();
|
||||
private async restartDaemon(options: { reason?: 'stale-config'; expectedPid?: number } = {}): Promise<void> {
|
||||
await this.startingWithLock(async () => {
|
||||
const currentStatus = await this.readVerifiedStatus();
|
||||
if (
|
||||
currentStatus &&
|
||||
options.expectedPid !== undefined &&
|
||||
currentStatus.pid !== options.expectedPid &&
|
||||
(await this.checkConfigState()) === 'fresh'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (options.reason === 'stale-config' && currentStatus && (await this.checkConfigState()) === 'fresh') {
|
||||
return;
|
||||
}
|
||||
await this.stop().catch(() => {});
|
||||
await this.waitForStopped();
|
||||
await this.launchDaemonAndWait();
|
||||
});
|
||||
}
|
||||
|
||||
private async startDaemon(): Promise<void> {
|
||||
private async startDaemon(options: { preflightTimeoutMs?: number } = {}): Promise<void> {
|
||||
await this.startingWithLock(async () => {
|
||||
if (await this.isResponsive(options.preflightTimeoutMs)) {
|
||||
return;
|
||||
}
|
||||
await this.launchDaemonAndWait();
|
||||
});
|
||||
}
|
||||
|
||||
private async startingWithLock(task: () => Promise<void>): Promise<void> {
|
||||
if (this.startingPromise) {
|
||||
await this.startingPromise;
|
||||
return;
|
||||
}
|
||||
this.startingPromise = Promise.resolve()
|
||||
.then(async () => {
|
||||
const { launchDaemonDetached } = await import('./launch.js');
|
||||
launchDaemonDetached({
|
||||
configPath: this.options.configPath,
|
||||
configExplicit: this.options.configExplicit,
|
||||
rootDir: this.options.rootDir,
|
||||
metadataPath: this.metadataPath,
|
||||
socketPath: this.socketPath,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.startingPromise = null;
|
||||
});
|
||||
this.startingPromise = withFileLock(this.metadataPath, async () => {
|
||||
await task();
|
||||
}).finally(() => {
|
||||
this.startingPromise = null;
|
||||
});
|
||||
await this.startingPromise;
|
||||
}
|
||||
|
||||
private async launchDaemonAndWait(): Promise<void> {
|
||||
const { launchDaemonDetached } = await import('./launch.js');
|
||||
launchDaemonDetached({
|
||||
configPath: this.options.configPath,
|
||||
configExplicit: this.options.configExplicit,
|
||||
rootDir: this.options.rootDir,
|
||||
metadataPath: this.metadataPath,
|
||||
socketPath: this.socketPath,
|
||||
});
|
||||
await this.waitForReady();
|
||||
}
|
||||
|
||||
private async waitForStopped(): Promise<void> {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!(await this.isResponsive())) {
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error('Daemon did not stop before restart could begin.');
|
||||
}
|
||||
|
||||
private async waitForReady(): Promise<void> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
@ -169,20 +204,31 @@ export class DaemonClient {
|
||||
throw new Error('Timeout while waiting for MCPorter daemon to start.');
|
||||
}
|
||||
|
||||
private async isResponsive(): Promise<boolean> {
|
||||
private async isResponsive(timeoutMs?: number): Promise<boolean> {
|
||||
return (await this.readVerifiedStatus(timeoutMs)) !== null;
|
||||
}
|
||||
|
||||
private async readVerifiedStatus(timeoutMs?: number): Promise<StatusResult | null> {
|
||||
const metadata = await readDaemonMetadata(this.metadataPath);
|
||||
if (!metadata || metadata.socketPath !== this.socketPath || !isProcessRunning(metadata.pid)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await this.sendRequest('status', {});
|
||||
return true;
|
||||
const status = (await this.sendRequest<StatusResult>('status', {}, timeoutMs)) as StatusResult;
|
||||
if (status.pid !== metadata.pid || status.socketPath !== metadata.socketPath) {
|
||||
return null;
|
||||
}
|
||||
return status;
|
||||
} catch (error) {
|
||||
if (isTransportError(error)) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkConfigState(): Promise<DaemonConfigState> {
|
||||
const metadata = await readDaemonMetadata(this.metadataPath);
|
||||
private async checkConfigState(metadata?: DaemonMetadata | null): Promise<DaemonConfigState> {
|
||||
metadata ??= await readDaemonMetadata(this.metadataPath);
|
||||
if (!metadata) {
|
||||
return 'missing';
|
||||
}
|
||||
@ -290,6 +336,18 @@ function isTransportError(error: unknown): boolean {
|
||||
return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ETIMEDOUT' || code === 'ECONNRESET';
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDaemonTimeout(override?: number): number {
|
||||
if (typeof override === 'number' && Number.isFinite(override) && override > 0) {
|
||||
return override;
|
||||
@ -305,6 +363,13 @@ function resolveDaemonTimeout(override?: number): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveDaemonStatusTimeout(override?: number): number | undefined {
|
||||
if (typeof override !== 'number' || !Number.isFinite(override) || override <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(override, MIN_DAEMON_STATUS_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
async function statConfigMtime(configPath: string): Promise<number | null> {
|
||||
try {
|
||||
const stats = await fs.stat(configPath);
|
||||
|
||||
@ -40,7 +40,7 @@ function buildResponse(method: string, id: string) {
|
||||
id,
|
||||
ok: true,
|
||||
result: {
|
||||
pid: 123,
|
||||
pid: activeStatusPid,
|
||||
startedAt: Date.now(),
|
||||
configPath: activeConfigPath,
|
||||
configMtimeMs: activeConfigMtime,
|
||||
@ -59,6 +59,7 @@ function buildResponse(method: string, id: string) {
|
||||
|
||||
let activeConfigPath: string;
|
||||
let activeConfigMtime: number | null = null;
|
||||
let activeStatusPid = process.pid;
|
||||
let activeSocketPath: string;
|
||||
let previousDaemonDir: string | undefined;
|
||||
let activeLayers: Array<{ path: string; mtimeMs: number | null }> = [];
|
||||
@ -84,6 +85,28 @@ describe('DaemonClient config freshness', () => {
|
||||
previousDaemonDir = process.env.MCPORTER_DAEMON_DIR;
|
||||
activeLayers = [];
|
||||
launchDaemonDetached.mockClear();
|
||||
launchDaemonDetached.mockImplementation(
|
||||
(options: { metadataPath: string; socketPath: string; configPath: string }) => {
|
||||
activeStatusPid = process.pid;
|
||||
void fs.writeFile(
|
||||
options.metadataPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: process.pid,
|
||||
socketPath: options.socketPath,
|
||||
configPath: options.configPath,
|
||||
startedAt: Date.now(),
|
||||
logPath: null,
|
||||
configMtimeMs: activeConfigMtime,
|
||||
configLayers: activeLayers,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@ -102,10 +125,12 @@ describe('DaemonClient config freshness', () => {
|
||||
await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }), 'utf8');
|
||||
const stat = await fs.stat(configPath);
|
||||
const oldMtime = stat.mtimeMs - 1000;
|
||||
const deadPid = findNonRunningPid();
|
||||
const { metadataPath, socketPath } = resolveDaemonPaths(configPath);
|
||||
activeConfigPath = configPath;
|
||||
activeSocketPath = socketPath;
|
||||
activeConfigMtime = stat.mtimeMs;
|
||||
activeStatusPid = deadPid;
|
||||
activeLayers = [{ path: configPath, mtimeMs: stat.mtimeMs }];
|
||||
|
||||
await fs.mkdir(path.dirname(metadataPath), { recursive: true });
|
||||
@ -113,7 +138,7 @@ describe('DaemonClient config freshness', () => {
|
||||
metadataPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: 1111,
|
||||
pid: deadPid,
|
||||
socketPath,
|
||||
configPath,
|
||||
startedAt: Date.now() - 10_000,
|
||||
@ -143,10 +168,12 @@ describe('DaemonClient config freshness', () => {
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }), 'utf8');
|
||||
const stat = await fs.stat(configPath);
|
||||
const deadPid = findNonRunningPid();
|
||||
const { metadataPath, socketPath } = resolveDaemonPaths(configPath);
|
||||
activeConfigPath = configPath;
|
||||
activeSocketPath = socketPath;
|
||||
activeConfigMtime = stat.mtimeMs;
|
||||
activeStatusPid = deadPid;
|
||||
activeLayers = [{ path: configPath, mtimeMs: stat.mtimeMs }];
|
||||
|
||||
await fs.mkdir(path.dirname(metadataPath), { recursive: true });
|
||||
@ -154,7 +181,7 @@ describe('DaemonClient config freshness', () => {
|
||||
metadataPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: 1111,
|
||||
pid: deadPid,
|
||||
socketPath,
|
||||
configPath,
|
||||
startedAt: Date.now() - 10_000,
|
||||
@ -189,6 +216,7 @@ describe('DaemonClient config freshness', () => {
|
||||
activeConfigPath = configPath;
|
||||
activeSocketPath = socketPath;
|
||||
activeConfigMtime = stat.mtimeMs;
|
||||
activeStatusPid = process.pid;
|
||||
activeLayers = [{ path: configPath, mtimeMs: stat.mtimeMs }];
|
||||
|
||||
await fs.mkdir(path.dirname(metadataPath), { recursive: true });
|
||||
@ -196,7 +224,7 @@ describe('DaemonClient config freshness', () => {
|
||||
metadataPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: 1111,
|
||||
pid: process.pid,
|
||||
socketPath,
|
||||
configPath,
|
||||
startedAt: Date.now() - 10_000,
|
||||
@ -213,8 +241,21 @@ describe('DaemonClient config freshness', () => {
|
||||
const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
|
||||
await client.listTools({ server: 'playwright' });
|
||||
|
||||
expect(sentMethods).toEqual(['listTools']);
|
||||
expect(sentMethods).toEqual(['status', 'listTools']);
|
||||
expect(sentMethods).not.toContain('stop');
|
||||
expect(launchDaemonDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function findNonRunningPid(): number {
|
||||
for (let pid = process.pid + 100_000; pid < process.pid + 101_000; pid += 1) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Unable to find a non-running pid for daemon tests.');
|
||||
}
|
||||
|
||||
230
tests/daemon-client-lifecycle.test.ts
Normal file
230
tests/daemon-client-lifecycle.test.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { makeShortTempDir } from './fixtures/test-helpers.js';
|
||||
|
||||
const launchDaemonDetached = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../src/daemon/launch.js', () => ({
|
||||
launchDaemonDetached,
|
||||
}));
|
||||
|
||||
const { DaemonClient, resolveDaemonPaths } = await import('../src/daemon/client.js');
|
||||
|
||||
interface MockDaemonOptions {
|
||||
readonly configPath: string;
|
||||
readonly socketPath: string;
|
||||
readonly metadataPath: string;
|
||||
}
|
||||
|
||||
const servers: net.Server[] = [];
|
||||
let previousDaemonDir: string | undefined;
|
||||
|
||||
describe('DaemonClient lifecycle reconciliation', () => {
|
||||
beforeEach(() => {
|
||||
previousDaemonDir = process.env.MCPORTER_DAEMON_DIR;
|
||||
launchDaemonDetached.mockReset();
|
||||
launchDaemonDetached.mockImplementation((options: MockDaemonOptions) => {
|
||||
void startMockDaemon(options, process.pid);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => closeServer(server)));
|
||||
if (previousDaemonDir === undefined) {
|
||||
delete process.env.MCPORTER_DAEMON_DIR;
|
||||
} else {
|
||||
process.env.MCPORTER_DAEMON_DIR = previousDaemonDir;
|
||||
}
|
||||
});
|
||||
|
||||
it('serializes concurrent daemon starts with a filesystem lock', async () => {
|
||||
const tmpDir = await makeShortTempDir('daemon-lock');
|
||||
process.env.MCPORTER_DAEMON_DIR = tmpDir;
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const firstClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
|
||||
const secondClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
|
||||
|
||||
await Promise.all([firstClient.listTools({ server: 'warm' }), secondClient.listTools({ server: 'warm' })]);
|
||||
|
||||
expect(launchDaemonDetached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects socket responders that do not match metadata pid', async () => {
|
||||
const tmpDir = await makeShortTempDir('daemon-pid');
|
||||
process.env.MCPORTER_DAEMON_DIR = tmpDir;
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }), 'utf8');
|
||||
const { socketPath, metadataPath } = resolveDaemonPaths(configPath);
|
||||
await fs.mkdir(path.dirname(metadataPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
metadataPath,
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
socketPath,
|
||||
configPath,
|
||||
configLayers: [{ path: configPath, mtimeMs: (await fs.stat(configPath)).mtimeMs }],
|
||||
startedAt: Date.now(),
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await startStatusServer(socketPath, process.pid + 10_000, configPath);
|
||||
|
||||
const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
|
||||
|
||||
await expect(client.status()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('forces a new daemon after a request transport failure even when status still responds', async () => {
|
||||
const tmpDir = await makeShortTempDir('daemon-restart');
|
||||
process.env.MCPORTER_DAEMON_DIR = tmpDir;
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }),
|
||||
'utf8'
|
||||
);
|
||||
const paths = resolveDaemonPaths(configPath);
|
||||
await startMockDaemon({ ...paths, configPath }, process.pid, { failCallTool: true });
|
||||
|
||||
const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
|
||||
const result = await client.callTool({ server: 'warm', tool: 'list' });
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(launchDaemonDetached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent stale-config restarts after the first replacement wins', async () => {
|
||||
const tmpDir = await makeShortTempDir('daemon-stale-lock');
|
||||
process.env.MCPORTER_DAEMON_DIR = tmpDir;
|
||||
const configPath = path.join(tmpDir, 'config.json');
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }),
|
||||
'utf8'
|
||||
);
|
||||
const stat = await fs.stat(configPath);
|
||||
const deadPid = findNonRunningPid();
|
||||
const paths = resolveDaemonPaths(configPath);
|
||||
await fs.mkdir(path.dirname(paths.metadataPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
paths.metadataPath,
|
||||
JSON.stringify({
|
||||
pid: deadPid,
|
||||
socketPath: paths.socketPath,
|
||||
configPath,
|
||||
configLayers: [{ path: configPath, mtimeMs: stat.mtimeMs - 1000 }],
|
||||
startedAt: Date.now() - 10_000,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const firstClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
|
||||
const secondClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir });
|
||||
|
||||
await Promise.all([firstClient.listTools({ server: 'warm' }), secondClient.listTools({ server: 'warm' })]);
|
||||
|
||||
expect(launchDaemonDetached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
async function startMockDaemon(
|
||||
options: MockDaemonOptions,
|
||||
pid: number,
|
||||
behavior: { failCallTool?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const stat = await fs.stat(options.configPath);
|
||||
await startStatusServer(options.socketPath, pid, options.configPath, options.metadataPath, behavior);
|
||||
await fs.mkdir(path.dirname(options.metadataPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
options.metadataPath,
|
||||
JSON.stringify({
|
||||
pid,
|
||||
socketPath: options.socketPath,
|
||||
configPath: options.configPath,
|
||||
configLayers: [{ path: options.configPath, mtimeMs: stat.mtimeMs }],
|
||||
startedAt: Date.now(),
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function startStatusServer(
|
||||
socketPath: string,
|
||||
pid: number,
|
||||
configPath: string,
|
||||
metadataPath?: string,
|
||||
behavior: { failCallTool?: boolean } = {}
|
||||
): Promise<void> {
|
||||
await fs.mkdir(path.dirname(socketPath), { recursive: true });
|
||||
await fs.unlink(socketPath).catch(() => {});
|
||||
const server = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
const request = JSON.parse(buffer) as { id: string; method: string };
|
||||
if (request.method === 'callTool' && behavior.failCallTool) {
|
||||
behavior.failCallTool = false;
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (request.method === 'stop') {
|
||||
socket.end(JSON.stringify({ id: request.id, ok: true, result: true }), () => {
|
||||
server.close(() => {});
|
||||
if (metadataPath) {
|
||||
void fs.unlink(metadataPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
request.method === 'status'
|
||||
? {
|
||||
pid,
|
||||
startedAt: Date.now(),
|
||||
configPath,
|
||||
socketPath,
|
||||
servers: [],
|
||||
}
|
||||
: request.method === 'callTool'
|
||||
? { ok: true }
|
||||
: { tools: [] };
|
||||
socket.end(JSON.stringify({ id: request.id, ok: true, result }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(socketPath, () => {
|
||||
server.off('error', reject);
|
||||
servers.push(server);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function closeServer(server: net.Server): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve()).on('error', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function findNonRunningPid(): number {
|
||||
for (let pid = process.pid + 100_000; pid < process.pid + 101_000; pid += 1) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('Unable to find a non-running pid for daemon tests.');
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
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 }> = [];
|
||||
|
||||
@ -34,6 +37,8 @@ class MockSocket extends EventEmitter {
|
||||
}
|
||||
|
||||
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);
|
||||
@ -41,6 +46,8 @@ const createConnection = vi.fn(() => {
|
||||
});
|
||||
|
||||
let previousDaemonTimeout: string | undefined;
|
||||
let previousDaemonDir: string | undefined;
|
||||
let tmpDaemonDir: string | undefined;
|
||||
|
||||
vi.mock('node:net', () => ({
|
||||
createConnection,
|
||||
@ -51,7 +58,7 @@ vi.mock('../src/daemon/launch.js', () => ({
|
||||
launchDaemonDetached: vi.fn(),
|
||||
}));
|
||||
|
||||
const { DaemonClient } = await import('../src/daemon/client.js');
|
||||
const { DaemonClient, resolveDaemonPaths } = await import('../src/daemon/client.js');
|
||||
|
||||
function buildResponse(method: string, id: string) {
|
||||
if (method === 'status') {
|
||||
@ -59,10 +66,10 @@ function buildResponse(method: string, id: string) {
|
||||
id,
|
||||
ok: true,
|
||||
result: {
|
||||
pid: 123,
|
||||
pid: process.pid,
|
||||
startedAt: Date.now(),
|
||||
configPath: 'test',
|
||||
socketPath: '/tmp/socket',
|
||||
configPath: activeConfigPath,
|
||||
socketPath: activeSocketPath,
|
||||
servers: [],
|
||||
},
|
||||
};
|
||||
@ -75,40 +82,92 @@ function buildResponse(method: string, id: string) {
|
||||
}
|
||||
|
||||
describe('DaemonClient timeouts', () => {
|
||||
beforeEach(() => {
|
||||
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(() => {
|
||||
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 client = new DaemonClient({ configPath: 'mcporter.config.json' });
|
||||
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 client = new DaemonClient({ configPath: 'mcporter.config.json' });
|
||||
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 client = new DaemonClient({ configPath: 'mcporter.config.json' });
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
@ -90,7 +90,7 @@ describe('daemon client', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('skips status preflight when daemon metadata is fresh', async () => {
|
||||
it('verifies daemon pid before trusting fresh metadata', async () => {
|
||||
const tmpDir = await makeShortTempDir('mcpd-fresh');
|
||||
const originalDir = process.env.MCPORTER_DAEMON_DIR;
|
||||
process.env.MCPORTER_DAEMON_DIR = tmpDir;
|
||||
@ -121,7 +121,18 @@ describe('daemon client', () => {
|
||||
buffer += chunk;
|
||||
const request = JSON.parse(buffer) as { id: string; method: string };
|
||||
methods.push(request.method);
|
||||
socket.end(JSON.stringify({ id: request.id, ok: true, result: { tools: [] } }));
|
||||
const result =
|
||||
request.method === 'status'
|
||||
? {
|
||||
pid: process.pid,
|
||||
startedAt: Date.now(),
|
||||
configPath,
|
||||
configLayers: [{ path: configPath, mtimeMs: configStats.mtimeMs }],
|
||||
socketPath,
|
||||
servers: [],
|
||||
}
|
||||
: { tools: [] };
|
||||
socket.end(JSON.stringify({ id: request.id, ok: true, result }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@ -134,7 +145,7 @@ describe('daemon client', () => {
|
||||
try {
|
||||
const client = new DaemonClient({ configPath, configExplicit: true });
|
||||
await client.listTools({ server: 'warm' });
|
||||
expect(methods).toEqual(['listTools']);
|
||||
expect(methods).toEqual(['status', 'listTools']);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await fs.unlink(socketPath).catch(() => {});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user