mcporter/tests/daemon-host.test.ts
Peter Steinberger fe87142d89
Some checks are pending
CI / build (${{ matrix.os }}) (windows-latest) (push) Waiting to run
CI / build (${{ matrix.os }}) (macos-15) (push) Waiting to run
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Waiting to run
fix(daemon): preserve replacement socket ownership
2026-06-25 13:46:49 -07:00

322 lines
10 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import {
__testProcessRequest,
cleanupDaemonArtifactsIfOwned,
isDaemonResponding,
metadataMatches,
} from '../src/daemon/host.js';
import type { DaemonRequest } from '../src/daemon/protocol.js';
import type { Runtime } from '../src/runtime.js';
describe('daemon host request handling', () => {
const metadata = {
configPath: '/tmp/config.json',
configLayers: [],
configMtimeMs: Date.now(),
socketPath: '/tmp/socket',
startedAt: Date.now(),
logPath: null,
};
const logContext = { enabled: false, logAllServers: false, servers: new Set<string>() };
it('reuses pre-parsed requests without reparsing payloads', async () => {
const parsedRequest: DaemonRequest = { id: '1', method: 'status', params: {} };
const result = await __testProcessRequest(
'!!!invalid-json!!!',
{} as Runtime,
new Map<string, ServerDefinition>(),
new Map(),
metadata,
logContext,
parsedRequest
);
expect(result.response.ok).toBe(true);
expect(result.shouldShutdown).toBe(false);
});
it('defaults daemon callTool and listTools requests to cached auth', async () => {
const runtime = createRuntimeDouble();
const managedServers = createManagedServers();
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'call',
method: 'callTool',
params: { server: 'oauth', tool: 'ping' },
});
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
args: {},
timeoutMs: undefined,
disableOAuth: false,
});
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'list',
method: 'listTools',
params: { server: 'oauth', includeSchema: true },
});
expect(runtime.listTools).toHaveBeenCalledWith('oauth', {
includeSchema: true,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: false,
});
});
it('keeps stdio keep-alive listTools requests reusable when callers disable auto auth', async () => {
const runtime = createRuntimeDouble();
const managedServers = createManagedServers();
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'list',
method: 'listTools',
params: { server: 'local', includeSchema: true, autoAuthorize: false, allowCachedAuth: true },
});
expect(runtime.listTools).toHaveBeenCalledWith('local', {
includeSchema: true,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: false,
});
});
it('preserves HTTP listTools auto-auth opt out on daemon requests', async () => {
const runtime = createRuntimeDouble();
const managedServers = createManagedServers();
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'list',
method: 'listTools',
params: { server: 'oauth', includeSchema: true, autoAuthorize: false, allowCachedAuth: true },
});
expect(runtime.listTools).toHaveBeenCalledWith('oauth', {
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
disableOAuth: false,
});
});
it('forwards disableOAuth on daemon callTool and listTools requests', async () => {
const runtime = createRuntimeDouble();
const managedServers = createManagedServers();
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'call',
method: 'callTool',
params: { server: 'oauth', tool: 'ping', disableOAuth: true },
});
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
args: {},
timeoutMs: undefined,
disableOAuth: true,
});
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'list',
method: 'listTools',
params: { server: 'oauth', includeSchema: true, disableOAuth: true },
});
expect(runtime.listTools).toHaveBeenCalledWith('oauth', {
includeSchema: true,
autoAuthorize: undefined,
allowCachedAuth: true,
disableOAuth: true,
});
});
it('preserves explicit listTools cached-auth opt out on daemon requests', async () => {
const runtime = createRuntimeDouble();
const managedServers = createManagedServers();
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
id: 'list',
method: 'listTools',
params: { server: 'oauth', allowCachedAuth: false },
});
expect(runtime.listTools).toHaveBeenCalledWith('oauth', {
includeSchema: undefined,
autoAuthorize: undefined,
allowCachedAuth: false,
disableOAuth: false,
});
});
});
const describeUnixSocket = process.platform === 'win32' ? describe.skip : describe;
describeUnixSocket('isDaemonResponding', () => {
const servers: net.Server[] = [];
const connections: net.Socket[] = [];
const socketPaths: string[] = [];
function socketPath(): string {
const p = path.join(os.tmpdir(), `mcporter-probe-${randomUUID().slice(0, 8)}.sock`);
socketPaths.push(p);
return p;
}
function listen(server: net.Server, p: string): Promise<void> {
servers.push(server);
server.on('connection', (socket) => connections.push(socket));
return new Promise((resolve) => server.listen(p, () => resolve()));
}
afterEach(async () => {
for (const socket of connections.splice(0)) {
socket.destroy();
}
for (const server of servers.splice(0)) {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
for (const p of socketPaths.splice(0)) {
await fs.rm(p, { force: true }).catch(() => {});
}
});
it('returns true when the socket answers status with a matching socket and live pid', async () => {
const p = socketPath();
await listen(statusServer({ pid: process.pid, socketPath: p }), p);
expect(await isDaemonResponding(p)).toBe(true);
});
it('returns false when the socket accepts but never responds (hung daemon)', async () => {
const p = socketPath();
await listen(
net.createServer((socket) => socket.pause()),
p
);
expect(await isDaemonResponding(p)).toBe(false);
}, 5_000);
it('returns false when status reports a different socket (foreign listener)', async () => {
const p = socketPath();
await listen(statusServer({ pid: process.pid, socketPath: '/some/other/daemon.sock' }), p);
expect(await isDaemonResponding(p)).toBe(false);
});
it('returns false when status reports a dead pid', async () => {
const p = socketPath();
await listen(statusServer({ pid: 2_147_483_646, socketPath: p }), p);
expect(await isDaemonResponding(p)).toBe(false);
});
it('returns false when nothing is listening', async () => {
expect(await isDaemonResponding(socketPath())).toBe(false);
});
});
describe('metadataMatches', () => {
let metadataPath: string;
const live = { pid: 4321, socketPath: '/tmp/daemon.sock' };
beforeEach(async () => {
metadataPath = path.join(os.tmpdir(), `mcporter-meta-${randomUUID().slice(0, 8)}.json`);
});
afterEach(async () => {
await fs.rm(metadataPath, { force: true }).catch(() => {});
});
it('matches when pid and socket agree', async () => {
await fs.writeFile(metadataPath, JSON.stringify({ pid: 4321, socketPath: '/tmp/daemon.sock' }), 'utf8');
expect(await metadataMatches(metadataPath, live)).toBe(true);
});
it('does not match a different pid', async () => {
await fs.writeFile(metadataPath, JSON.stringify({ pid: 9999, socketPath: '/tmp/daemon.sock' }), 'utf8');
expect(await metadataMatches(metadataPath, live)).toBe(false);
});
it('does not match when metadata is missing', async () => {
expect(await metadataMatches(metadataPath, live)).toBe(false);
});
it('does not match when metadata is corrupt', async () => {
await fs.writeFile(metadataPath, '{ not json', 'utf8');
expect(await metadataMatches(metadataPath, live)).toBe(false);
});
});
describe('daemon artifact cleanup', () => {
let dir: string;
let metadataPath: string;
let socketPath: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cleanup-'));
metadataPath = path.join(dir, 'daemon.json');
socketPath = path.join(dir, 'daemon.sock');
await fs.writeFile(socketPath, 'socket', 'utf8');
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it('removes artifacts still owned by the stopping daemon', async () => {
await fs.writeFile(metadataPath, JSON.stringify({ pid: 4321, socketPath }), 'utf8');
await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321);
await expect(fs.access(metadataPath)).rejects.toThrow();
await expect(fs.access(socketPath)).rejects.toThrow();
});
it('preserves artifacts replaced by a newer daemon', async () => {
await fs.writeFile(metadataPath, JSON.stringify({ pid: 9876, socketPath }), 'utf8');
await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321);
await expect(fs.access(metadataPath)).resolves.toBeUndefined();
await expect(fs.access(socketPath)).resolves.toBeUndefined();
});
});
function createRuntimeDouble(): Pick<Runtime, 'callTool' | 'listTools'> {
return {
callTool: vi.fn().mockResolvedValue({ ok: true }),
listTools: vi.fn().mockResolvedValue([]),
};
}
function createManagedServers(): Map<string, ServerDefinition> {
return new Map([
[
'local',
{
name: 'local',
command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: '/tmp' },
lifecycle: { mode: 'keep-alive' },
},
],
[
'oauth',
{
name: 'oauth',
command: { kind: 'http', url: new URL('https://oauth.example.com/mcp') },
lifecycle: { mode: 'keep-alive' },
},
],
]);
}
function statusServer(result: Record<string, unknown>): net.Server {
return net.createServer((socket) => {
socket.on('data', () => socket.end(JSON.stringify({ id: '1', ok: true, result })));
});
}