From 73c6d699edaabeb45895580699ad46be53b362b8 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 12:27:46 +0200 Subject: [PATCH 1/8] feat(utils): add a process supervision kit and shut stdio servers down when orphaned Move the CLI's process-tree helpers into a Node-only process module in @frontmcp/utils, with spawnOwned, reapOrphans, watchParent and watchPath. runStdio shuts down through the normal shutdown path when stdin ends or the parent exits (lifecycle.exitWhenOrphaned, on by default for stdio, opt-in for the HTTP and Unix socket servers). CliTaskRunner spawns its workers owned under TASK_WORKER_MARKER so orphaned ones can be reaped. --- .../e2e/dts-consumer.e2e.spec.ts | 5 + .../fixture/process-kit-must-compile.ts | 54 +++++ .../fixture/tsconfig.json | 1 + .../e2e/reap-orphans.e2e.spec.ts | 45 ++++ .../fixture/orphan-owner.ts | 25 ++ .../e2e/helpers/stdio-json-rpc.ts | 21 +- .../e2e/stdio-orphaned.e2e.spec.ts | 100 ++++++++ .../src/stdio-orphan-entrypoint.ts | 31 +++ .../src/stdio-orphan-parent.ts | 19 ++ .../commands/dev/bridge/child-supervisor.ts | 3 +- libs/cli/src/commands/dev/dev.ts | 3 +- .../common/types/options/lifecycle/schema.ts | 7 + .../__tests__/exit-when-orphaned.spec.ts | 200 ++++++++++++++++ .../__tests__/run-stdio-orphaned.spec.ts | 101 ++++++++ libs/sdk/src/front-mcp/front-mcp.ts | 24 +- libs/sdk/src/front-mcp/shutdown-signals.ts | 26 ++- .../helpers/__tests__/cli-task-runner.spec.ts | 37 ++- libs/sdk/src/task/helpers/cli-task-runner.ts | 16 +- libs/sdk/src/task/index.ts | 1 + .../references/mcp-client-integration.md | 4 + .../references/create-provider.md | 12 +- .../stdio-transport-error-handling.md | 11 +- .../references/production-cli-binary.md | 2 +- .../references/production-cli-daemon.md | 2 + libs/skills/catalog/skills-manifest.json | 2 +- libs/utils/README.md | 39 ++-- libs/utils/src/index.ts | 23 ++ .../process/__tests__/owned-process.spec.ts | 215 ++++++++++++++++++ .../process}/__tests__/process-tree.spec.ts | 0 .../__tests__/process-tree.windows.spec.ts | 0 .../process/__tests__/process-watch.spec.ts | 153 +++++++++++++ libs/utils/src/process/child-process.ts | 12 + libs/utils/src/process/index.ts | 6 + libs/utils/src/process/owned-process.ts | 189 +++++++++++++++ .../src/process}/process-tree.ts | 36 +-- libs/utils/src/process/process-watch.ts | 87 +++++++ 36 files changed, 1448 insertions(+), 64 deletions(-) create mode 100644 apps/e2e/demo-e2e-dts-consumer/fixture/process-kit-must-compile.ts create mode 100644 apps/e2e/demo-e2e-local-runtime/e2e/reap-orphans.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-local-runtime/fixture/orphan-owner.ts create mode 100644 apps/e2e/demo-e2e-stdio-transport/e2e/stdio-orphaned.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-entrypoint.ts create mode 100644 apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-parent.ts create mode 100644 libs/sdk/src/front-mcp/__tests__/exit-when-orphaned.spec.ts create mode 100644 libs/sdk/src/front-mcp/__tests__/run-stdio-orphaned.spec.ts create mode 100644 libs/utils/src/process/__tests__/owned-process.spec.ts rename libs/{cli/src/shared => utils/src/process}/__tests__/process-tree.spec.ts (100%) rename libs/{cli/src/shared => utils/src/process}/__tests__/process-tree.windows.spec.ts (100%) create mode 100644 libs/utils/src/process/__tests__/process-watch.spec.ts create mode 100644 libs/utils/src/process/child-process.ts create mode 100644 libs/utils/src/process/index.ts create mode 100644 libs/utils/src/process/owned-process.ts rename libs/{cli/src/shared => utils/src/process}/process-tree.ts (90%) create mode 100644 libs/utils/src/process/process-watch.ts diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts index 8b27a2547..ae067962c 100644 --- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts +++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts @@ -27,6 +27,7 @@ function toFixtureDiagnostic(diagnostic: ts.Diagnostic): FixtureDiagnostic { const fixtureFiles = [ 'provider-lifecycle-must-compile.ts', 'sqlite-storage-must-compile.ts', + 'process-kit-must-compile.ts', 'tool-must-fail.ts', 'prompts-must-compile.ts', 'hooks-must-compile.ts', @@ -109,6 +110,10 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => { expect(describeEach(inFixtureFile('sqlite-storage-must-compile.ts'))).toEqual([]); }); + it('types the process kit and lifecycle.exitWhenOrphaned', () => { + expect(describeEach(inFixtureFile('process-kit-must-compile.ts'))).toEqual([]); + }); + it('accepts the documented @Prompt forms', () => { expect(describeEach(inFixtureFile('prompts-must-compile.ts'))).toEqual([]); }); diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/process-kit-must-compile.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/process-kit-must-compile.ts new file mode 100644 index 000000000..32b772a8a --- /dev/null +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/process-kit-must-compile.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; + +import { App, FrontMcp, TASK_WORKER_MARKER } from '@frontmcp/sdk'; +import { + OwnedProcessError, + reapOrphans, + spawnOwned, + watchParent, + watchPath, + type OwnedProcess, + type ParentGoneReason, + type ProcessWatcher, +} from '@frontmcp/utils'; + +@App({ name: 'attached' }) +class AttachedApp {} + +@FrontMcp({ + info: { name: 'attached', version: '1.0.0' }, + apps: [AttachedApp], + lifecycle: { exitWhenOrphaned: false }, +}) +export class AttachedServer {} + +@FrontMcp({ + info: { name: 'attached-typo', version: '1.0.0' }, + apps: [AttachedApp], + // @ts-expect-error exitWhenOrphaned is a boolean + lifecycle: { exitWhenOrphaned: 'no' }, +}) +export class AttachedTypoServer {} + +export function startWorker(): OwnedProcess { + return spawnOwned(process.execPath, ['worker.js'], { marker: 'worker', detached: true, stdio: 'ignore' }); +} + +// @ts-expect-error the marker is required +spawnOwned(process.execPath, ['worker.js'], { detached: true }); + +export async function reapTaskWorkers(): Promise { + try { + return await reapOrphans(TASK_WORKER_MARKER, { graceMs: 1000 }); + } catch (error) { + if (error instanceof OwnedProcessError) return []; + throw error; + } +} + +export function exitWithParent(onGone: (reason: ParentGoneReason) => void): ProcessWatcher[] { + return [ + watchParent({ onGone, stdin: process.stdin }), + watchPath('/srv/checkout', { onGone: () => onGone('parent-exited') }), + ]; +} diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json index 3947979dd..187e7dc0c 100644 --- a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json @@ -13,6 +13,7 @@ "files": [ "provider-lifecycle-must-compile.ts", "sqlite-storage-must-compile.ts", + "process-kit-must-compile.ts", "tool-must-fail.ts", "prompts-must-compile.ts", "hooks-must-compile.ts", diff --git a/apps/e2e/demo-e2e-local-runtime/e2e/reap-orphans.e2e.spec.ts b/apps/e2e/demo-e2e-local-runtime/e2e/reap-orphans.e2e.spec.ts new file mode 100644 index 000000000..7a17ea545 --- /dev/null +++ b/apps/e2e/demo-e2e-local-runtime/e2e/reap-orphans.e2e.spec.ts @@ -0,0 +1,45 @@ +/** E2E: `reapOrphans(marker)` stops the processes marked by an owner that is gone, and leaves every other process alone. */ +import { randomUUID, reapOrphans, spawnOwned, type OwnedProcess } from '@frontmcp/utils'; + +import type { OrphanOwnerResult } from '../fixture/orphan-owner'; +import { spawnWorker } from './helpers/workers'; + +function running(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +(process.platform === 'win32' ? describe.skip : describe)('reapOrphans', () => { + const leftovers: number[] = []; + let ours: OwnedProcess | undefined; + + afterEach(async () => { + for (const pid of leftovers.splice(0)) if (running(pid)) process.kill(pid, 'SIGKILL'); + await ours?.stop('SIGKILL', 1000); + ours = undefined; + }); + + it('stops marked orphans and leaves unmarked processes and live owners alone', async () => { + const marker = `e2e-reap-${randomUUID().slice(0, 8)}`; + ours = spawnOwned(process.execPath, ['-e', 'setInterval(() => {}, 1000)', '--'], { + marker, + detached: true, + stdio: 'ignore', + }); + const { marked, unmarked } = await spawnWorker('orphan-owner.ts', { MARKER: marker }).result; + leftovers.push(marked, unmarked); + expect(running(marked)).toBe(true); + + const reaped = await reapOrphans(marker); + + expect(reaped).toEqual([marked]); + expect(running(marked)).toBe(false); + expect(running(unmarked)).toBe(true); + expect(ours.child.exitCode).toBeNull(); + await expect(reapOrphans(marker)).resolves.toEqual([]); + }); +}); diff --git a/apps/e2e/demo-e2e-local-runtime/fixture/orphan-owner.ts b/apps/e2e/demo-e2e-local-runtime/fixture/orphan-owner.ts new file mode 100644 index 000000000..2612233ed --- /dev/null +++ b/apps/e2e/demo-e2e-local-runtime/fixture/orphan-owner.ts @@ -0,0 +1,25 @@ +/** Start one child owned under `MARKER` and one unmarked child, both detached, report their pids, and exit: both outlive their owner. */ +import { getSpawnFn, spawnOwned } from '@frontmcp/utils'; + +import { report, requiredEnv, runWorker } from './worker-protocol'; + +export interface OrphanOwnerResult { + marked: number; + unmarked: number; +} + +// `--` ends node's own options, so the owner tag spawnOwned appends reaches the script as an argument. +const IDLE = ['-e', 'setInterval(() => {}, 1000)', '--']; + +runWorker(async () => { + const marked = spawnOwned(process.execPath, IDLE, { + marker: requiredEnv('MARKER'), + detached: true, + stdio: 'ignore', + }).child; + const unmarked = getSpawnFn()(process.execPath, IDLE, { detached: true, stdio: 'ignore' }); + marked.unref(); + unmarked.unref(); + if (marked.pid === undefined || unmarked.pid === undefined) throw new Error('worker: a child has no pid'); + report({ marked: marked.pid, unmarked: unmarked.pid } satisfies OrphanOwnerResult); +}); diff --git a/apps/e2e/demo-e2e-stdio-transport/e2e/helpers/stdio-json-rpc.ts b/apps/e2e/demo-e2e-stdio-transport/e2e/helpers/stdio-json-rpc.ts index 25fb95789..9fb0eccdc 100644 --- a/apps/e2e/demo-e2e-stdio-transport/e2e/helpers/stdio-json-rpc.ts +++ b/apps/e2e/demo-e2e-stdio-transport/e2e/helpers/stdio-json-rpc.ts @@ -79,8 +79,27 @@ export class StdioJsonRpcProcess { return exited; } - async stop(): Promise { + /** End stdin, leaving the process unsignalled, and resolve with the exit code. */ + async closeStdin(): Promise { + if (this.hasExited()) return this.child.exitCode; + const exited = new Promise((resolve) => this.child.once('exit', (code) => resolve(code))); + this.child.stdin.end(); + return exited; + } + + /** Kill the process outright, leaving stdin open. */ + async kill(): Promise { if (this.hasExited()) return; + const exited = new Promise((resolve) => this.child.once('exit', resolve)); + this.child.kill('SIGKILL'); + await exited; + } + + async stop(): Promise { + if (this.hasExited()) { + this.child.stdin.destroy(); + return; + } const exited = new Promise((resolve) => this.child.once('close', resolve)); this.child.stdin.end(); this.child.kill('SIGTERM'); diff --git a/apps/e2e/demo-e2e-stdio-transport/e2e/stdio-orphaned.e2e.spec.ts b/apps/e2e/demo-e2e-stdio-transport/e2e/stdio-orphaned.e2e.spec.ts new file mode 100644 index 000000000..01cb257b7 --- /dev/null +++ b/apps/e2e/demo-e2e-stdio-transport/e2e/stdio-orphaned.e2e.spec.ts @@ -0,0 +1,100 @@ +/** + * E2E: a stdio server whose client goes away shuts down through its shutdown phase and exits, instead of staying + * alive as an orphan: when stdin closes, and when the parent dies while stdin stays open. The server's provider keeps + * a timer running until `onShutdown()`, so only the shutdown phase lets it end. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { StdioJsonRpcProcess } from './helpers/stdio-json-rpc'; + +const projectPath = path.join(__dirname, '..'); +const EXIT_BOUND_MS = 10_000; + +const initialize = { + jsonrpc: '2.0' as const, + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'orphan-e2e', version: '1.0' } }, +}; + +function running(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function poll(read: () => T | undefined, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = read(); + if (value !== undefined || Date.now() > deadline) return value; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +function readPids(pidFile: string): { server: number; relay: number } | undefined { + try { + const pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')) as { server?: number; relay?: number }; + return pids.server && pids.relay ? { server: pids.server, relay: pids.relay } : undefined; + } catch { + return undefined; + } +} + +describe('stdio server without its client', () => { + let directory: string; + let shutdownMarker: string; + let server: StdioJsonRpcProcess | undefined; + const leftovers: number[] = []; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'stdio-orphan-')); + shutdownMarker = path.join(directory, 'shutdown'); + }); + + afterEach(async () => { + for (const pid of leftovers.splice(0)) if (running(pid)) process.kill(pid, 'SIGKILL'); + await server?.stop(); + server = undefined; + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it('runs the shutdown phase and exits 0 when stdin closes', async () => { + server = StdioJsonRpcProcess.start('src/stdio-orphan-entrypoint.ts', projectPath, { + SHUTDOWN_MARKER: shutdownMarker, + }); + expect((await server.request(initialize)).result).toBeDefined(); + + const exitCode = await Promise.race([ + server.closeStdin(), + new Promise((resolve) => setTimeout(() => resolve('still running'), EXIT_BOUND_MS)), + ]); + + expect(exitCode).toBe(0); + expect(fs.readFileSync(shutdownMarker, 'utf8')).toBe('shutdown'); + }, 60_000); + + it('runs the shutdown phase and exits within a bounded time when its parent dies and stdin stays open', async () => { + const pidFile = path.join(directory, 'pids.json'); + server = StdioJsonRpcProcess.start('src/stdio-orphan-parent.ts', projectPath, { + SHUTDOWN_MARKER: shutdownMarker, + SERVER_PID_FILE: pidFile, + }); + expect((await server.request(initialize)).result).toBeDefined(); + const pids = await poll(() => readPids(pidFile), 10_000); + if (!pids) throw new Error('the parent never reported the server pid'); + leftovers.push(pids.server, pids.relay); + + await server.kill(); + const gone = await poll(() => (running(pids.server) ? undefined : true), EXIT_BOUND_MS); + + expect(gone).toBe(true); + expect(running(pids.relay)).toBe(true); + expect(fs.readFileSync(shutdownMarker, 'utf8')).toBe('shutdown'); + }, 60_000); +}); diff --git a/apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-entrypoint.ts b/apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-entrypoint.ts new file mode 100644 index 000000000..0ba36e716 --- /dev/null +++ b/apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-entrypoint.ts @@ -0,0 +1,31 @@ +/** + * Stdio entrypoint with a provider that keeps a timer running, as a connection pool would, until `onShutdown()` + * clears it and writes `SHUTDOWN_MARKER`. Only the shutdown phase lets the process end. + */ +import { App, FrontMcpInstance, Provider, type OnReady, type OnShutdown } from '@frontmcp/sdk'; +import { writeFile } from '@frontmcp/utils'; + +import { serverConfig } from './config.js'; + +@Provider({ name: 'KeepAlive' }) +class KeepAlive implements OnReady, OnShutdown { + private timer?: ReturnType; + + onReady(): void { + this.timer = setInterval(() => undefined, 60_000); + } + + async onShutdown(): Promise { + clearInterval(this.timer); + const markerPath = process.env['SHUTDOWN_MARKER']; + if (markerPath) await writeFile(markerPath, 'shutdown'); + } +} + +@App({ name: 'Orphan', providers: [KeepAlive] }) +class OrphanApp {} + +FrontMcpInstance.runStdio({ ...serverConfig, apps: [...serverConfig.apps, OrphanApp] }).catch((err) => { + console.error('Failed to start stdio server:', err); + process.exit(1); +}); diff --git a/apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-parent.ts b/apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-parent.ts new file mode 100644 index 000000000..58c48d26d --- /dev/null +++ b/apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-parent.ts @@ -0,0 +1,19 @@ +/** + * A client stand-in that can die while the stdio server's stdin stays open: a relay that outlives it forwards + * this process's stdin to the server. Writes `{ server, relay }` pids to `SERVER_PID_FILE`. + */ +import { getSpawnFn, writeFile } from '@frontmcp/utils'; + +const RELAY = 'process.stdin.on("data", (chunk) => process.stdout.write(chunk)); setInterval(() => undefined, 60_000);'; + +const spawn = getSpawnFn(); +const relay = spawn(process.execPath, ['-e', RELAY], { stdio: ['inherit', 'pipe', 'ignore'] }); +const server = spawn(process.execPath, ['--import', 'tsx', 'src/stdio-orphan-entrypoint.ts'], { + stdio: [relay.stdout, 'inherit', 'inherit'], +}); +relay.stdout?.destroy(); +void writeFile( + process.env['SERVER_PID_FILE'] ?? 'server.pid', + JSON.stringify({ server: server.pid, relay: relay.pid }), +); +setInterval(() => undefined, 60_000); diff --git a/libs/cli/src/commands/dev/bridge/child-supervisor.ts b/libs/cli/src/commands/dev/bridge/child-supervisor.ts index 596ab730e..ed9968ed7 100644 --- a/libs/cli/src/commands/dev/bridge/child-supervisor.ts +++ b/libs/cli/src/commands/dev/bridge/child-supervisor.ts @@ -29,7 +29,8 @@ import { type ChildProcess } from 'node:child_process'; import * as net from 'node:net'; import { pathToFileURL } from 'node:url'; -import { processTreeSpawnOptions, stopProcessTree } from '../../../shared/process-tree'; +import { processTreeSpawnOptions, stopProcessTree } from '@frontmcp/utils'; + import { packageManagerCommand, spawnTool, type ToolCommand, type ToolHost } from '../../../shared/tool-command'; import type { BridgeLogger } from './log'; diff --git a/libs/cli/src/commands/dev/dev.ts b/libs/cli/src/commands/dev/dev.ts index 3bc4f5975..a7bc149bc 100644 --- a/libs/cli/src/commands/dev/dev.ts +++ b/libs/cli/src/commands/dev/dev.ts @@ -1,6 +1,8 @@ import { type ChildProcess } from 'child_process'; import * as path from 'path'; +import { processTreeSpawnOptions, signalProcessTree, stopProcessTree } from '@frontmcp/utils'; + import { resolveConfig, type ResolvedFrontMcpConfig } from '../../config'; import { serverRuntimeEnv } from '../../config/deployment-env'; import { absolutizePathOptions, enterConfigRoot } from '../../config/project-root'; @@ -9,7 +11,6 @@ import { type ParsedArgs } from '../../core/args'; import { c } from '../../core/colors'; import { loadDevEnv } from '../../shared/env'; import { resolveEntry } from '../../shared/fs'; -import { processTreeSpawnOptions, signalProcessTree, stopProcessTree } from '../../shared/process-tree'; import { projectToolCommand, spawnTool, type ToolCommand } from '../../shared/tool-command'; import { findNextFreePort, isPortFree, lookupPortOwner } from './port'; diff --git a/libs/sdk/src/common/types/options/lifecycle/schema.ts b/libs/sdk/src/common/types/options/lifecycle/schema.ts index d98c1d823..2b73e722c 100644 --- a/libs/sdk/src/common/types/options/lifecycle/schema.ts +++ b/libs/sdk/src/common/types/options/lifecycle/schema.ts @@ -8,6 +8,12 @@ export interface LifecycleOptionsInterface { * @default 3000 */ shutdownHookTimeoutMs?: number; + + /** + * Shut down gracefully and exit when the parent process exits or, under stdio, stdin ends. + * @default true for `runStdio()`, false for `bootstrap()` and `runUnixSocket()` + */ + exitWhenOrphaned?: boolean; } export const lifecycleOptionsSchema = z @@ -17,6 +23,7 @@ export const lifecycleOptionsSchema = z .int('lifecycle.shutdownHookTimeoutMs must be a whole number of milliseconds') .positive('lifecycle.shutdownHookTimeoutMs must be greater than 0') .optional(), + exitWhenOrphaned: z.boolean({ message: 'lifecycle.exitWhenOrphaned must be true or false' }).optional(), } satisfies RawZodShape) .strict(); diff --git a/libs/sdk/src/front-mcp/__tests__/exit-when-orphaned.spec.ts b/libs/sdk/src/front-mcp/__tests__/exit-when-orphaned.spec.ts new file mode 100644 index 000000000..f05cfbed6 --- /dev/null +++ b/libs/sdk/src/front-mcp/__tests__/exit-when-orphaned.spec.ts @@ -0,0 +1,200 @@ +/** + * `lifecycle.exitWhenOrphaned`: the shutdown helper watches the parent (and stdin, when given); `runStdio()` watches + * by default and can opt out, the HTTP and Unix socket servers watch only when asked. + */ +import 'reflect-metadata'; + +import { EventEmitter } from 'node:events'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { randomUUID, type ProcessWatcher, type WatchParentOptions } from '@frontmcp/utils'; + +import { App, frontMcpMetadataSchema, LogLevel, Tool, ToolContext } from '../../common'; +import { FrontMcpInstance } from '../front-mcp'; +import { exitOnShutdownSignals } from '../shutdown-signals'; + +const mockWatchers: Array<{ options: WatchParentOptions; close: jest.Mock }> = []; + +jest.mock('@frontmcp/utils', () => ({ + ...jest.requireActual('@frontmcp/utils'), + watchParent: (options: WatchParentOptions): ProcessWatcher => { + const close = jest.fn(); + mockWatchers.push({ options, close }); + return { close }; + }, +})); + +jest.mock('@frontmcp/protocol', () => { + const actual = jest.requireActual('@frontmcp/protocol'); + class IdleMcpServer { + setRequestHandler() { + return undefined; + } + setNotificationHandler() { + return undefined; + } + async connect() { + return undefined; + } + async close() { + return undefined; + } + } + class IdleStdioServerTransport { + async close() { + return undefined; + } + } + return { ...actual, McpServer: IdleMcpServer, StdioServerTransport: IdleStdioServerTransport }; +}); + +@Tool({ name: 'ping', inputSchema: {} }) +class PingTool extends ToolContext { + async execute() { + return { pong: true }; + } +} + +@App({ id: 'demo', name: 'Demo', tools: [PingTool] }) +class DemoApp {} + +class FakeProcess extends EventEmitter { + readonly exit = jest.fn(); +} + +function flush(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +const signalsBefore = { SIGINT: process.listeners('SIGINT'), SIGTERM: process.listeners('SIGTERM') }; + +function removeAddedSignalListeners(): void { + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + for (const listener of process.listeners(signal)) { + if (!signalsBefore[signal].includes(listener)) process.removeListener(signal, listener); + } + } +} + +beforeEach(() => { + mockWatchers.length = 0; +}); + +describe('exitOnShutdownSignals({ whenOrphaned })', () => { + it.each(['stdin-ended', 'parent-exited'] as const)( + 'shuts down once and exits 0 when the watcher reports %s', + async (reason) => { + const target = new FakeProcess(); + const stdin = new PassThrough(); + const shutdown = jest.fn().mockResolvedValue(undefined); + const logger = { error: jest.fn(), info: jest.fn() }; + exitOnShutdownSignals(shutdown, { target, logger, whenOrphaned: { stdin } }); + expect(mockWatchers[0].options.stdin).toBe(stdin); + + mockWatchers[0].options.onGone(reason); + target.emit('SIGTERM'); + await flush(); + + expect(shutdown).toHaveBeenCalledTimes(1); + expect(target.exit).toHaveBeenCalledWith(0); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('shutting down')); + }, + ); + + it('watches nothing unless asked, and stops watching when removed', () => { + const target = new FakeProcess(); + exitOnShutdownSignals(jest.fn(), { target }); + expect(mockWatchers).toHaveLength(0); + + const remove = exitOnShutdownSignals(jest.fn(), { target, whenOrphaned: {} }); + remove(); + + expect(mockWatchers[0].options.stdin).toBeUndefined(); + expect(mockWatchers[0].close).toHaveBeenCalledTimes(1); + }); +}); + +describe('lifecycle.exitWhenOrphaned', () => { + it('accepts a boolean and refuses anything else', () => { + expect( + frontMcpMetadataSchema.parse({ + info: { name: 'x', version: '1' }, + apps: [DemoApp], + lifecycle: { exitWhenOrphaned: false }, + }).lifecycle, + ).toEqual({ exitWhenOrphaned: false }); + expect(() => + frontMcpMetadataSchema.parse({ + info: { name: 'x', version: '1' }, + apps: [DemoApp], + lifecycle: { exitWhenOrphaned: 'yes' }, + }), + ).toThrow('lifecycle.exitWhenOrphaned must be true or false'); + }); + + it('turns the stdio watch off when false', async () => { + const savedConsole = { ...console }; + const savedStdioEnv = process.env['FRONTMCP_STDIO']; + const savedLogsMax = process.env['FRONTMCP_LOGS_MAX']; + process.env['FRONTMCP_LOGS_MAX'] = '0'; + const exit = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + try { + await FrontMcpInstance.runStdio({ + info: { name: 'stdio-attached', version: '1.0.0' }, + apps: [DemoApp], + logging: { level: LogLevel.Off, enableConsole: false }, + lifecycle: { exitWhenOrphaned: false }, + }); + expect(mockWatchers).toHaveLength(0); + } finally { + exit.mockRestore(); + Object.assign(console, savedConsole); + if (savedStdioEnv === undefined) delete process.env['FRONTMCP_STDIO']; + else process.env['FRONTMCP_STDIO'] = savedStdioEnv; + if (savedLogsMax === undefined) delete process.env['FRONTMCP_LOGS_MAX']; + else process.env['FRONTMCP_LOGS_MAX'] = savedLogsMax; + removeAddedSignalListeners(); + } + }); + + it.each([ + [undefined, 0], + [true, 1], + ])('watches the parent of a Unix socket server only when asked (%p)', async (exitWhenOrphaned, watchers) => { + const handle = await FrontMcpInstance.runUnixSocket({ + info: { name: 'socket-orphan', version: '1.0.0' }, + apps: [DemoApp], + logging: { level: LogLevel.Off }, + socketPath: path.join(os.tmpdir(), `fmcp-${randomUUID().slice(0, 8)}.sock`), + ...(exitWhenOrphaned === undefined ? {} : { lifecycle: { exitWhenOrphaned } }), + }); + try { + expect(mockWatchers).toHaveLength(watchers); + if (watchers) expect(mockWatchers[0].options.stdin).toBeUndefined(); + } finally { + await handle.close(); + } + }); + + it('watches the parent of the HTTP server when asked', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => undefined); + let instance: FrontMcpInstance | undefined; + try { + instance = await FrontMcpInstance.bootstrap({ + info: { name: 'http-orphan', version: '1.0.0' }, + apps: [DemoApp], + logging: { level: LogLevel.Off }, + http: { port: 0 }, + lifecycle: { exitWhenOrphaned: true }, + }); + expect(mockWatchers).toHaveLength(1); + expect(mockWatchers[0].options.stdin).toBeUndefined(); + } finally { + await instance?.shutdown(); + removeAddedSignalListeners(); + log.mockRestore(); + } + }); +}); diff --git a/libs/sdk/src/front-mcp/__tests__/run-stdio-orphaned.spec.ts b/libs/sdk/src/front-mcp/__tests__/run-stdio-orphaned.spec.ts new file mode 100644 index 000000000..f56395392 --- /dev/null +++ b/libs/sdk/src/front-mcp/__tests__/run-stdio-orphaned.spec.ts @@ -0,0 +1,101 @@ +/** + * A stdio server whose client goes away (stdin ends, or the parent process dies) shuts down through the same + * path as SIGTERM, providers' `onShutdown()` included, and exits 0, instead of staying alive as an orphan. + */ +import 'reflect-metadata'; + +import type { ProcessWatcher, WatchParentOptions } from '@frontmcp/utils'; + +import { App, LogLevel, Provider, type OnShutdown } from '../../common'; +import { FrontMcpInstance } from '../front-mcp'; + +const mockWatchers: WatchParentOptions[] = []; + +jest.mock('@frontmcp/utils', () => ({ + ...jest.requireActual('@frontmcp/utils'), + watchParent: (options: WatchParentOptions): ProcessWatcher => { + mockWatchers.push(options); + return { close: () => undefined }; + }, +})); + +jest.mock('@frontmcp/protocol', () => { + const actual = jest.requireActual('@frontmcp/protocol'); + class IdleMcpServer { + setRequestHandler() { + return undefined; + } + setNotificationHandler() { + return undefined; + } + async connect() { + return undefined; + } + async close() { + return undefined; + } + } + class IdleStdioServerTransport { + async close() { + return undefined; + } + } + return { ...actual, McpServer: IdleMcpServer, StdioServerTransport: IdleStdioServerTransport }; +}); + +const phases: string[] = []; + +@Provider({ name: 'journal' }) +class Journal implements OnShutdown { + async onShutdown() { + phases.push('shutdown'); + } +} + +@App({ id: 'orphan', name: 'Orphan', providers: [Journal] }) +class OrphanApp {} + +describe('runStdio when its client goes away', () => { + const savedConsole = { ...console }; + const savedStdioEnv = process.env['FRONTMCP_STDIO']; + const savedLogsMax = process.env['FRONTMCP_LOGS_MAX']; + const signalsBefore = { SIGINT: process.listeners('SIGINT'), SIGTERM: process.listeners('SIGTERM') }; + + afterAll(() => { + Object.assign(console, savedConsole); + if (savedStdioEnv === undefined) delete process.env['FRONTMCP_STDIO']; + else process.env['FRONTMCP_STDIO'] = savedStdioEnv; + if (savedLogsMax === undefined) delete process.env['FRONTMCP_LOGS_MAX']; + else process.env['FRONTMCP_LOGS_MAX'] = savedLogsMax; + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + for (const listener of process.listeners(signal)) { + if (!signalsBefore[signal].includes(listener)) process.removeListener(signal, listener); + } + } + }); + + it('watches stdin and the parent by default, and on stdin end runs the shutdown phase and exits 0', async () => { + process.env['FRONTMCP_LOGS_MAX'] = '0'; + const exit = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + try { + await FrontMcpInstance.runStdio({ + info: { name: 'stdio-orphan', version: '1.0.0' }, + apps: [OrphanApp], + logging: { level: LogLevel.Off, enableConsole: false }, + }); + expect(mockWatchers).toHaveLength(1); + expect(mockWatchers[0].stdin).toBe(process.stdin); + + mockWatchers[0].onGone('stdin-ended'); + const deadline = Date.now() + 5_000; + while (exit.mock.calls.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expect(phases).toEqual(['shutdown']); + expect(exit).toHaveBeenCalledWith(0); + } finally { + exit.mockRestore(); + } + }); +}); diff --git a/libs/sdk/src/front-mcp/front-mcp.ts b/libs/sdk/src/front-mcp/front-mcp.ts index 4d549fbd0..270f8287e 100644 --- a/libs/sdk/src/front-mcp/front-mcp.ts +++ b/libs/sdk/src/front-mcp/front-mcp.ts @@ -365,7 +365,10 @@ export class FrontMcpInstance implements FrontMcpInterface { await frontMcp.ready; await frontMcp.startOrRelease(); - exitOnShutdownSignals(() => frontMcp.shutdown(), { logger: frontMcp.log }); + exitOnShutdownSignals(() => frontMcp.shutdown(), { + logger: frontMcp.log, + whenOrphaned: parsedConfig.lifecycle?.exitWhenOrphaned ? {} : undefined, + }); frontMcp.log?.info('FrontMCP bootstrap complete'); return frontMcp; } @@ -688,7 +691,10 @@ export class FrontMcpInstance implements FrontMcpInterface { // The graceful shutdown of the TCP server (#712). Closing the server removes its socket file, and the next // start clears one a crash left behind, so nothing here unlinks a path a replacement may already hold. const shutdown = () => frontMcp.shutdown(); - const removeSignalHandlers = exitOnShutdownSignals(shutdown, { logger: frontMcp.log }); + const removeSignalHandlers = exitOnShutdownSignals(shutdown, { + logger: frontMcp.log, + whenOrphaned: parsedConfig.lifecycle?.exitWhenOrphaned ? {} : undefined, + }); const close = async () => { removeSignalHandlers(); @@ -912,7 +918,8 @@ export class FrontMcpInstance implements FrontMcpInterface { // giving us the real stdin/stdout. const { IpcServerTransport, isIpcStdioRequested } = await import('../transport/ipc-server.transport.js'); const { StdioRevisionRouter } = await import('../transport/stdio-revision-router.js'); - const stdioTransport = isIpcStdioRequested() ? new IpcServerTransport() : new StdioServerTransport(); + const overIpc = isIpcStdioRequested(); + const stdioTransport = overIpc ? new IpcServerTransport() : new StdioServerTransport(); // The router serves MCP 2026-07-28 messages itself and passes the rest to the session-era server. const transport = new StdioRevisionRouter(stdioTransport, { scope, principal: sessionId }); startupCleanups.push(() => transport.close()); @@ -932,7 +939,7 @@ export class FrontMcpInstance implements FrontMcpInterface { await frontMcp.emitProvidersReady(); // The graceful shutdown of the HTTP and socket servers (#712): every scope is shut down, then - // disposed, so `onDispose` callbacks run under stdio too. + // disposed, so `onDispose` callbacks run under stdio too. It also runs when the client goes away. exitOnShutdownSignals( async () => { scope.notifications.unregisterServer(sessionId); @@ -942,7 +949,14 @@ export class FrontMcpInstance implements FrontMcpInterface { await mcpServer.close(); } }, - { logger: frontMcp.log, deadlineMs: STDIO_SHUTDOWN_DEADLINE_MS }, + { + logger: frontMcp.log, + deadlineMs: STDIO_SHUTDOWN_DEADLINE_MS, + whenOrphaned: + parsedConfig.lifecycle?.exitWhenOrphaned === false + ? undefined + : { stdin: overIpc ? undefined : process.stdin }, + }, ); } finally { if (!startupSucceeded) { diff --git a/libs/sdk/src/front-mcp/shutdown-signals.ts b/libs/sdk/src/front-mcp/shutdown-signals.ts index 3f724032c..9faadf875 100644 --- a/libs/sdk/src/front-mcp/shutdown-signals.ts +++ b/libs/sdk/src/front-mcp/shutdown-signals.ts @@ -1,5 +1,12 @@ +import { watchParent, type ParentGoneReason } from '@frontmcp/utils'; + const SHUTDOWN_SIGNALS = ['SIGTERM', 'SIGINT'] as const; +const ORPHANED_MESSAGES: Record = { + 'parent-exited': 'The parent process exited; shutting down', + 'stdin-ended': 'stdin ended; shutting down', +}; + export const DEFAULT_SHUTDOWN_DEADLINE_MS = 10_000; /** A stdio client waits on the process to exit, so stdio keeps its shorter deadline. */ @@ -16,15 +23,18 @@ export interface ShutdownSignalOptions { target?: SignalTarget; /** How long the shutdown may take before the process exits with code 1. */ deadlineMs?: number; - logger?: { error(message: string, error?: unknown): void }; + logger?: { error(message: string, error?: unknown): void; info?(message: string): void }; + /** Also shut down when the parent process exits or, when given, `stdin` ends (`lifecycle.exitWhenOrphaned`). */ + whenOrphaned?: { stdin?: NodeJS.ReadableStream }; } /** * On SIGTERM or SIGINT, run `shutdown` once and exit: code 0 when it completes, 1 when it fails * or outlasts the deadline (#712). The deadline timer is ref'd, so the process stays up while - * the shutdown runs even after the server stops listening. + * the shutdown runs even after the server stops listening. With `whenOrphaned`, the parent + * exiting (or stdin ending) does the same. * - * @returns A function that removes the signal listeners + * @returns A function that removes the signal listeners and stops watching the parent */ export function exitOnShutdownSignals(shutdown: () => Promise, options: ShutdownSignalOptions = {}): () => void { const target = options.target ?? process; @@ -49,7 +59,17 @@ export function exitOnShutdownSignals(shutdown: () => Promise, options: Sh }; for (const signal of SHUTDOWN_SIGNALS) target.on(signal, onSignal); + const watcher = + options.whenOrphaned && + watchParent({ + stdin: options.whenOrphaned.stdin, + onGone: (reason) => { + options.logger?.info?.(ORPHANED_MESSAGES[reason]); + onSignal(); + }, + }); return () => { + watcher?.close(); for (const signal of SHUTDOWN_SIGNALS) target.removeListener(signal, onSignal); }; } diff --git a/libs/sdk/src/task/helpers/__tests__/cli-task-runner.spec.ts b/libs/sdk/src/task/helpers/__tests__/cli-task-runner.spec.ts index 2ef31fac2..7314367f6 100644 --- a/libs/sdk/src/task/helpers/__tests__/cli-task-runner.spec.ts +++ b/libs/sdk/src/task/helpers/__tests__/cli-task-runner.spec.ts @@ -1,7 +1,11 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { getHostname } from '@frontmcp/utils'; import type { TaskRecord } from '../../task.types'; -import { CliTaskRunner } from '../cli-task-runner'; +import { CliTaskRunner, RUN_TASK_SUBCOMMAND, TASK_WORKER_MARKER } from '../cli-task-runner'; const WORKER_PID = 4242; @@ -75,4 +79,35 @@ describe('CliTaskRunner.run', () => { }), ); }); + + it('spawns the worker as owned by this process, so an orphaned worker can be reaped', async () => { + const directory = mkdtempSync(join(tmpdir(), 'cli-task-runner-')); + const seen = join(directory, 'seen.json'); + const script = `require('fs').writeFileSync(${JSON.stringify(seen)}, JSON.stringify({ argv: process.argv.slice(1), owner: process.env.FRONTMCP_OWNER, taskId: process.env.FRONTMCP_RUN_TASK_ID }))`; + const runner = new CliTaskRunner({ + store: { update: jest.fn(async () => null) } as never, + command: { exe: process.execPath, args: ['-e', script] }, + }); + try { + await runner.run(runningTask(undefined), { cleanedRequestParams: {}, ctx: {} }); + const deadline = Date.now() + 10_000; + let written: string | undefined; + while (!written && Date.now() < deadline) { + try { + written = readFileSync(seen, 'utf8'); + } catch { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + + expect(TASK_WORKER_MARKER).toBe('frontmcp-task'); + expect(JSON.parse(written ?? '{}')).toEqual({ + argv: [RUN_TASK_SUBCOMMAND, 'task-cancel', `--frontmcp-owner=frontmcp-task:${process.pid}`], + owner: `frontmcp-task:${process.pid}`, + taskId: 'task-cancel', + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); }); diff --git a/libs/sdk/src/task/helpers/cli-task-runner.ts b/libs/sdk/src/task/helpers/cli-task-runner.ts index ad283175b..526385f09 100644 --- a/libs/sdk/src/task/helpers/cli-task-runner.ts +++ b/libs/sdk/src/task/helpers/cli-task-runner.ts @@ -10,15 +10,18 @@ * Cancellation uses `process.kill(pid, SIGTERM)`. The worker is expected to * observe the signal via the AbortSignal surfaced on `ToolContext.signal`. * + * Workers are spawned owned under {@link TASK_WORKER_MARKER}, so one whose + * server is gone can be stopped with `reapOrphans(TASK_WORKER_MARKER)`. + * * @module task/helpers/cli-task-runner */ -// `spawn` is resolved lazily through `@frontmcp/utils` so this module can be +// `spawnOwned` loads `child_process` lazily inside `@frontmcp/utils` so this module can be // imported by the main SDK barrel in V8-isolate builds (Cloudflare Workers, // Deno Deploy) without their bundlers eagerly pulling in `node:child_process`. -// The runner can only execute on Node runtimes — calling `spawn` outside Node +// The runner can only execute on Node runtimes — spawning outside Node // throws via `assertNode` inside utils. -import { getHostname, getSpawnFn } from '@frontmcp/utils'; +import { getHostname, spawnOwned } from '@frontmcp/utils'; import type { FrontMcpLogger } from '../../common'; import type { TaskStore } from '../store'; @@ -40,6 +43,8 @@ export interface CliTaskRunnerDeps { const RUN_TASK_SUBCOMMAND = '__run-task'; const RUN_TASK_ENV_VAR = 'FRONTMCP_RUN_TASK_ID'; +/** The `spawnOwned` marker of task workers, for `reapOrphans(TASK_WORKER_MARKER)`. */ +const TASK_WORKER_MARKER = 'frontmcp-task'; export class CliTaskRunner implements TaskRunner { readonly kind = 'cli' as const; @@ -56,7 +61,8 @@ export class CliTaskRunner implements TaskRunner { args: fullArgs, }); - const child = getSpawnFn()(exe, fullArgs, { + const { child } = spawnOwned(exe, fullArgs, { + marker: TASK_WORKER_MARKER, detached: true, stdio: 'ignore', env: { ...process.env, [RUN_TASK_ENV_VAR]: record.taskId }, @@ -177,4 +183,4 @@ export class CliTaskRunner implements TaskRunner { } } -export { RUN_TASK_SUBCOMMAND, RUN_TASK_ENV_VAR }; +export { RUN_TASK_SUBCOMMAND, RUN_TASK_ENV_VAR, TASK_WORKER_MARKER }; diff --git a/libs/sdk/src/task/index.ts b/libs/sdk/src/task/index.ts index 31e5eb2b9..a54ab3165 100644 --- a/libs/sdk/src/task/index.ts +++ b/libs/sdk/src/task/index.ts @@ -21,6 +21,7 @@ export { type CliTaskRunnerCommand, RUN_TASK_SUBCOMMAND, RUN_TASK_ENV_VAR, + TASK_WORKER_MARKER, } from './helpers/cli-task-runner'; export { isAlive } from './helpers/process-liveness'; // NOTE: `executeTaskWorker` intentionally NOT re-exported here — it imports diff --git a/libs/skills/catalog/frontmcp-deployment/references/mcp-client-integration.md b/libs/skills/catalog/frontmcp-deployment/references/mcp-client-integration.md index ecf7c046b..48f360a1b 100644 --- a/libs/skills/catalog/frontmcp-deployment/references/mcp-client-integration.md +++ b/libs/skills/catalog/frontmcp-deployment/references/mcp-client-integration.md @@ -132,6 +132,10 @@ The most common transport. The MCP client spawns your server as a child process see `protocol-versions` - **Stdio binds no TCP port** — the HTTP server is disabled, so multiple stdio instances of the same server run without an `EADDRINUSE` conflict +- **No orphans** — when stdin ends (the client closed it or exited) or the parent + process exits, the server runs its graceful shutdown (providers' `onShutdown()` + included) and exits 0, as on `SIGTERM`. The parent is checked once a second. + `lifecycle: { exitWhenOrphaned: false }` turns this off ### npx (published npm package) diff --git a/libs/skills/catalog/frontmcp-development/references/create-provider.md b/libs/skills/catalog/frontmcp-development/references/create-provider.md index d40711271..2c6d7a584 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-provider.md +++ b/libs/skills/catalog/frontmcp-development/references/create-provider.md @@ -319,11 +319,11 @@ class CacheProvider extends Map { A GLOBAL provider (a `@Provider` class, a `useClass` or `useValue` object, or the value an `AsyncProvider` factory returns) can implement any of three hooks. CONTEXT-scoped providers get none: a request builds them. -| Hook | Interface | Runs | When it fails | -| --------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `onBootstrap()` | `OnBootstrap` | Once the provider is built: after its dependencies' `onBootstrap()`, before any provider that depends on it is built, and before tools, resources and prompts are listed | Startup fails with `ProviderBootstrapError` (`PROVIDER_BOOTSTRAP_FAILED`), naming the provider | -| `onReady()` | `OnReady` | Once the server serves: HTTP or Unix socket listening, stdio connected, before `createDirect()` or `connect()` returns, a serverless or fetch handler built (on the edge, by its first request). In dependency order, once | Logged as an error; the server keeps serving and the other `onReady()` hooks still run | -| `onShutdown()` | `OnShutdown` | At shutdown (SIGTERM / SIGINT, `shutdown()`, `dispose()`, `close()`), over HTTP or a socket after the requests in flight finish. In reverse order: an app's providers before the server's, a provider before its dependencies | Logged as a warning; after `lifecycle.shutdownHookTimeoutMs` (default 3000 ms) shutdown moves on | +| Hook | Interface | Runs | When it fails | +| --------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `onBootstrap()` | `OnBootstrap` | Once the provider is built: after its dependencies' `onBootstrap()`, before any provider that depends on it is built, and before tools, resources and prompts are listed | Startup fails with `ProviderBootstrapError` (`PROVIDER_BOOTSTRAP_FAILED`), naming the provider | +| `onReady()` | `OnReady` | Once the server serves: HTTP or Unix socket listening, stdio connected, before `createDirect()` or `connect()` returns, a serverless or fetch handler built (on the edge, by its first request). In dependency order, once | Logged as an error; the server keeps serving and the other `onReady()` hooks still run | +| `onShutdown()` | `OnShutdown` | At shutdown (SIGTERM / SIGINT, `shutdown()`, `dispose()`, `close()`, a stdio client closing stdin or exiting), over HTTP or a socket after the requests in flight finish. In reverse order: an app's providers before the server's, a provider before its dependencies | Logged as a warning; after `lifecycle.shutdownHookTimeoutMs` (default 3000 ms) shutdown moves on | ```typescript import { FrontMcp, Provider, type OnBootstrap, type OnReady, type OnShutdown } from '@frontmcp/sdk'; @@ -363,6 +363,8 @@ class SearchIndex implements OnBootstrap, OnReady, OnShutdown { class Server {} ``` +`lifecycle.exitWhenOrphaned` decides whether the server also shuts down this way when it is orphaned: its parent process exits or, under stdio, stdin ends. It is on by default for `runStdio()`, so a stdio server whose client dies runs `onShutdown()` and exits 0 instead of running on; `false` turns it off. It is off by default for the HTTP server and `runUnixSocket()`, whose daemons usually outlive the process that started them; `true` turns it on. + | Setup need | Pattern | Cleanup | | -------------------------------------------------- | --------------------------------------------------------------------- | -------------- | | Sync: read env, build a Map | `@Provider({ name })` + constructor (throw on missing config) | None | diff --git a/libs/skills/catalog/frontmcp-production-readiness/examples/production-cli-binary/stdio-transport-error-handling.md b/libs/skills/catalog/frontmcp-production-readiness/examples/production-cli-binary/stdio-transport-error-handling.md index 54fed1ce6..a11e2a215 100644 --- a/libs/skills/catalog/frontmcp-production-readiness/examples/production-cli-binary/stdio-transport-error-handling.md +++ b/libs/skills/catalog/frontmcp-production-readiness/examples/production-cli-binary/stdio-transport-error-handling.md @@ -7,7 +7,7 @@ tags: [production, json-rpc, cli, transport, binary, stdio] features: - 'Exit code conventions: 0 (success), 1 (user error), 2 (internal error)' - 'Using stderr for all logging since stdout is the MCP JSON-RPC channel' - - 'Handling EOF on stdin and broken pipe on stdout gracefully' + - 'Relying on the built-in shutdown when stdin ends or the parent exits, and handling a broken pipe on stdout' - 'Showing helpful error messages for unknown flags instead of stack traces' - 'Validating file paths to prevent writes to unexpected locations' --- @@ -47,11 +47,8 @@ async function main() { // stderr for all logging — stdout is the MCP JSON-RPC channel console.error('[my-mcp-cli] Starting MCP server via stdio...'); - // Handle EOF on stdin gracefully - process.stdin.on('end', () => { - console.error('[my-mcp-cli] stdin closed. Exiting.'); - process.exit(0); - }); + // No stdin 'end' handler: when stdin ends or the parent process exits, the stdio server runs its + // graceful shutdown (providers' onShutdown() included) and exits 0. `process.exit()` here would skip it. // Handle broken pipe on stdout gracefully process.stdout.on('error', (err: NodeJS.ErrnoException) => { @@ -122,7 +119,7 @@ export class SafeFileTool extends ToolContext { - Exit code conventions: 0 (success), 1 (user error), 2 (internal error) - Using stderr for all logging since stdout is the MCP JSON-RPC channel -- Handling EOF on stdin and broken pipe on stdout gracefully +- Relying on the built-in shutdown when stdin ends or the parent exits, and handling a broken pipe on stdout - Showing helpful error messages for unknown flags instead of stack traces - Validating file paths to prevent writes to unexpected locations diff --git a/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-binary.md b/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-binary.md index a27df0c69..465faf21b 100644 --- a/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-binary.md +++ b/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-binary.md @@ -28,7 +28,7 @@ Checklist for publishing FrontMCP as a one-shot CLI binary — runs a command, p - [ ] MCP stdio transport works: reads JSON-RPC from stdin, writes to stdout - [ ] stderr is used for logging (not stdout — that's the MCP channel) -- [ ] Handles EOF on stdin gracefully (clean exit) +- [ ] EOF on stdin, or the parent process exiting, shuts the server down gracefully and exits 0: built into `runStdio()` (`lifecycle.exitWhenOrphaned`, on by default); no `process.stdin.on('end', () => process.exit())`, which would skip `onShutdown()` - [ ] Handles broken pipe on stdout gracefully (no crash) ## Exit Behavior diff --git a/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-daemon.md b/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-daemon.md index 8757a8c87..6c0c04408 100644 --- a/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-daemon.md +++ b/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-daemon.md @@ -16,6 +16,8 @@ Checklist for deploying FrontMCP as a long-running local MCP server managed by t - [ ] `frontmcp restart ` works without orphaned processes - [ ] `frontmcp status` shows the daemon as running - [ ] `frontmcp logs --follow` streams daemon output +- [ ] A daemon that must not outlive the process that started it sets `lifecycle: { exitWhenOrphaned: true }`: it then shuts down gracefully when its parent exits. Off by default for `runUnixSocket()` and the HTTP server, since a detached daemon outlives its launcher +- [ ] Background task workers (`tasks.runner: 'cli'`) carry the owner mark `TASK_WORKER_MARKER` (`@frontmcp/sdk`): `reapOrphans(TASK_WORKER_MARKER)` (`@frontmcp/utils`) stops the ones whose server is gone (POSIX `ps`; does nothing on Windows) ## Socket / Transport diff --git a/libs/skills/catalog/skills-manifest.json b/libs/skills/catalog/skills-manifest.json index ec0c72355..7e348682f 100644 --- a/libs/skills/catalog/skills-manifest.json +++ b/libs/skills/catalog/skills-manifest.json @@ -2977,7 +2977,7 @@ "features": [ "Exit code conventions: 0 (success), 1 (user error), 2 (internal error)", "Using stderr for all logging since stdout is the MCP JSON-RPC channel", - "Handling EOF on stdin and broken pipe on stdout gracefully", + "Relying on the built-in shutdown when stdin ends or the parent exits, and handling a broken pipe on stdout", "Showing helpful error messages for unknown flags instead of stack traces", "Validating file paths to prevent writes to unexpected locations" ] diff --git a/libs/utils/README.md b/libs/utils/README.md index 7538951b3..9f8d56110 100644 --- a/libs/utils/README.md +++ b/libs/utils/README.md @@ -37,25 +37,26 @@ await storage.get('ticket:T-1'); // 'open' ## What's in it -| Area | Exports | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Naming | `splitWords`, `toCase`, `shortHash`, `ensureMaxLen`, `idFromString` | -| URIs | `isValidMcpUri`, `extractUriScheme`, `parseUriTemplate`, `matchUriTemplate`, `expandUriTemplate`, `extractTemplateParams` (RFC 3986 URIs, RFC 6570 templates) | -| Paths | `trimSlashes`, `joinPath`, and cross-platform `pathJoin`, `pathResolve`, `basename`, `dirname`, `extname` | -| Content | `sanitizeToJson`, `inferMimeType`, `safeStringify` | -| HTTP | `validateBaseUrl`, `buildSetCookie`, `parseCookies`, `getCookie`, `isLocalhost`, `isSecureRequest` | -| Network | `classifyIpAddress`, `parseIpv4`, `expandIpv6`, `isRedirectResponse`, for SSRF (server-side request forgery) checks | -| Escaping | `escapeHtml`, `escapeHtmlAttr`, `escapeJsString`, `safeJsonForScript` | -| Crypto | `sha256`, `sha256Hex`, `hmacSha256`, `hkdfSha256`, `encryptAesGcm`, `decryptAesGcm`, `randomBytes`, `randomUUID`, `timingSafeEqual`, `base64urlEncode`, `base64urlDecode` | -| PKCE | `generateCodeVerifier`, `generateCodeChallenge`, `verifyCodeChallenge`, `generatePkcePair` (RFC 7636) | -| Encryption, keys | `encryptValue`, `decryptValue`, `signData`, `verifyData`, `KeyPersistence`, `createKeyPersistence` | -| Storage | `createStorage`, `createMemoryStorage`, `TypedStorage`, `EncryptedTypedStorage`, and adapters for memory, Redis, Vercel KV, Upstash and the file system | -| File system | `readFile`, `writeFile`, `mkdir`, `stat`, `fileExists`, `readJSON`, `writeJSON`, `ensureDir`, `isDirEmpty`, `runCmd` and more. Node.js only; `fs` loads on first use. | -| Environment | `getEnv`, `isProduction`, `isDevelopment`, `isEdgeRuntime`, `isServerless`, `getRuntimeContext`, `isNode`, `isBrowser` | -| Context, events | `AsyncLocalStorage`, `EventEmitter`, both with browser builds | -| Safe regex | `isPatternSafe`, `createSafeRegExp`, `safeTest`, `safeMatch`, `safeReplace`, against ReDoS (regular expression denial of service) | -| Model tool calls | `processPlatformToolCalls`, which passes OpenAI, Claude or Vercel AI tool calls to your `callTool` function and returns the results in that platform's format | -| Machine ID | `getMachineId`, `setMachineIdOverride`, `getHostname` | +| Area | Exports | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Naming | `splitWords`, `toCase`, `shortHash`, `ensureMaxLen`, `idFromString` | +| URIs | `isValidMcpUri`, `extractUriScheme`, `parseUriTemplate`, `matchUriTemplate`, `expandUriTemplate`, `extractTemplateParams` (RFC 3986 URIs, RFC 6570 templates) | +| Paths | `trimSlashes`, `joinPath`, and cross-platform `pathJoin`, `pathResolve`, `basename`, `dirname`, `extname` | +| Content | `sanitizeToJson`, `inferMimeType`, `safeStringify` | +| HTTP | `validateBaseUrl`, `buildSetCookie`, `parseCookies`, `getCookie`, `isLocalhost`, `isSecureRequest` | +| Network | `classifyIpAddress`, `parseIpv4`, `expandIpv6`, `isRedirectResponse`, for SSRF (server-side request forgery) checks | +| Escaping | `escapeHtml`, `escapeHtmlAttr`, `escapeJsString`, `safeJsonForScript` | +| Crypto | `sha256`, `sha256Hex`, `hmacSha256`, `hkdfSha256`, `encryptAesGcm`, `decryptAesGcm`, `randomBytes`, `randomUUID`, `timingSafeEqual`, `base64urlEncode`, `base64urlDecode` | +| PKCE | `generateCodeVerifier`, `generateCodeChallenge`, `verifyCodeChallenge`, `generatePkcePair` (RFC 7636) | +| Encryption, keys | `encryptValue`, `decryptValue`, `signData`, `verifyData`, `KeyPersistence`, `createKeyPersistence` | +| Storage | `createStorage`, `createMemoryStorage`, `TypedStorage`, `EncryptedTypedStorage`, and adapters for memory, Redis, Vercel KV, Upstash and the file system | +| File system | `readFile`, `writeFile`, `mkdir`, `stat`, `fileExists`, `readJSON`, `writeJSON`, `ensureDir`, `isDirEmpty`, `runCmd` and more. Node.js only; `fs` loads on first use. | +| Processes | `spawnOwned`, `reapOrphans` (POSIX `ps`; nothing on Windows), `watchParent`, `watchPath`, `stopProcessTree`, `signalProcessTree`. Node.js only; `child_process` loads on first use. | +| Environment | `getEnv`, `isProduction`, `isDevelopment`, `isEdgeRuntime`, `isServerless`, `getRuntimeContext`, `isNode`, `isBrowser` | +| Context, events | `AsyncLocalStorage`, `EventEmitter`, both with browser builds | +| Safe regex | `isPatternSafe`, `createSafeRegExp`, `safeTest`, `safeMatch`, `safeReplace`, against ReDoS (regular expression denial of service) | +| Model tool calls | `processPlatformToolCalls`, which passes OpenAI, Claude or Vercel AI tool calls to your `callTool` function and returns the results in that platform's format | +| Machine ID | `getMachineId`, `setMachineIdOverride`, `getHostname` | ## Related packages diff --git a/libs/utils/src/index.ts b/libs/utils/src/index.ts index 03f50fa50..9c8c8cf13 100644 --- a/libs/utils/src/index.ts +++ b/libs/utils/src/index.ts @@ -82,6 +82,29 @@ export { } from './fs'; export type { FileWatcherHandle } from './fs'; +// Process supervision (Node.js only): process trees, owned children, orphan reaping, parent and path watchers +export { + processTreeSpawnOptions, + signalProcessTree, + isProcessTreeAlive, + stopProcessTree, + spawnOwned, + reapOrphans, + OwnedProcessError, + watchParent, + watchPath, +} from './process'; +export type { + ProcessTreeDeps, + SpawnOwnedOptions, + OwnedProcess, + ReapOrphansOptions, + ParentGoneReason, + ProcessWatcher, + WatchParentOptions, + WatchPathOptions, +} from './process'; + // Escape utilities (HTML, JS, XSS prevention) export { escapeHtml, escapeHtmlAttr, escapeJsString, escapeScriptClose, safeJsonForScript } from './escape'; diff --git a/libs/utils/src/process/__tests__/owned-process.spec.ts b/libs/utils/src/process/__tests__/owned-process.spec.ts new file mode 100644 index 000000000..2bc03f97a --- /dev/null +++ b/libs/utils/src/process/__tests__/owned-process.spec.ts @@ -0,0 +1,215 @@ +import { spawn } from 'child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { OwnedProcessError, parseProcessList, reapOrphans, spawnOwned, type ProcessInfo } from '../owned-process'; + +function esrch(): Error { + return Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' }); +} + +function running(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitUntil(condition: () => boolean, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error('condition not met in time'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +describe('spawnOwned', () => { + let directory: string; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'spawn-owned-')); + }); + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }); + }); + + it('passes the marker and this process as owner in the environment and as the last argument', async () => { + const out = join(directory, 'seen.json'); + const script = `require('fs').writeFileSync(${JSON.stringify(out)}, JSON.stringify({ argv: process.argv.slice(1), owner: process.env.FRONTMCP_OWNER, kept: process.env.KEPT }))`; + const owned = spawnOwned(process.execPath, ['-e', script, 'first'], { + marker: 'unit-marker', + env: { ...process.env, KEPT: 'yes' }, + stdio: 'ignore', + }); + await new Promise((resolve) => owned.child.once('exit', resolve)); + + expect(JSON.parse(readFileSync(out, 'utf8'))).toEqual({ + argv: ['first', `--frontmcp-owner=unit-marker:${process.pid}`], + owner: `unit-marker:${process.pid}`, + kept: 'yes', + }); + }); + + it("stops the child's tree", async () => { + const owned = spawnOwned(process.execPath, ['-e', 'setInterval(() => {}, 1000)', '--'], { + marker: 'unit-stop', + detached: process.platform !== 'win32', + stdio: 'ignore', + }); + const pid = owned.child.pid; + if (pid === undefined) throw new Error('no pid'); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(owned.child.exitCode).toBeNull(); + + await expect(owned.stop('SIGTERM', 2000)).resolves.toBe(true); + expect(running(pid)).toBe(false); + }); + + it.each(['', 'has space', 'a:b', '-leading', 'x'.repeat(65)])('refuses the marker %p', (marker) => { + expect(() => spawnOwned(process.execPath, [], { marker })).toThrow(OwnedProcessError); + }); +}); + +describe('parseProcessList', () => { + it('reads pid, parent pid, group and command, skipping lines it cannot read', () => { + const output = ' 1 0 1 /sbin/launchd\n 812 1 812 node worker.js --frontmcp-owner=a:9 x\nbogus\n\n'; + expect(parseProcessList(output)).toEqual([ + { pid: 1, ppid: 0, pgid: 1, command: '/sbin/launchd' }, + { pid: 812, ppid: 1, pgid: 812, command: 'node worker.js --frontmcp-owner=a:9 x' }, + ]); + }); +}); + +describe('reapOrphans', () => { + const sleep = jest.fn(async () => undefined); + + function scenario(processes: ProcessInfo[], aliveOwners: number[]) { + const alive = new Set([...processes.map((info) => info.pid), ...aliveOwners]); + const signals: Array<[number, NodeJS.Signals | 0]> = []; + const kill = jest.fn((pid: number, signal: NodeJS.Signals | 0) => { + if (signal === 0) { + if (!alive.has(pid)) throw esrch(); + return; + } + signals.push([pid, signal]); + alive.delete(pid); + }); + return { kill, signals, alive, deps: { platform: 'linux' as const, kill, sleep, listProcesses: () => processes } }; + } + + it('stops a marked process whose owner is gone, with its descendants and its group', async () => { + const { deps, signals } = scenario( + [ + { pid: 100, ppid: 1, pgid: 100, command: 'node worker.js --frontmcp-owner=tasks:50' }, + { pid: 101, ppid: 100, pgid: 100, command: 'node child.js' }, + { pid: 102, ppid: 101, pgid: 100, command: 'node grandchild.js' }, + { pid: 103, ppid: 1, pgid: 100, command: 'node left-in-group.js' }, + { pid: 200, ppid: 1, pgid: 200, command: 'node unmarked.js' }, + ], + [], + ); + + await expect(reapOrphans('tasks', {}, deps)).resolves.toEqual([100]); + + expect(signals.map(([pid]) => pid).sort()).toEqual([100, 101, 102, 103]); + expect(signals.every(([, signal]) => signal === 'SIGTERM')).toBe(true); + }); + + it('leaves processes whose owner still runs, other markers and this process alone', async () => { + const { deps, signals } = scenario( + [ + { pid: 300, ppid: 60, pgid: 300, command: 'node a.js --frontmcp-owner=tasks:60' }, + { pid: 301, ppid: 1, pgid: 301, command: 'node b.js --frontmcp-owner=other:61' }, + { pid: 302, ppid: 1, pgid: 302, command: 'node c.js --frontmcp-owner=tasks-2:62' }, + { pid: 303, ppid: 1, pgid: 303, command: 'node d.js --frontmcp-owner=tasks:not-a-pid' }, + { pid: process.pid, ppid: 1, pgid: process.pid, command: 'node self.js --frontmcp-owner=tasks:63' }, + ], + [60], + ); + + await expect(reapOrphans('tasks', {}, deps)).resolves.toEqual([]); + expect(signals).toEqual([]); + }); + + it('treats an owner it may not signal as running', async () => { + const kill = jest.fn((pid: number) => { + if (pid === 70) throw Object.assign(new Error('kill EPERM'), { code: 'EPERM' }); + }); + const listProcesses = () => [{ pid: 400, ppid: 1, pgid: 400, command: 'x --frontmcp-owner=tasks:70' }]; + await expect(reapOrphans('tasks', {}, { platform: 'darwin', kill, sleep, listProcesses })).resolves.toEqual([]); + expect(kill).toHaveBeenCalledTimes(1); + }); + + it('kills what outlives the grace period', async () => { + const processes = [{ pid: 500, ppid: 1, pgid: 500, command: 'x --frontmcp-owner=tasks:80' }]; + let alive = true; + const kill = jest.fn((pid: number, signal: NodeJS.Signals | 0) => { + if (pid === 80) throw esrch(); + if (signal === 0 && !alive) throw esrch(); + if (signal === 'SIGKILL') alive = false; + }); + + await expect( + reapOrphans('tasks', { graceMs: 0 }, { platform: 'linux', kill, sleep, listProcesses: () => processes }), + ).resolves.toEqual([500]); + expect(kill).toHaveBeenCalledWith(500, 'SIGTERM'); + expect(kill).toHaveBeenCalledWith(500, 'SIGKILL'); + }); + + it('does not report an orphan that survives even SIGKILL', async () => { + const kill = jest.fn((pid: number) => { + if (pid === 90) throw esrch(); + }); + const listProcesses = () => [{ pid: 600, ppid: 1, pgid: 600, command: 'x --frontmcp-owner=tasks:90' }]; + await expect( + reapOrphans('tasks', { graceMs: 0 }, { platform: 'linux', kill, sleep, listProcesses }), + ).resolves.toEqual([]); + }); + + it('does nothing on Windows', async () => { + const listProcesses = jest.fn(() => []); + await expect(reapOrphans('tasks', {}, { platform: 'win32', listProcesses })).resolves.toEqual([]); + expect(listProcesses).not.toHaveBeenCalled(); + }); + + it('refuses an invalid marker', async () => { + await expect(reapOrphans('bad marker')).rejects.toThrow(OwnedProcessError); + }); + + (process.platform === 'win32' ? it.skip : it)( + 'finds a real orphan with ps and leaves an owned child of this process running', + async () => { + const marker = `unit-reap-${process.pid}`; + const ours = spawnOwned(process.execPath, ['-e', 'setInterval(() => {}, 1000)', '--'], { + marker, + detached: true, + stdio: 'ignore', + }); + const orphanScript = `const { spawn } = require('child_process'); + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)', '--', '--frontmcp-owner=${marker}:' + process.pid], { detached: true, stdio: 'ignore' }); + child.unref(); + process.stdout.write(String(child.pid));`; + const launcher = spawn(process.execPath, ['-e', orphanScript], { stdio: ['ignore', 'pipe', 'ignore'] }); + const orphanPid = await new Promise((resolve) => { + launcher.stdout.once('data', (chunk: Buffer) => resolve(Number(chunk.toString()))); + }); + await new Promise((resolve) => launcher.once('exit', resolve)); + try { + await waitUntil(() => running(orphanPid)); + + await expect(reapOrphans(marker, { graceMs: 2000 })).resolves.toEqual([orphanPid]); + + expect(running(orphanPid)).toBe(false); + expect(ours.child.exitCode).toBeNull(); + } finally { + if (running(orphanPid)) process.kill(orphanPid, 'SIGKILL'); + await ours.stop('SIGKILL', 1000); + } + }, + 20_000, + ); +}); diff --git a/libs/cli/src/shared/__tests__/process-tree.spec.ts b/libs/utils/src/process/__tests__/process-tree.spec.ts similarity index 100% rename from libs/cli/src/shared/__tests__/process-tree.spec.ts rename to libs/utils/src/process/__tests__/process-tree.spec.ts diff --git a/libs/cli/src/shared/__tests__/process-tree.windows.spec.ts b/libs/utils/src/process/__tests__/process-tree.windows.spec.ts similarity index 100% rename from libs/cli/src/shared/__tests__/process-tree.windows.spec.ts rename to libs/utils/src/process/__tests__/process-tree.windows.spec.ts diff --git a/libs/utils/src/process/__tests__/process-watch.spec.ts b/libs/utils/src/process/__tests__/process-watch.spec.ts new file mode 100644 index 000000000..e0bb2a65b --- /dev/null +++ b/libs/utils/src/process/__tests__/process-watch.spec.ts @@ -0,0 +1,153 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { PassThrough } from 'stream'; + +import { watchParent, watchPath } from '../process-watch'; + +function esrch(): Error { + return Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' }); +} + +describe('watchParent', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('reports the parent gone once this process is reparented', () => { + let parent = 4000; + const onGone = jest.fn(); + watchParent({ onGone, intervalMs: 100 }, { ppid: () => parent, kill: jest.fn() }); + + jest.advanceTimersByTime(300); + expect(onGone).not.toHaveBeenCalled(); + parent = 1; + jest.advanceTimersByTime(100); + + expect(onGone).toHaveBeenCalledWith('parent-exited'); + jest.advanceTimersByTime(1000); + expect(onGone).toHaveBeenCalledTimes(1); + }); + + it('reports the parent gone when its pid no longer runs (no reparenting, as on Windows)', () => { + const onGone = jest.fn(); + let alive = true; + const kill = jest.fn(() => { + if (!alive) throw esrch(); + }); + watchParent({ onGone }, { ppid: () => 4100, kill }); + + jest.advanceTimersByTime(1000); + expect(onGone).not.toHaveBeenCalled(); + alive = false; + jest.advanceTimersByTime(1000); + + expect(kill).toHaveBeenCalledWith(4100, 0); + expect(onGone).toHaveBeenCalledWith('parent-exited'); + }); + + it('treats a parent it may not signal as running', () => { + const onGone = jest.fn(); + const kill = jest.fn(() => { + throw Object.assign(new Error('kill EPERM'), { code: 'EPERM' }); + }); + watchParent({ onGone, intervalMs: 10 }, { ppid: () => 4200, kill }); + + jest.advanceTimersByTime(100); + expect(onGone).not.toHaveBeenCalled(); + }); + + it('reports stdin ending', () => { + const stdin = new PassThrough(); + const onGone = jest.fn(); + watchParent({ onGone, stdin }, { ppid: () => 4300, kill: jest.fn() }); + + stdin.emit('end'); + stdin.emit('close'); + + expect(onGone).toHaveBeenCalledTimes(1); + expect(onGone).toHaveBeenCalledWith('stdin-ended'); + }); + + it('reports stdin closing without an end', () => { + const stdin = new PassThrough(); + const onGone = jest.fn(); + watchParent({ onGone, stdin }, { ppid: () => 4400, kill: jest.fn() }); + + stdin.emit('close'); + + expect(onGone).toHaveBeenCalledWith('stdin-ended'); + }); + + it('polls nothing when the parent is already init', () => { + const kill = jest.fn(); + const onGone = jest.fn(); + watchParent({ onGone }, { ppid: () => 1, kill }); + + jest.advanceTimersByTime(5000); + expect(kill).not.toHaveBeenCalled(); + expect(onGone).not.toHaveBeenCalled(); + }); + + it('stops watching once closed', () => { + const stdin = new PassThrough(); + let parent = 4500; + const onGone = jest.fn(); + const watcher = watchParent({ onGone, stdin }, { ppid: () => parent, kill: jest.fn() }); + + watcher.close(); + parent = 1; + jest.advanceTimersByTime(5000); + stdin.emit('end'); + + expect(onGone).not.toHaveBeenCalled(); + expect(stdin.listenerCount('end')).toBe(0); + expect(stdin.listenerCount('close')).toBe(0); + }); + + it('watches the real parent by default without keeping the process alive', () => { + const onGone = jest.fn(); + const watcher = watchParent({ onGone, intervalMs: 50 }); + jest.advanceTimersByTime(500); + watcher.close(); + expect(onGone).not.toHaveBeenCalled(); + }); +}); + +describe('watchPath', () => { + let directory: string; + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'watch-path-')); + }); + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }); + }); + + it('reports a removed directory once', async () => { + const onGone = jest.fn(); + const watcher = watchPath(directory, { onGone, intervalMs: 10 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onGone).not.toHaveBeenCalled(); + + rmSync(directory, { recursive: true, force: true }); + await new Promise((resolve) => setTimeout(resolve, 80)); + + expect(onGone).toHaveBeenCalledTimes(1); + watcher.close(); + }); + + it('stops watching once closed', async () => { + const onGone = jest.fn(); + watchPath(directory, { onGone, intervalMs: 10 }).close(); + rmSync(directory, { recursive: true, force: true }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(onGone).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/utils/src/process/child-process.ts b/libs/utils/src/process/child-process.ts new file mode 100644 index 000000000..f3f6a01a2 --- /dev/null +++ b/libs/utils/src/process/child-process.ts @@ -0,0 +1,12 @@ +import { assertNode } from '../crypto/runtime'; + +let childProcess: typeof import('child_process') | null = null; + +/** `child_process`, loaded on first use so the browser and edge builds never import it. */ +export function getChildProcess(): typeof import('child_process') { + if (!childProcess) { + assertNode('Process operations'); + childProcess = require('child_process') as typeof import('child_process'); + } + return childProcess; +} diff --git a/libs/utils/src/process/index.ts b/libs/utils/src/process/index.ts new file mode 100644 index 000000000..1ff637423 --- /dev/null +++ b/libs/utils/src/process/index.ts @@ -0,0 +1,6 @@ +export { processTreeSpawnOptions, signalProcessTree, isProcessTreeAlive, stopProcessTree } from './process-tree'; +export type { ProcessTreeDeps } from './process-tree'; +export { spawnOwned, reapOrphans, OwnedProcessError } from './owned-process'; +export type { SpawnOwnedOptions, OwnedProcess, ReapOrphansOptions } from './owned-process'; +export { watchParent, watchPath } from './process-watch'; +export type { ParentGoneReason, ProcessWatcher, WatchParentOptions, WatchPathOptions } from './process-watch'; diff --git a/libs/utils/src/process/owned-process.ts b/libs/utils/src/process/owned-process.ts new file mode 100644 index 000000000..b5aad2088 --- /dev/null +++ b/libs/utils/src/process/owned-process.ts @@ -0,0 +1,189 @@ +/** + * Child processes that carry their owner's mark, so the ones whose owner died can be found and stopped. + * + * **Node.js only.** + */ + +import type { ChildProcess, SpawnOptions } from 'child_process'; + +import { getChildProcess } from './child-process'; +import { isPidRunning, sleep, stopProcessTree, type ProcessTreeDeps } from './process-tree'; + +const OWNER_ENV_VAR = 'FRONTMCP_OWNER'; +const OWNER_ARG_PREFIX = '--frontmcp-owner='; +const MARKER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +const DEFAULT_REAP_GRACE_MS = 2000; +const KILL_WAIT_MS = 1000; + +export class OwnedProcessError extends Error { + constructor(message: string) { + super(message); + this.name = 'OwnedProcessError'; + } +} + +export interface SpawnOwnedOptions extends SpawnOptions { + /** Names the kind of process, for {@link reapOrphans}: up to 64 letters, digits, `.`, `_` and `-`. */ + marker: string; +} + +export interface OwnedProcess { + readonly child: ChildProcess; + /** Stop the child and every process it started; resolves `true` once they are gone. */ + stop(signal?: NodeJS.Signals, graceMs?: number): Promise; +} + +export interface ProcessInfo { + pid: number; + ppid: number; + pgid: number; + command: string; +} + +export interface ReapOrphansOptions { + /** How long the orphans get to exit after `SIGTERM` before they are killed. @default 2000 */ + graceMs?: number; +} + +export interface ReapOrphansDeps extends Pick { + listProcesses: () => ProcessInfo[]; +} + +function assertMarker(marker: string): void { + if (!MARKER_PATTERN.test(marker)) { + throw new OwnedProcessError( + `Invalid process marker "${marker}": use up to 64 letters, digits, ".", "_" and "-", starting with a letter or digit`, + ); + } +} + +/** + * Spawn `command` marked as owned by this process: the child gets `FRONTMCP_OWNER=:` in its + * environment and `--frontmcp-owner=:` as its last argument, which {@link reapOrphans} finds + * with `ps`. The command must take that last argument as an argument: a script run by node does, while + * `node -e ` reads it as a node option unless the arguments end with `--`. + * + * @throws OwnedProcessError when the marker is invalid + */ +export function spawnOwned(command: string, args: readonly string[], options: SpawnOwnedOptions): OwnedProcess { + const { marker, ...spawnOptions } = options; + assertMarker(marker); + const owner = `${marker}:${process.pid}`; + const child = getChildProcess().spawn(command, [...args, `${OWNER_ARG_PREFIX}${owner}`], { + ...spawnOptions, + env: { ...(spawnOptions.env ?? process.env), [OWNER_ENV_VAR]: owner }, + }); + return { + child, + stop: (signal = 'SIGTERM', graceMs = 5000) => stopProcessTree(child, signal, graceMs), + }; +} + +/** Parse `ps -o pid=,ppid=,pgid=,command=` output; lines it cannot read are skipped. */ +export function parseProcessList(output: string): ProcessInfo[] { + const processes: ProcessInfo[] = []; + for (const line of output.split('\n')) { + const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line); + if (match) { + processes.push({ pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), command: match[4] }); + } + } + return processes; +} + +function listProcesses(): ProcessInfo[] { + const result = getChildProcess().spawnSync('ps', ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,command='], { + encoding: 'utf8', + }); + if (result.error || result.status !== 0) { + const reason = result.error?.message ?? (String(result.stderr).trim() || `exit code ${result.status}`); + throw new OwnedProcessError(`Could not list processes with ps: ${reason}`); + } + return parseProcessList(String(result.stdout)); +} + +function ownerPidOf(command: string, marker: string): number | undefined { + const prefix = `${OWNER_ARG_PREFIX}${marker}:`; + const tag = command.split(/\s+/).find((token) => token.startsWith(prefix)); + const pid = tag?.slice(prefix.length); + return pid && /^\d+$/.test(pid) ? Number(pid) : undefined; +} + +/** The orphans, the processes they started, and the rest of each orphan's process group. */ +function treesOf(orphans: ProcessInfo[], processes: ProcessInfo[]): Set { + const members = new Set(); + const pending = orphans.map((orphan) => orphan.pid); + for (const orphan of orphans) { + if (orphan.pgid === orphan.pid) { + for (const info of processes) if (info.pgid === orphan.pid) pending.push(info.pid); + } + } + while (pending.length > 0) { + const pid = pending.pop() as number; + if (members.has(pid) || pid === process.pid) continue; + members.add(pid); + for (const info of processes) if (info.ppid === pid) pending.push(info.pid); + } + return members; +} + +function signalEach(pids: Iterable, signal: NodeJS.Signals, d: ReapOrphansDeps): void { + for (const pid of pids) { + try { + d.kill(pid, signal); + } catch { + // Gone already, or not ours to signal. + } + } +} + +async function waitForExit(pids: number[], timeoutMs: number, d: ReapOrphansDeps): Promise { + const deadline = Date.now() + timeoutMs; + let running = pids.filter((pid) => isPidRunning(pid, d)); + while (running.length > 0 && Date.now() < deadline) { + await d.sleep(50); + running = running.filter((pid) => isPidRunning(pid, d)); + } + return running; +} + +/** + * Stop the processes {@link spawnOwned} marked with `marker` whose owner no longer runs, with every process they + * started: `SIGTERM`, then `SIGKILL` for what is still running after `graceMs`. A process whose owner still runs, + * or that carries another marker or none, is left alone. Resolves with the pids of the marked orphans it stopped. + * + * POSIX only: it reads the process table with `ps`. On Windows it does nothing and resolves with `[]`. + * + * @throws OwnedProcessError when the marker is invalid, or `ps` cannot list the processes + */ +export async function reapOrphans( + marker: string, + options: ReapOrphansOptions = {}, + deps?: Partial, +): Promise { + assertMarker(marker); + const d: ReapOrphansDeps = { + platform: process.platform, + kill: (pid, signal) => process.kill(pid, signal), + sleep, + listProcesses, + ...deps, + }; + if (d.platform === 'win32') return []; + + const processes = d.listProcesses(); + const orphans = processes.filter((info) => { + const owner = ownerPidOf(info.command, marker); + return owner !== undefined && info.pid !== process.pid && !isPidRunning(owner, d); + }); + if (orphans.length === 0) return []; + + const tree = [...treesOf(orphans, processes)]; + signalEach(tree, 'SIGTERM', d); + const survivors = await waitForExit(tree, options.graceMs ?? DEFAULT_REAP_GRACE_MS, d); + if (survivors.length > 0) { + signalEach(survivors, 'SIGKILL', d); + await waitForExit(survivors, KILL_WAIT_MS, d); + } + return orphans.map((orphan) => orphan.pid).filter((pid) => !isPidRunning(pid, d)); +} diff --git a/libs/cli/src/shared/process-tree.ts b/libs/utils/src/process/process-tree.ts similarity index 90% rename from libs/cli/src/shared/process-tree.ts rename to libs/utils/src/process/process-tree.ts index ac7bd1369..bd89696ec 100644 --- a/libs/cli/src/shared/process-tree.ts +++ b/libs/utils/src/process/process-tree.ts @@ -1,5 +1,5 @@ /** - * Process-tree lifecycle for the long-lived children `frontmcp dev` spawns. + * Process-tree lifecycle for long-lived children, such as the ones `frontmcp dev` spawns. * * The process that actually holds the dev server's port is not always our * direct child: `tsx --watch main.ts` forks the server, and the `npx` fallback @@ -20,13 +20,17 @@ * - when the direct child has already exited, the processes it started are * found by their parent PID (`Get-CimInstance Win32_Process`) and ended one * tree at a time, and the tree only counts as gone once none is left. - * Spawning tools directly with `process.execPath` (see `tool-command.ts`) + * Spawning tools directly with `process.execPath` (the CLI's `tool-command.ts`) * removes the `cmd.exe` shim from the tree, so a dying parent's kill-on-close * job object (libuv puts every non-detached child in one) takes its children * with it; the lookup above covers what is left. + * + * **Node.js only**: `child_process` is loaded on first use, as the file system helpers load `fs`. */ -import { spawnSync, type ChildProcess, type SpawnOptions } from 'child_process'; +import type { ChildProcess, SpawnOptions } from 'child_process'; + +import { getChildProcess } from './child-process'; export interface ProcessTreeDeps { platform: NodeJS.Platform; @@ -44,7 +48,7 @@ export interface ProcessTreeDeps { /** Runs taskkill and reports whether it succeeded — `spawnSync` itself never throws. */ export function runTaskkill(pid: number, force: boolean): boolean { - const result = spawnSync('taskkill', ['/pid', String(pid), '/T', ...(force ? ['/F'] : [])], { + const result = getChildProcess().spawnSync('taskkill', ['/pid', String(pid), '/T', ...(force ? ['/F'] : [])], { stdio: 'ignore', windowsHide: true, }); @@ -54,7 +58,7 @@ export function runTaskkill(pid: number, force: boolean): boolean { /** Child PIDs of `pid` from `Get-CimInstance Win32_Process`; `[]` when the lookup fails. */ export function queryChildPids(pid: number): number[] { if (!Number.isInteger(pid) || pid <= 0) return []; - const result = spawnSync( + const result = getChildProcess().spawnSync( 'powershell.exe', [ '-NoProfile', @@ -79,16 +83,19 @@ export function parsePidList(output: string): number[] { .filter((pid) => pid > 0); } -const defaultDeps: ProcessTreeDeps = { - platform: process.platform, - kill: (pid, signal) => process.kill(pid, signal), - taskkill: runTaskkill, - childPids: queryChildPids, - sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), -}; +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} function withDefaults(deps?: Partial): ProcessTreeDeps { - return { ...defaultDeps, ...deps }; + return { + platform: process.platform, + kill: (pid, signal) => process.kill(pid, signal), + taskkill: runTaskkill, + childPids: queryChildPids, + sleep, + ...deps, + }; } /** @@ -105,7 +112,8 @@ function hasExited(child: ChildProcess): boolean { return child.exitCode !== null || child.signalCode !== null; } -function isPidRunning(pid: number, d: ProcessTreeDeps): boolean { +/** True while `pid` runs; a process this user may not signal (`EPERM`) counts as running. */ +export function isPidRunning(pid: number, d: Pick): boolean { try { d.kill(pid, 0); return true; diff --git a/libs/utils/src/process/process-watch.ts b/libs/utils/src/process/process-watch.ts new file mode 100644 index 000000000..c9152547c --- /dev/null +++ b/libs/utils/src/process/process-watch.ts @@ -0,0 +1,87 @@ +/** + * Watchers that tell a long-lived process its reason to run is gone: its parent exited, its stdin ended, or a + * directory it serves was removed. Their timers are unref'd, so they never keep the process alive. + * + * **Node.js only.** + */ + +import { fileExistsSync } from '../fs/fs'; +import { isPidRunning, type ProcessTreeDeps } from './process-tree'; + +const DEFAULT_INTERVAL_MS = 1000; + +export type ParentGoneReason = 'parent-exited' | 'stdin-ended'; + +export interface ProcessWatcher { + /** Stop watching. Safe to call more than once. */ + close(): void; +} + +export interface WatchParentOptions { + /** Called once, when the parent is gone. */ + onGone: (reason: ParentGoneReason) => void; + /** How often the parent is checked. @default 1000 */ + intervalMs?: number; + /** A stream whose end also counts as the parent going away, such as `process.stdin` for a stdio server. */ + stdin?: NodeJS.ReadableStream; +} + +export interface WatchParentDeps extends Pick { + ppid: () => number; +} + +export interface WatchPathOptions { + /** Called once, when the path no longer exists. */ + onGone: () => void; + /** How often the path is checked. @default 1000 */ + intervalMs?: number; +} + +/** + * Report when this process's parent is gone: this process was handed to another parent (the parent pid changed), + * or the parent pid no longer runs (Windows does not reparent). A process started by init has no parent to watch, + * so only `stdin` is watched then. + */ +export function watchParent(options: WatchParentOptions, deps?: Partial): ProcessWatcher { + const d: WatchParentDeps = { ppid: () => process.ppid, kill: (pid, signal) => process.kill(pid, signal), ...deps }; + const parentPid = d.ppid(); + const stdin = options.stdin; + let timer: ReturnType | undefined; + let closed = false; + + const close = (): void => { + closed = true; + clearInterval(timer); + stdin?.removeListener('end', onStdinEnded); + stdin?.removeListener('close', onStdinEnded); + }; + const report = (reason: ParentGoneReason): void => { + if (closed) return; + close(); + options.onGone(reason); + }; + function onStdinEnded(): void { + report('stdin-ended'); + } + + stdin?.once('end', onStdinEnded); + stdin?.once('close', onStdinEnded); + if (parentPid > 1) { + timer = setInterval(() => { + if (d.ppid() !== parentPid || !isPidRunning(parentPid, d)) report('parent-exited'); + }, options.intervalMs ?? DEFAULT_INTERVAL_MS); + timer.unref(); + } + return { close }; +} + +/** Report when `path` no longer exists, for example a checkout that was deleted. */ +export function watchPath(path: string, options: WatchPathOptions): ProcessWatcher { + const timer = setInterval(() => { + if (fileExistsSync(path)) return; + clearInterval(timer); + options.onGone(); + }, options.intervalMs ?? DEFAULT_INTERVAL_MS); + timer.unref(); + return { close: () => clearInterval(timer) }; +} From 27d91ab0ce6faea8bfed79054a437c88a647213e Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 12:28:04 +0200 Subject: [PATCH 2/8] docs(utils): list the process helpers without reflowing the README table --- libs/utils/README.md | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/libs/utils/README.md b/libs/utils/README.md index 9f8d56110..1a2c04797 100644 --- a/libs/utils/README.md +++ b/libs/utils/README.md @@ -37,26 +37,26 @@ await storage.get('ticket:T-1'); // 'open' ## What's in it -| Area | Exports | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Naming | `splitWords`, `toCase`, `shortHash`, `ensureMaxLen`, `idFromString` | -| URIs | `isValidMcpUri`, `extractUriScheme`, `parseUriTemplate`, `matchUriTemplate`, `expandUriTemplate`, `extractTemplateParams` (RFC 3986 URIs, RFC 6570 templates) | -| Paths | `trimSlashes`, `joinPath`, and cross-platform `pathJoin`, `pathResolve`, `basename`, `dirname`, `extname` | -| Content | `sanitizeToJson`, `inferMimeType`, `safeStringify` | -| HTTP | `validateBaseUrl`, `buildSetCookie`, `parseCookies`, `getCookie`, `isLocalhost`, `isSecureRequest` | -| Network | `classifyIpAddress`, `parseIpv4`, `expandIpv6`, `isRedirectResponse`, for SSRF (server-side request forgery) checks | -| Escaping | `escapeHtml`, `escapeHtmlAttr`, `escapeJsString`, `safeJsonForScript` | -| Crypto | `sha256`, `sha256Hex`, `hmacSha256`, `hkdfSha256`, `encryptAesGcm`, `decryptAesGcm`, `randomBytes`, `randomUUID`, `timingSafeEqual`, `base64urlEncode`, `base64urlDecode` | -| PKCE | `generateCodeVerifier`, `generateCodeChallenge`, `verifyCodeChallenge`, `generatePkcePair` (RFC 7636) | -| Encryption, keys | `encryptValue`, `decryptValue`, `signData`, `verifyData`, `KeyPersistence`, `createKeyPersistence` | -| Storage | `createStorage`, `createMemoryStorage`, `TypedStorage`, `EncryptedTypedStorage`, and adapters for memory, Redis, Vercel KV, Upstash and the file system | -| File system | `readFile`, `writeFile`, `mkdir`, `stat`, `fileExists`, `readJSON`, `writeJSON`, `ensureDir`, `isDirEmpty`, `runCmd` and more. Node.js only; `fs` loads on first use. | -| Processes | `spawnOwned`, `reapOrphans` (POSIX `ps`; nothing on Windows), `watchParent`, `watchPath`, `stopProcessTree`, `signalProcessTree`. Node.js only; `child_process` loads on first use. | -| Environment | `getEnv`, `isProduction`, `isDevelopment`, `isEdgeRuntime`, `isServerless`, `getRuntimeContext`, `isNode`, `isBrowser` | -| Context, events | `AsyncLocalStorage`, `EventEmitter`, both with browser builds | -| Safe regex | `isPatternSafe`, `createSafeRegExp`, `safeTest`, `safeMatch`, `safeReplace`, against ReDoS (regular expression denial of service) | -| Model tool calls | `processPlatformToolCalls`, which passes OpenAI, Claude or Vercel AI tool calls to your `callTool` function and returns the results in that platform's format | -| Machine ID | `getMachineId`, `setMachineIdOverride`, `getHostname` | +| Area | Exports | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Naming | `splitWords`, `toCase`, `shortHash`, `ensureMaxLen`, `idFromString` | +| URIs | `isValidMcpUri`, `extractUriScheme`, `parseUriTemplate`, `matchUriTemplate`, `expandUriTemplate`, `extractTemplateParams` (RFC 3986 URIs, RFC 6570 templates) | +| Paths | `trimSlashes`, `joinPath`, and cross-platform `pathJoin`, `pathResolve`, `basename`, `dirname`, `extname` | +| Content | `sanitizeToJson`, `inferMimeType`, `safeStringify` | +| HTTP | `validateBaseUrl`, `buildSetCookie`, `parseCookies`, `getCookie`, `isLocalhost`, `isSecureRequest` | +| Network | `classifyIpAddress`, `parseIpv4`, `expandIpv6`, `isRedirectResponse`, for SSRF (server-side request forgery) checks | +| Escaping | `escapeHtml`, `escapeHtmlAttr`, `escapeJsString`, `safeJsonForScript` | +| Crypto | `sha256`, `sha256Hex`, `hmacSha256`, `hkdfSha256`, `encryptAesGcm`, `decryptAesGcm`, `randomBytes`, `randomUUID`, `timingSafeEqual`, `base64urlEncode`, `base64urlDecode` | +| PKCE | `generateCodeVerifier`, `generateCodeChallenge`, `verifyCodeChallenge`, `generatePkcePair` (RFC 7636) | +| Encryption, keys | `encryptValue`, `decryptValue`, `signData`, `verifyData`, `KeyPersistence`, `createKeyPersistence` | +| Storage | `createStorage`, `createMemoryStorage`, `TypedStorage`, `EncryptedTypedStorage`, and adapters for memory, Redis, Vercel KV, Upstash and the file system | +| File system | `readFile`, `writeFile`, `mkdir`, `stat`, `fileExists`, `readJSON`, `writeJSON`, `ensureDir`, `isDirEmpty`, `runCmd` and more. Node.js only; `fs` loads on first use. | +| Processes | `spawnOwned`, `reapOrphans`, `watchParent`, `watchPath`, `stopProcessTree`, `signalProcessTree`. Node.js only; `child_process` loads on first use. | +| Environment | `getEnv`, `isProduction`, `isDevelopment`, `isEdgeRuntime`, `isServerless`, `getRuntimeContext`, `isNode`, `isBrowser` | +| Context, events | `AsyncLocalStorage`, `EventEmitter`, both with browser builds | +| Safe regex | `isPatternSafe`, `createSafeRegExp`, `safeTest`, `safeMatch`, `safeReplace`, against ReDoS (regular expression denial of service) | +| Model tool calls | `processPlatformToolCalls`, which passes OpenAI, Claude or Vercel AI tool calls to your `callTool` function and returns the results in that platform's format | +| Machine ID | `getMachineId`, `setMachineIdOverride`, `getHostname` | ## Related packages From 625fa2d69d92609fa5e842962e5c52ee03f47020 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 12:32:12 +0200 Subject: [PATCH 3/8] test(utils): cover reapOrphans when ps fails --- libs/utils/src/fs/fs.ts | 7 ++--- .../process/__tests__/owned-process.spec.ts | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/libs/utils/src/fs/fs.ts b/libs/utils/src/fs/fs.ts index 65b739576..4dc26ffec 100644 --- a/libs/utils/src/fs/fs.ts +++ b/libs/utils/src/fs/fs.ts @@ -43,9 +43,10 @@ function getSpawn(): typeof import('child_process').spawn { * detached worker (CLI task runners, dev-server supervisors, etc.). Throws * via `assertNode` on non-Node runtimes. * - * **Node.js only.** Exposed because consumers (e.g. `CliTaskRunner` in the - * SDK) need a single place to reach `spawn` without a top-level - * `import 'node:child_process'` that V8 isolates would choke on. + * **Node.js only.** Exposed so consumers have a single place to reach `spawn` + * without a top-level `import 'node:child_process'` that V8 isolates would + * choke on. A worker that should be reapable once its owner dies is spawned + * with `spawnOwned` instead (as `CliTaskRunner` in the SDK does). */ export function getSpawnFn(): typeof import('child_process').spawn { return getSpawn(); diff --git a/libs/utils/src/process/__tests__/owned-process.spec.ts b/libs/utils/src/process/__tests__/owned-process.spec.ts index 2bc03f97a..386e61327 100644 --- a/libs/utils/src/process/__tests__/owned-process.spec.ts +++ b/libs/utils/src/process/__tests__/owned-process.spec.ts @@ -5,6 +5,16 @@ import { join } from 'path'; import { OwnedProcessError, parseProcessList, reapOrphans, spawnOwned, type ProcessInfo } from '../owned-process'; +let mockPsResult: object | undefined; + +jest.mock('child_process', () => { + const actual = jest.requireActual('child_process'); + return { + ...actual, + spawnSync: (...args: Parameters) => mockPsResult ?? actual.spawnSync(...args), + }; +}); + function esrch(): Error { return Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' }); } @@ -213,3 +223,20 @@ describe('reapOrphans', () => { 20_000, ); }); + +describe('reapOrphans when ps fails', () => { + afterEach(() => { + mockPsResult = undefined; + }); + + it.each([ + [{ status: 1, stdout: '', stderr: 'ps: illegal option -- w\n' }, 'ps: illegal option -- w'], + [{ status: 1, stdout: '', stderr: '' }, 'exit code 1'], + [{ status: null, stdout: '', stderr: '', error: new Error('spawnSync ps ENOENT') }, 'spawnSync ps ENOENT'], + ])('rejects with the reason', async (result, reason) => { + mockPsResult = result; + const reaping = reapOrphans('tasks', {}, { platform: 'linux' }); + await expect(reaping).rejects.toBeInstanceOf(OwnedProcessError); + await expect(reaping).rejects.toThrow(`Could not list processes with ps: ${reason}`); + }); +}); From 1e6887ef261685d7aa4ec6b6e5d675b92436fa9f Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 12:39:24 +0200 Subject: [PATCH 4/8] perf(sdk): import mkdir statically so the browser bundle can tree-shake @frontmcp/utils The lazy import('@frontmcp/utils') in Scope kept every export of the barrel in an app's browser bundle, the new process helpers included. mkdir already loads fs on first use, so a static import is enough. --- libs/sdk/src/scope/scope.instance.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/libs/sdk/src/scope/scope.instance.ts b/libs/sdk/src/scope/scope.instance.ts index 7d9b27086..6217c8580 100644 --- a/libs/sdk/src/scope/scope.instance.ts +++ b/libs/sdk/src/scope/scope.instance.ts @@ -2,7 +2,7 @@ import 'reflect-metadata'; import { createGuardManager, type GuardConfig, type GuardManager } from '@frontmcp/guard'; import { type EventStore } from '@frontmcp/protocol'; -import { createRedisClient, getEnvFlag, getMachineId, getRuntimeContext, isEdgeRuntime } from '@frontmcp/utils'; +import { createRedisClient, getEnvFlag, getMachineId, getRuntimeContext, isEdgeRuntime, mkdir } from '@frontmcp/utils'; import AdapterRegistry from '../adapter/adapter.registry'; import AgentRegistry from '../agent/agent.registry'; @@ -82,7 +82,6 @@ import { warnIfRequestStateKeyNotShared } from '../transport/mcp-20260728/reques import type { SessionRelayHandle } from '../transport/relay/relay-scope.helper'; import { warnIfSessionModeIgnored } from '../transport/session-mode.check'; import type { TransportService } from '../transport/transport.registry'; -import { importWithRequireFallback } from '../utils/dynamic-import.utils'; import type WorkflowRegistry from '../workflow/workflow.registry'; import { registerServerEntries } from './server-entries.helper'; import { reportStartup } from './startup-report.helper'; @@ -1077,10 +1076,6 @@ export class Scope extends ScopeEntry { } try { const { dirname } = require('path') as typeof import('path'); - const { mkdir } = await importWithRequireFallback( - () => import('@frontmcp/utils'), - () => require('@frontmcp/utils') as typeof import('@frontmcp/utils'), - ); await mkdir(dirname(finalPath), { recursive: true }); } catch (err) { this.logger.warn('[FrontMcp] Could not ensure sqlite parent directory exists', { From d736477226b6578e6ef4db6f2149aee7d4301a81 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 12:44:49 +0200 Subject: [PATCH 5/8] fix(utils): report a stdin that ended before watchParent attached runStdio attaches the watcher after the providers' onReady() hooks, so a client that closed stdin meanwhile left the server running. Also document the orphan behaviour on runStdio and runUnixSocket. --- libs/sdk/src/front-mcp/front-mcp.ts | 6 +++++- libs/sdk/src/front-mcp/shutdown-signals.ts | 4 +++- .../src/process/__tests__/process-watch.spec.ts | 13 +++++++++++++ libs/utils/src/process/process-watch.ts | 5 ++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/libs/sdk/src/front-mcp/front-mcp.ts b/libs/sdk/src/front-mcp/front-mcp.ts index 270f8287e..66e2a53e5 100644 --- a/libs/sdk/src/front-mcp/front-mcp.ts +++ b/libs/sdk/src/front-mcp/front-mcp.ts @@ -646,7 +646,8 @@ export class FrontMcpInstance implements FrontMcpInterface { * class — spreading a class yields no config) plus the socket path. * * SIGTERM and SIGINT run the graceful {@link shutdown} and exit, as `bootstrap()` does for - * the TCP server; `close()` runs the same shutdown without exiting. + * the TCP server; `close()` runs the same shutdown without exiting. With + * `lifecycle.exitWhenOrphaned: true` the parent process exiting does too. * * @example * ```typescript @@ -712,6 +713,9 @@ export class FrontMcpInstance implements FrontMcpInterface { * * One connection serves every revision: `initialize` opens a session, a 2026-07-28 request is served statelessly. * + * When stdin ends or the parent process exits, the server shuts down as on SIGTERM and exits 0, so a client that + * dies leaves no orphan behind; `lifecycle: { exitWhenOrphaned: false }` turns that off. + * * Accepts either a `@FrontMcp`-decorated class or the same config object you * pass to `@FrontMcp()` (#450). * diff --git a/libs/sdk/src/front-mcp/shutdown-signals.ts b/libs/sdk/src/front-mcp/shutdown-signals.ts index 9faadf875..da0e6c0db 100644 --- a/libs/sdk/src/front-mcp/shutdown-signals.ts +++ b/libs/sdk/src/front-mcp/shutdown-signals.ts @@ -1,3 +1,5 @@ +import type { Readable } from 'node:stream'; + import { watchParent, type ParentGoneReason } from '@frontmcp/utils'; const SHUTDOWN_SIGNALS = ['SIGTERM', 'SIGINT'] as const; @@ -25,7 +27,7 @@ export interface ShutdownSignalOptions { deadlineMs?: number; logger?: { error(message: string, error?: unknown): void; info?(message: string): void }; /** Also shut down when the parent process exits or, when given, `stdin` ends (`lifecycle.exitWhenOrphaned`). */ - whenOrphaned?: { stdin?: NodeJS.ReadableStream }; + whenOrphaned?: { stdin?: Readable }; } /** diff --git a/libs/utils/src/process/__tests__/process-watch.spec.ts b/libs/utils/src/process/__tests__/process-watch.spec.ts index e0bb2a65b..e2465b53f 100644 --- a/libs/utils/src/process/__tests__/process-watch.spec.ts +++ b/libs/utils/src/process/__tests__/process-watch.spec.ts @@ -83,6 +83,19 @@ describe('watchParent', () => { expect(onGone).toHaveBeenCalledWith('stdin-ended'); }); + it('reports stdin that ended before the watch started', () => { + const stdin = new PassThrough(); + stdin.destroy(); + const onGone = jest.fn(); + watchParent({ onGone, stdin }, { ppid: () => 4450, kill: jest.fn() }); + expect(onGone).not.toHaveBeenCalled(); + + jest.runAllTicks(); + + expect(onGone).toHaveBeenCalledTimes(1); + expect(onGone).toHaveBeenCalledWith('stdin-ended'); + }); + it('polls nothing when the parent is already init', () => { const kill = jest.fn(); const onGone = jest.fn(); diff --git a/libs/utils/src/process/process-watch.ts b/libs/utils/src/process/process-watch.ts index c9152547c..2f5fbe46c 100644 --- a/libs/utils/src/process/process-watch.ts +++ b/libs/utils/src/process/process-watch.ts @@ -5,6 +5,8 @@ * **Node.js only.** */ +import type { Readable } from 'stream'; + import { fileExistsSync } from '../fs/fs'; import { isPidRunning, type ProcessTreeDeps } from './process-tree'; @@ -23,7 +25,7 @@ export interface WatchParentOptions { /** How often the parent is checked. @default 1000 */ intervalMs?: number; /** A stream whose end also counts as the parent going away, such as `process.stdin` for a stdio server. */ - stdin?: NodeJS.ReadableStream; + stdin?: Readable; } export interface WatchParentDeps extends Pick { @@ -64,6 +66,7 @@ export function watchParent(options: WatchParentOptions, deps?: Partial 1) { From 639058f6de92b2115e1ec8c9803e45666adae7f1 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 13:20:48 +0200 Subject: [PATCH 6/8] fix(utils): keep reapOrphans off its own lineage, count zombies as stopped, and stop a child without its own group - reapOrphans never stops the calling process, its ancestors or the leader of its process group. A task worker started through `npx tsx` carries the owner tag on its launchers too, so a worker that reaped after its server died stopped its own launchers and their group. - A stopped orphan that its new parent does not collect (a zombie, as under a container's PID 1 without an init) counts as stopped: `kill(pid, 0)` still reaches a zombie, so the reaper waited out the grace period, sent SIGKILL and reported nothing. `ps` now reads the `stat` column. - OwnedProcess.stop() on a child spawned without `detached` signals the child itself and kills it after the grace period. It resolved `true` at once while the child was still running, since such a child leads no process group. --- .../references/production-cli-daemon.md | 2 +- .../process/__tests__/owned-process.spec.ts | 136 +++++++++++++++--- libs/utils/src/process/owned-process.ts | 98 +++++++++++-- 3 files changed, 203 insertions(+), 33 deletions(-) diff --git a/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-daemon.md b/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-daemon.md index 6c0c04408..a51a99e31 100644 --- a/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-daemon.md +++ b/libs/skills/catalog/frontmcp-production-readiness/references/production-cli-daemon.md @@ -17,7 +17,7 @@ Checklist for deploying FrontMCP as a long-running local MCP server managed by t - [ ] `frontmcp status` shows the daemon as running - [ ] `frontmcp logs --follow` streams daemon output - [ ] A daemon that must not outlive the process that started it sets `lifecycle: { exitWhenOrphaned: true }`: it then shuts down gracefully when its parent exits. Off by default for `runUnixSocket()` and the HTTP server, since a detached daemon outlives its launcher -- [ ] Background task workers (`tasks.runner: 'cli'`) carry the owner mark `TASK_WORKER_MARKER` (`@frontmcp/sdk`): `reapOrphans(TASK_WORKER_MARKER)` (`@frontmcp/utils`) stops the ones whose server is gone (POSIX `ps`; does nothing on Windows) +- [ ] Background task workers (`tasks.runner: 'cli'`) carry the owner mark `TASK_WORKER_MARKER` (`@frontmcp/sdk`): `reapOrphans(TASK_WORKER_MARKER)` (`@frontmcp/utils`) stops the ones whose server is gone, never the calling process or its ancestors (POSIX `ps`, which slim and Alpine images may lack or not support; does nothing on Windows) ## Socket / Transport diff --git a/libs/utils/src/process/__tests__/owned-process.spec.ts b/libs/utils/src/process/__tests__/owned-process.spec.ts index 386e61327..55e19fa60 100644 --- a/libs/utils/src/process/__tests__/owned-process.spec.ts +++ b/libs/utils/src/process/__tests__/owned-process.spec.ts @@ -79,17 +79,45 @@ describe('spawnOwned', () => { expect(running(pid)).toBe(false); }); + it('stops a child that shares this process group, killing it when it ignores SIGTERM', async () => { + const script = 'process.on("SIGTERM", () => {}); process.stdout.write("ready"); setInterval(() => {}, 1000)'; + const owned = spawnOwned(process.execPath, ['-e', script, '--'], { + marker: 'unit-stop-child', + stdio: ['ignore', 'pipe', 'ignore'], + }); + await new Promise((resolve) => owned.child.stdout?.once('data', resolve)); + + try { + await expect(owned.stop('SIGTERM', 200)).resolves.toBe(true); + + expect(owned.child.signalCode).toBe('SIGKILL'); + await expect(owned.stop()).resolves.toBe(true); + } finally { + owned.child.kill('SIGKILL'); + } + }); + + it('has nothing to stop when the command could not start', async () => { + const owned = spawnOwned(join(directory, 'missing-command'), [], { marker: 'unit-missing', stdio: 'ignore' }); + const failed = new Promise((resolve) => owned.child.once('error', resolve)); + + await expect(owned.stop()).resolves.toBe(true); + await failed; + }); + it.each(['', 'has space', 'a:b', '-leading', 'x'.repeat(65)])('refuses the marker %p', (marker) => { expect(() => spawnOwned(process.execPath, [], { marker })).toThrow(OwnedProcessError); }); }); describe('parseProcessList', () => { - it('reads pid, parent pid, group and command, skipping lines it cannot read', () => { - const output = ' 1 0 1 /sbin/launchd\n 812 1 812 node worker.js --frontmcp-owner=a:9 x\nbogus\n\n'; + it('reads pid, parent pid, group, zombie state and command, skipping lines it cannot read', () => { + const output = + ' 1 0 1 Ss /sbin/launchd\n 812 1 812 S node worker.js --frontmcp-owner=a:9 x\n 813 1 1 Z+ [node] \nbogus\n\n'; expect(parseProcessList(output)).toEqual([ - { pid: 1, ppid: 0, pgid: 1, command: '/sbin/launchd' }, - { pid: 812, ppid: 1, pgid: 812, command: 'node worker.js --frontmcp-owner=a:9 x' }, + { pid: 1, ppid: 0, pgid: 1, zombie: false, command: '/sbin/launchd' }, + { pid: 812, ppid: 1, pgid: 812, zombie: false, command: 'node worker.js --frontmcp-owner=a:9 x' }, + { pid: 813, ppid: 1, pgid: 1, zombie: true, command: '[node] ' }, ]); }); }); @@ -114,11 +142,11 @@ describe('reapOrphans', () => { it('stops a marked process whose owner is gone, with its descendants and its group', async () => { const { deps, signals } = scenario( [ - { pid: 100, ppid: 1, pgid: 100, command: 'node worker.js --frontmcp-owner=tasks:50' }, - { pid: 101, ppid: 100, pgid: 100, command: 'node child.js' }, - { pid: 102, ppid: 101, pgid: 100, command: 'node grandchild.js' }, - { pid: 103, ppid: 1, pgid: 100, command: 'node left-in-group.js' }, - { pid: 200, ppid: 1, pgid: 200, command: 'node unmarked.js' }, + { pid: 100, ppid: 1, pgid: 100, zombie: false, command: 'node worker.js --frontmcp-owner=tasks:50' }, + { pid: 101, ppid: 100, pgid: 100, zombie: false, command: 'node child.js' }, + { pid: 102, ppid: 101, pgid: 100, zombie: false, command: 'node grandchild.js' }, + { pid: 103, ppid: 1, pgid: 100, zombie: false, command: 'node left-in-group.js' }, + { pid: 200, ppid: 1, pgid: 200, zombie: false, command: 'node unmarked.js' }, ], [], ); @@ -132,11 +160,17 @@ describe('reapOrphans', () => { it('leaves processes whose owner still runs, other markers and this process alone', async () => { const { deps, signals } = scenario( [ - { pid: 300, ppid: 60, pgid: 300, command: 'node a.js --frontmcp-owner=tasks:60' }, - { pid: 301, ppid: 1, pgid: 301, command: 'node b.js --frontmcp-owner=other:61' }, - { pid: 302, ppid: 1, pgid: 302, command: 'node c.js --frontmcp-owner=tasks-2:62' }, - { pid: 303, ppid: 1, pgid: 303, command: 'node d.js --frontmcp-owner=tasks:not-a-pid' }, - { pid: process.pid, ppid: 1, pgid: process.pid, command: 'node self.js --frontmcp-owner=tasks:63' }, + { pid: 300, ppid: 60, pgid: 300, zombie: false, command: 'node a.js --frontmcp-owner=tasks:60' }, + { pid: 301, ppid: 1, pgid: 301, zombie: false, command: 'node b.js --frontmcp-owner=other:61' }, + { pid: 302, ppid: 1, pgid: 302, zombie: false, command: 'node c.js --frontmcp-owner=tasks-2:62' }, + { pid: 303, ppid: 1, pgid: 303, zombie: false, command: 'node d.js --frontmcp-owner=tasks:not-a-pid' }, + { + pid: process.pid, + ppid: 1, + pgid: process.pid, + zombie: false, + command: 'node self.js --frontmcp-owner=tasks:63', + }, ], [60], ); @@ -145,17 +179,67 @@ describe('reapOrphans', () => { expect(signals).toEqual([]); }); + it('never stops this process or its ancestors, even when they carry the marker of a gone owner', async () => { + const { deps, signals } = scenario( + [ + { + pid: 650, + ppid: 1, + pgid: 650, + zombie: false, + command: 'npx tsx main.ts __run-task t1 --frontmcp-owner=tasks:50', + }, + { + pid: 700, + ppid: 650, + pgid: 700, + zombie: false, + command: 'tsx main.ts __run-task t1 --frontmcp-owner=tasks:50', + }, + { pid: 701, ppid: 700, pgid: 700, zombie: false, command: 'node sibling.js' }, + { + pid: process.pid, + ppid: 700, + pgid: 700, + zombie: false, + command: 'node main.ts __run-task t1 --frontmcp-owner=tasks:50', + }, + { pid: 900, ppid: 1, pgid: 900, zombie: false, command: 'node other.js --frontmcp-owner=tasks:50' }, + ], + [], + ); + + await expect(reapOrphans('tasks', {}, deps)).resolves.toEqual([900]); + expect(signals).toEqual([[900, 'SIGTERM']]); + }); + + it("leaves the leader of this process's own group alone", async () => { + const { deps, signals } = scenario( + [ + { pid: 800, ppid: 1, pgid: 800, zombie: false, command: 'node leader.js --frontmcp-owner=tasks:50' }, + { pid: 801, ppid: 1, pgid: 800, zombie: false, command: 'node sibling.js' }, + { pid: process.pid, ppid: 1, pgid: 800, zombie: false, command: 'node reaper.js' }, + ], + [], + ); + + await expect(reapOrphans('tasks', {}, deps)).resolves.toEqual([]); + expect(signals).toEqual([]); + }); + it('treats an owner it may not signal as running', async () => { const kill = jest.fn((pid: number) => { if (pid === 70) throw Object.assign(new Error('kill EPERM'), { code: 'EPERM' }); }); - const listProcesses = () => [{ pid: 400, ppid: 1, pgid: 400, command: 'x --frontmcp-owner=tasks:70' }]; + const listProcesses = () => [ + { pid: 400, ppid: 1, pgid: 400, zombie: false, command: 'x --frontmcp-owner=tasks:70' }, + ]; await expect(reapOrphans('tasks', {}, { platform: 'darwin', kill, sleep, listProcesses })).resolves.toEqual([]); expect(kill).toHaveBeenCalledTimes(1); }); it('kills what outlives the grace period', async () => { - const processes = [{ pid: 500, ppid: 1, pgid: 500, command: 'x --frontmcp-owner=tasks:80' }]; + const processes = [{ pid: 500, ppid: 1, pgid: 500, zombie: false, command: 'x --frontmcp-owner=tasks:80' }]; let alive = true; const kill = jest.fn((pid: number, signal: NodeJS.Signals | 0) => { if (pid === 80) throw esrch(); @@ -170,11 +254,29 @@ describe('reapOrphans', () => { expect(kill).toHaveBeenCalledWith(500, 'SIGKILL'); }); + it('counts an orphan that exited but was not collected by its new parent (a zombie) as stopped', async () => { + let exited = false; + const kill = jest.fn((pid: number, signal: NodeJS.Signals | 0) => { + if (pid === 95) throw esrch(); + if (signal === 'SIGTERM') exited = true; + }); + const listProcesses = () => [ + exited + ? { pid: 650, ppid: 1, pgid: 650, zombie: true, command: '[node] ' } + : { pid: 650, ppid: 1, pgid: 650, zombie: false, command: 'x --frontmcp-owner=tasks:95' }, + ]; + + await expect(reapOrphans('tasks', {}, { platform: 'linux', kill, sleep, listProcesses })).resolves.toEqual([650]); + expect(kill).not.toHaveBeenCalledWith(650, 'SIGKILL'); + }); + it('does not report an orphan that survives even SIGKILL', async () => { const kill = jest.fn((pid: number) => { if (pid === 90) throw esrch(); }); - const listProcesses = () => [{ pid: 600, ppid: 1, pgid: 600, command: 'x --frontmcp-owner=tasks:90' }]; + const listProcesses = () => [ + { pid: 600, ppid: 1, pgid: 600, zombie: false, command: 'x --frontmcp-owner=tasks:90' }, + ]; await expect( reapOrphans('tasks', { graceMs: 0 }, { platform: 'linux', kill, sleep, listProcesses }), ).resolves.toEqual([]); diff --git a/libs/utils/src/process/owned-process.ts b/libs/utils/src/process/owned-process.ts index b5aad2088..97d3d121a 100644 --- a/libs/utils/src/process/owned-process.ts +++ b/libs/utils/src/process/owned-process.ts @@ -29,7 +29,10 @@ export interface SpawnOwnedOptions extends SpawnOptions { export interface OwnedProcess { readonly child: ChildProcess; - /** Stop the child and every process it started; resolves `true` once they are gone. */ + /** + * Stop the child, with every process it started when it was spawned `detached` (always on Windows), killing what + * outlives `graceMs`; resolves `true` once they are gone. + */ stop(signal?: NodeJS.Signals, graceMs?: number): Promise; } @@ -37,6 +40,8 @@ export interface ProcessInfo { pid: number; ppid: number; pgid: number; + /** Exited, but not yet collected by its parent: `kill(pid, 0)` still reaches it. */ + zombie: boolean; command: string; } @@ -73,26 +78,58 @@ export function spawnOwned(command: string, args: readonly string[], options: Sp ...spawnOptions, env: { ...(spawnOptions.env ?? process.env), [OWNER_ENV_VAR]: owner }, }); + // On POSIX only a detached child leads a process group, which is what stopProcessTree signals. + const stopsTree = spawnOptions.detached === true || process.platform === 'win32'; return { child, - stop: (signal = 'SIGTERM', graceMs = 5000) => stopProcessTree(child, signal, graceMs), + stop: (signal = 'SIGTERM', graceMs = 5000) => + stopsTree ? stopProcessTree(child, signal, graceMs) : stopChild(child, signal, graceMs), }; } -/** Parse `ps -o pid=,ppid=,pgid=,command=` output; lines it cannot read are skipped. */ +function exitsWithin(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true); + return new Promise((resolve) => { + const onExit = (): void => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + child.removeListener('exit', onExit); + resolve(false); + }, timeoutMs); + child.once('exit', onExit); + }); +} + +async function stopChild(child: ChildProcess, signal: NodeJS.Signals, graceMs: number): Promise { + if (child.pid === undefined) return true; + child.kill(signal); + if (await exitsWithin(child, graceMs)) return true; + child.kill('SIGKILL'); + return exitsWithin(child, KILL_WAIT_MS); +} + +/** Parse `ps -o pid=,ppid=,pgid=,stat=,command=` output; lines it cannot read are skipped. */ export function parseProcessList(output: string): ProcessInfo[] { const processes: ProcessInfo[] = []; for (const line of output.split('\n')) { - const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line); + const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line); if (match) { - processes.push({ pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), command: match[4] }); + processes.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + pgid: Number(match[3]), + zombie: match[4].startsWith('Z'), + command: match[5], + }); } } return processes; } function listProcesses(): ProcessInfo[] { - const result = getChildProcess().spawnSync('ps', ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,command='], { + const result = getChildProcess().spawnSync('ps', ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,stat=,command='], { encoding: 'utf8', }); if (result.error || result.status !== 0) { @@ -109,8 +146,22 @@ function ownerPidOf(command: string, marker: string): number | undefined { return pid && /^\d+$/.test(pid) ? Number(pid) : undefined; } -/** The orphans, the processes they started, and the rest of each orphan's process group. */ -function treesOf(orphans: ProcessInfo[], processes: ProcessInfo[]): Set { +/** This process, its ancestors and the leader of its process group: never reaped, so the reaper never stops itself. */ +function ownLineage(processes: ProcessInfo[]): Set { + const byPid = new Map(processes.map((info) => [info.pid, info])); + const lineage = new Set([process.pid]); + const self = byPid.get(process.pid); + let ancestor = self && byPid.get(self.ppid); + while (ancestor && !lineage.has(ancestor.pid)) { + lineage.add(ancestor.pid); + ancestor = byPid.get(ancestor.ppid); + } + if (self) lineage.add(self.pgid); + return lineage; +} + +/** The orphans, the processes they started, and the rest of each orphan's process group, minus `spared`. */ +function treesOf(orphans: ProcessInfo[], processes: ProcessInfo[], spared: Set): Set { const members = new Set(); const pending = orphans.map((orphan) => orphan.pid); for (const orphan of orphans) { @@ -120,7 +171,7 @@ function treesOf(orphans: ProcessInfo[], processes: ProcessInfo[]): Set } while (pending.length > 0) { const pid = pending.pop() as number; - if (members.has(pid) || pid === process.pid) continue; + if (members.has(pid) || spared.has(pid)) continue; members.add(pid); for (const info of processes) if (info.ppid === pid) pending.push(info.pid); } @@ -137,12 +188,25 @@ function signalEach(pids: Iterable, signal: NodeJS.Signals, d: ReapOrpha } } +/** The pids still running; a zombie counts as gone, as its new parent may never collect it (PID 1 in a container). */ +function stillRunning(pids: number[], d: ReapOrphansDeps): number[] { + const signalled = pids.filter((pid) => isPidRunning(pid, d)); + if (signalled.length === 0) return signalled; + const zombies = new Set( + d + .listProcesses() + .filter((info) => info.zombie) + .map((info) => info.pid), + ); + return signalled.filter((pid) => !zombies.has(pid)); +} + async function waitForExit(pids: number[], timeoutMs: number, d: ReapOrphansDeps): Promise { const deadline = Date.now() + timeoutMs; - let running = pids.filter((pid) => isPidRunning(pid, d)); + let running = stillRunning(pids, d); while (running.length > 0 && Date.now() < deadline) { await d.sleep(50); - running = running.filter((pid) => isPidRunning(pid, d)); + running = stillRunning(running, d); } return running; } @@ -150,7 +214,8 @@ async function waitForExit(pids: number[], timeoutMs: number, d: ReapOrphansDeps /** * Stop the processes {@link spawnOwned} marked with `marker` whose owner no longer runs, with every process they * started: `SIGTERM`, then `SIGKILL` for what is still running after `graceMs`. A process whose owner still runs, - * or that carries another marker or none, is left alone. Resolves with the pids of the marked orphans it stopped. + * or that carries another marker or none, is left alone, and so are this process, its ancestors and the leader of + * its process group. Resolves with the pids of the marked orphans it stopped. * * POSIX only: it reads the process table with `ps`. On Windows it does nothing and resolves with `[]`. * @@ -172,18 +237,21 @@ export async function reapOrphans( if (d.platform === 'win32') return []; const processes = d.listProcesses(); + const lineage = ownLineage(processes); const orphans = processes.filter((info) => { const owner = ownerPidOf(info.command, marker); - return owner !== undefined && info.pid !== process.pid && !isPidRunning(owner, d); + return owner !== undefined && !lineage.has(info.pid) && !isPidRunning(owner, d); }); if (orphans.length === 0) return []; - const tree = [...treesOf(orphans, processes)]; + const tree = [...treesOf(orphans, processes, lineage)]; signalEach(tree, 'SIGTERM', d); const survivors = await waitForExit(tree, options.graceMs ?? DEFAULT_REAP_GRACE_MS, d); if (survivors.length > 0) { signalEach(survivors, 'SIGKILL', d); await waitForExit(survivors, KILL_WAIT_MS, d); } - return orphans.map((orphan) => orphan.pid).filter((pid) => !isPidRunning(pid, d)); + const orphanPids = orphans.map((orphan) => orphan.pid); + const left = new Set(stillRunning(orphanPids, d)); + return orphanPids.filter((pid) => !left.has(pid)); } From 9cda78828974a20924d4595377185845429cfa77 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 13:20:59 +0200 Subject: [PATCH 7/8] fix(sdk): answer the stdio requests still in flight when stdin ends, before shutting down A stdio client that writes its requests and closes stdin (`cat requests.jsonl | node dist/main.js`) lost the reply to any request still running: the orphan watch started the shutdown as soon as stdin ended, and closed the server under it. Now `exitOnShutdownSignals()` tells the shutdown what triggered it, and on `stdin-ended` `runStdio()` first waits, for up to 2 seconds (`STDIO_DRAIN_MS`), until the stdio router has written every reply it owes (`StdioRevisionRouter.whenIdle()`; a cancelled request and a `subscriptions/listen` stream owe none). A signal also stops the orphan watch, so a shutdown already under way logs no second reason. --- .../e2e/stdio-orphaned.e2e.spec.ts | 24 ++++++ .../src/stdio-orphan-entrypoint.ts | 16 +++- .../__tests__/exit-when-orphaned.spec.ts | 13 ++++ libs/sdk/src/front-mcp/front-mcp.ts | 14 +++- libs/sdk/src/front-mcp/shutdown-signals.ts | 31 +++++--- .../__tests__/stdio-revision-router.spec.ts | 74 +++++++++++++++++++ .../src/transport/stdio-revision-router.ts | 57 +++++++++++--- .../references/mcp-client-integration.md | 2 + 8 files changed, 207 insertions(+), 24 deletions(-) diff --git a/apps/e2e/demo-e2e-stdio-transport/e2e/stdio-orphaned.e2e.spec.ts b/apps/e2e/demo-e2e-stdio-transport/e2e/stdio-orphaned.e2e.spec.ts index 01cb257b7..de7cdb213 100644 --- a/apps/e2e/demo-e2e-stdio-transport/e2e/stdio-orphaned.e2e.spec.ts +++ b/apps/e2e/demo-e2e-stdio-transport/e2e/stdio-orphaned.e2e.spec.ts @@ -79,6 +79,30 @@ describe('stdio server without its client', () => { expect(fs.readFileSync(shutdownMarker, 'utf8')).toBe('shutdown'); }, 60_000); + it('answers a request still in flight when stdin closes, then runs the shutdown phase and exits 0', async () => { + server = StdioJsonRpcProcess.start('src/stdio-orphan-entrypoint.ts', projectPath, { + SHUTDOWN_MARKER: shutdownMarker, + }); + expect((await server.request(initialize)).result).toBeDefined(); + server.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + server.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'slow-echo', arguments: { text: 'still owed', ms: 500 } }, + }); + + const exitCode = await Promise.race([ + server.closeStdin(), + new Promise((resolve) => setTimeout(() => resolve('still running'), EXIT_BOUND_MS)), + ]); + const reply = await server.waitFor((message) => message.id === 2); + + expect(exitCode).toBe(0); + expect(reply.result?.['structuredContent']).toEqual({ echo: 'still owed' }); + expect(fs.readFileSync(shutdownMarker, 'utf8')).toBe('shutdown'); + }, 60_000); + it('runs the shutdown phase and exits within a bounded time when its parent dies and stdin stays open', async () => { const pidFile = path.join(directory, 'pids.json'); server = StdioJsonRpcProcess.start('src/stdio-orphan-parent.ts', projectPath, { diff --git a/apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-entrypoint.ts b/apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-entrypoint.ts index 0ba36e716..34aa35674 100644 --- a/apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-entrypoint.ts +++ b/apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-entrypoint.ts @@ -1,8 +1,10 @@ /** * Stdio entrypoint with a provider that keeps a timer running, as a connection pool would, until `onShutdown()` - * clears it and writes `SHUTDOWN_MARKER`. Only the shutdown phase lets the process end. + * clears it and writes `SHUTDOWN_MARKER`. Only the shutdown phase lets the process end. `slow-echo` answers after + * `ms` milliseconds, to leave a request in flight. */ -import { App, FrontMcpInstance, Provider, type OnReady, type OnShutdown } from '@frontmcp/sdk'; +import { z } from '@frontmcp/lazy-zod'; +import { App, FrontMcpInstance, Provider, Tool, ToolContext, type OnReady, type OnShutdown } from '@frontmcp/sdk'; import { writeFile } from '@frontmcp/utils'; import { serverConfig } from './config.js'; @@ -22,7 +24,15 @@ class KeepAlive implements OnReady, OnShutdown { } } -@App({ name: 'Orphan', providers: [KeepAlive] }) +@Tool({ name: 'slow-echo', inputSchema: { text: z.string(), ms: z.number() } }) +class SlowEchoTool extends ToolContext { + async execute(input: { text: string; ms: number }) { + await new Promise((resolve) => setTimeout(resolve, input.ms)); + return { echo: input.text }; + } +} + +@App({ name: 'Orphan', providers: [KeepAlive], tools: [SlowEchoTool] }) class OrphanApp {} FrontMcpInstance.runStdio({ ...serverConfig, apps: [...serverConfig.apps, OrphanApp] }).catch((err) => { diff --git a/libs/sdk/src/front-mcp/__tests__/exit-when-orphaned.spec.ts b/libs/sdk/src/front-mcp/__tests__/exit-when-orphaned.spec.ts index f05cfbed6..5f2bca54e 100644 --- a/libs/sdk/src/front-mcp/__tests__/exit-when-orphaned.spec.ts +++ b/libs/sdk/src/front-mcp/__tests__/exit-when-orphaned.spec.ts @@ -98,11 +98,24 @@ describe('exitOnShutdownSignals({ whenOrphaned })', () => { await flush(); expect(shutdown).toHaveBeenCalledTimes(1); + expect(shutdown).toHaveBeenCalledWith(reason); expect(target.exit).toHaveBeenCalledWith(0); expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('shutting down')); }, ); + it('stops watching once a signal starts the shutdown', async () => { + const target = new FakeProcess(); + const shutdown = jest.fn().mockResolvedValue(undefined); + exitOnShutdownSignals(shutdown, { target, whenOrphaned: {} }); + + target.emit('SIGTERM'); + await flush(); + + expect(shutdown).toHaveBeenCalledWith('signal'); + expect(mockWatchers[0].close).toHaveBeenCalled(); + }); + it('watches nothing unless asked, and stops watching when removed', () => { const target = new FakeProcess(); exitOnShutdownSignals(jest.fn(), { target }); diff --git a/libs/sdk/src/front-mcp/front-mcp.ts b/libs/sdk/src/front-mcp/front-mcp.ts index 66e2a53e5..69ce4451d 100644 --- a/libs/sdk/src/front-mcp/front-mcp.ts +++ b/libs/sdk/src/front-mcp/front-mcp.ts @@ -28,7 +28,12 @@ import { computeTaskCapabilities } from '../task/task-scope.helper'; import type { FetchHandlerCtx, WebFetchHandler } from '../transport/web-fetch-handler'; import { markDirectOnly } from './direct-only.flag'; import { createMcpGlobalProviders } from './front-mcp.providers'; -import { DEFAULT_SHUTDOWN_DEADLINE_MS, exitOnShutdownSignals, STDIO_SHUTDOWN_DEADLINE_MS } from './shutdown-signals'; +import { + DEFAULT_SHUTDOWN_DEADLINE_MS, + exitOnShutdownSignals, + STDIO_DRAIN_MS, + STDIO_SHUTDOWN_DEADLINE_MS, +} from './shutdown-signals'; /** * A `@FrontMcp`-decorated server class, or the raw config object it wraps. @@ -714,7 +719,8 @@ export class FrontMcpInstance implements FrontMcpInterface { * One connection serves every revision: `initialize` opens a session, a 2026-07-28 request is served statelessly. * * When stdin ends or the parent process exits, the server shuts down as on SIGTERM and exits 0, so a client that - * dies leaves no orphan behind; `lifecycle: { exitWhenOrphaned: false }` turns that off. + * dies leaves no orphan behind; when stdin ends, the requests in flight are answered first (for up to 2 seconds). + * `lifecycle: { exitWhenOrphaned: false }` turns that off. * * Accepts either a `@FrontMcp`-decorated class or the same config object you * pass to `@FrontMcp()` (#450). @@ -945,7 +951,9 @@ export class FrontMcpInstance implements FrontMcpInterface { // The graceful shutdown of the HTTP and socket servers (#712): every scope is shut down, then // disposed, so `onDispose` callbacks run under stdio too. It also runs when the client goes away. exitOnShutdownSignals( - async () => { + async (trigger) => { + // A client that closed stdin may still read the replies to the requests it sent before. + if (trigger === 'stdin-ended') await transport.whenIdle(STDIO_DRAIN_MS); scope.notifications.unregisterServer(sessionId); try { await frontMcp.shutdown(); diff --git a/libs/sdk/src/front-mcp/shutdown-signals.ts b/libs/sdk/src/front-mcp/shutdown-signals.ts index da0e6c0db..440e8a71f 100644 --- a/libs/sdk/src/front-mcp/shutdown-signals.ts +++ b/libs/sdk/src/front-mcp/shutdown-signals.ts @@ -1,6 +1,6 @@ import type { Readable } from 'node:stream'; -import { watchParent, type ParentGoneReason } from '@frontmcp/utils'; +import { watchParent, type ParentGoneReason, type ProcessWatcher } from '@frontmcp/utils'; const SHUTDOWN_SIGNALS = ['SIGTERM', 'SIGINT'] as const; @@ -14,6 +14,12 @@ export const DEFAULT_SHUTDOWN_DEADLINE_MS = 10_000; /** A stdio client waits on the process to exit, so stdio keeps its shorter deadline. */ export const STDIO_SHUTDOWN_DEADLINE_MS = 5_000; +/** How long a stdio server whose stdin ended waits for the replies it still owes before it shuts down. */ +export const STDIO_DRAIN_MS = 2_000; + +/** What started the shutdown: a signal, or the process being orphaned (`lifecycle.exitWhenOrphaned`). */ +export type ShutdownTrigger = 'signal' | ParentGoneReason; + interface SignalTarget { on(signal: string, listener: () => void): unknown; removeListener(signal: string, listener: () => void): unknown; @@ -34,20 +40,25 @@ export interface ShutdownSignalOptions { * On SIGTERM or SIGINT, run `shutdown` once and exit: code 0 when it completes, 1 when it fails * or outlasts the deadline (#712). The deadline timer is ref'd, so the process stays up while * the shutdown runs even after the server stops listening. With `whenOrphaned`, the parent - * exiting (or stdin ending) does the same. + * exiting (or stdin ending) does the same. `shutdown` is told which of them started it. * * @returns A function that removes the signal listeners and stops watching the parent */ -export function exitOnShutdownSignals(shutdown: () => Promise, options: ShutdownSignalOptions = {}): () => void { +export function exitOnShutdownSignals( + shutdown: (trigger: ShutdownTrigger) => Promise, + options: ShutdownSignalOptions = {}, +): () => void { const target = options.target ?? process; const deadlineMs = options.deadlineMs ?? DEFAULT_SHUTDOWN_DEADLINE_MS; let shuttingDown = false; + let watcher: ProcessWatcher | undefined; - const onSignal = (): void => { + const begin = (trigger: ShutdownTrigger): void => { if (shuttingDown) return; shuttingDown = true; + watcher?.close(); const deadline = setTimeout(() => target.exit(1), deadlineMs); - shutdown().then( + shutdown(trigger).then( () => { clearTimeout(deadline); target.exit(0); @@ -60,16 +71,18 @@ export function exitOnShutdownSignals(shutdown: () => Promise, options: Sh ); }; + const onSignal = (): void => begin('signal'); + for (const signal of SHUTDOWN_SIGNALS) target.on(signal, onSignal); - const watcher = - options.whenOrphaned && - watchParent({ + if (options.whenOrphaned) { + watcher = watchParent({ stdin: options.whenOrphaned.stdin, onGone: (reason) => { options.logger?.info?.(ORPHANED_MESSAGES[reason]); - onSignal(); + begin(reason); }, }); + } return () => { watcher?.close(); for (const signal of SHUTDOWN_SIGNALS) target.removeListener(signal, onSignal); diff --git a/libs/sdk/src/transport/__tests__/stdio-revision-router.spec.ts b/libs/sdk/src/transport/__tests__/stdio-revision-router.spec.ts index 8bbb24c05..fc890143b 100644 --- a/libs/sdk/src/transport/__tests__/stdio-revision-router.spec.ts +++ b/libs/sdk/src/transport/__tests__/stdio-revision-router.spec.ts @@ -576,6 +576,80 @@ describe('StdioRevisionRouter', () => { }); }); + describe('whenIdle', () => { + it('resolves at once when no request is waiting for its reply', async () => { + const { router } = await connect(scope); + + await expect(router.whenIdle(60_000)).resolves.toBeUndefined(); + }); + + it('waits until the session-era server sent its reply', async () => { + const { stdio, router } = await connect(scope); + const call = unversionedRequest('tools/call', { name: 'add', arguments: { a: 1, b: 1 } }); + const idle = jest.fn(); + stdio.receive(call); + + const waiting = router.whenIdle(60_000).then(idle); + await settle(); + expect(idle).not.toHaveBeenCalled(); + await router.send({ jsonrpc: '2.0', id: call.id, result: {} }); + await waiting; + + expect(idle).toHaveBeenCalled(); + }); + + it('waits until a 2026-07-28 request is answered', async () => { + const { stdio, router } = await connect(scope); + const request = modernRequest('tools/call', { name: 'add', arguments: { a: 2, b: 3 } }); + stdio.receive(request); + + await router.whenIdle(60_000); + + expect(stdio.written.some((message) => isResponse(message) && message.id === request.id)).toBe(true); + }); + + it('stops waiting for a request the client cancelled', async () => { + const { stdio, router } = await connect(scope); + const call = unversionedRequest('tools/call', { name: 'add', arguments: { a: 1, b: 1 } }); + stdio.receive(call); + stdio.receive({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: call.id } }); + + await expect(router.whenIdle(60_000)).resolves.toBeUndefined(); + }); + + it('does not wait for a subscriptions/listen stream, which only a cancellation ends', async () => { + const { stdio, router } = await connect(scope); + const listen = modernRequest('subscriptions/listen', { notifications: { resourceSubscriptions: [NOTE_URI] } }); + stdio.receive(listen); + await stdio.waitFor( + (message) => 'method' in message && message.method === 'notifications/subscriptions/acknowledged', + ); + + try { + await expect(router.whenIdle(60_000)).resolves.toBeUndefined(); + } finally { + stdio.receive({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listen.id } }); + } + }); + + it('stops waiting when the connection closes', async () => { + const { stdio, router } = await connect(scope); + stdio.receive(unversionedRequest('tools/call', { name: 'add', arguments: { a: 1, b: 1 } })); + const idle = router.whenIdle(60_000); + + await router.close(); + + await expect(idle).resolves.toBeUndefined(); + }); + + it('gives up after the timeout', async () => { + const { stdio, router } = await connect(scope); + stdio.receive(unversionedRequest('tools/call', { name: 'add', arguments: { a: 1, b: 1 } })); + + await expect(router.whenIdle(20)).resolves.toBeUndefined(); + }); + }); + describe('transport.defaultProtocolVersion', () => { let statelessScope: Scope; diff --git a/libs/sdk/src/transport/stdio-revision-router.ts b/libs/sdk/src/transport/stdio-revision-router.ts index d73edbe41..715656108 100644 --- a/libs/sdk/src/transport/stdio-revision-router.ts +++ b/libs/sdk/src/transport/stdio-revision-router.ts @@ -24,6 +24,8 @@ export interface StdioRevisionRouterOptions { principal: string; } +/** A stream the client ends by cancelling it, so it never owes a reply. */ +const LISTEN_METHOD = 'subscriptions/listen'; const EVENT_SEPARATOR = '\n\n'; const EVENT_DATA_FIELD = 'data:'; @@ -54,6 +56,12 @@ function isAsyncIterable(stream: HttpReadableLike): stream is AsyncIterable(); + private readonly awaitingReply = new Set(); + private readonly idleWaiters = new Set<() => void>(); /** `initialize` makes the connection session-era for good; a 2026-07-28 message first makes it stateless. */ private era: 'unknown' | 'legacy' | 'modern' = 'unknown'; @@ -87,7 +97,12 @@ export class StdioRevisionRouter implements Transport { async send(message: JSONRPCMessage, options?: SendOptions): Promise { // A stateless client has no session, so it gets no session broadcasts (list changes, channel events). if (this.era === 'modern' && isJSONRPCNotification(message)) return; - await this.inner.send(message, options); + try { + await this.inner.send(message, options); + } finally { + const repliedTo = isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message) ? message.id : undefined; + if (repliedTo !== undefined) this.replied(repliedTo); + } } async close(): Promise { @@ -95,8 +110,33 @@ export class StdioRevisionRouter implements Transport { await this.inner.close(); } + /** Resolves once every request received so far has had its reply written, or after `timeoutMs`. */ + whenIdle(timeoutMs: number): Promise { + if (this.awaitingReply.size === 0) return Promise.resolve(); + return new Promise((resolve) => { + const done = (): void => { + clearTimeout(timer); + this.idleWaiters.delete(done); + resolve(); + }; + const timer = setTimeout(done, timeoutMs); + this.idleWaiters.add(done); + }); + } + + private replied(id: RequestId): void { + this.awaitingReply.delete(id); + if (this.awaitingReply.size === 0) for (const done of [...this.idleWaiters]) done(); + } + private route(message: JSONRPCMessage, extra?: MessageExtra): void { - if (this.cancelsModernRequest(message)) return; + if (isJSONRPCRequest(message) && message.method !== LISTEN_METHOD) this.awaitingReply.add(message.id); + const cancelled = cancelledRequestId(message); + if (cancelled !== undefined) { + // A cancelled request gets no reply. + this.replied(cancelled); + if (this.abortsModernRequest(cancelled)) return; + } if ((isJSONRPCRequest(message) || isJSONRPCNotification(message)) && this.servesModern(message)) { if (this.era === 'unknown') this.era = 'modern'; @@ -117,12 +157,7 @@ export class StdioRevisionRouter implements Transport { }); } - private cancelsModernRequest(message: JSONRPCMessage): boolean { - if (!isJSONRPCNotification(message) || message.method !== 'notifications/cancelled') return false; - - const requestId = message.params?.['requestId']; - if (typeof requestId !== 'string' && typeof requestId !== 'number') return false; - + private abortsModernRequest(requestId: RequestId): boolean { const controller = this.inFlight.get(requestId); if (!controller) return false; @@ -145,7 +180,10 @@ export class StdioRevisionRouter implements Transport { error: error instanceof Error ? error.message : String(error), }); } finally { - if (id !== undefined && this.inFlight.get(id) === controller) this.inFlight.delete(id); + if (id !== undefined) { + if (this.inFlight.get(id) === controller) this.inFlight.delete(id); + this.replied(id); + } } } @@ -188,5 +226,6 @@ export class StdioRevisionRouter implements Transport { private cancelAll(): void { for (const controller of this.inFlight.values()) controller.abort(); this.inFlight.clear(); + for (const id of [...this.awaitingReply]) this.replied(id); } } diff --git a/libs/skills/catalog/frontmcp-deployment/references/mcp-client-integration.md b/libs/skills/catalog/frontmcp-deployment/references/mcp-client-integration.md index 48f360a1b..a369009be 100644 --- a/libs/skills/catalog/frontmcp-deployment/references/mcp-client-integration.md +++ b/libs/skills/catalog/frontmcp-deployment/references/mcp-client-integration.md @@ -135,6 +135,8 @@ The most common transport. The MCP client spawns your server as a child process - **No orphans** — when stdin ends (the client closed it or exited) or the parent process exits, the server runs its graceful shutdown (providers' `onShutdown()` included) and exits 0, as on `SIGTERM`. The parent is checked once a second. + When stdin ends, the requests still in flight are answered first (for up to + 2 seconds), so `cat requests.jsonl | node dist/main.js` gets every reply. `lifecycle: { exitWhenOrphaned: false }` turns this off ### npx (published npm package) From cb55f70b643822330fb60cb9a010e558d0e42768 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 13:32:24 +0200 Subject: [PATCH 8/8] test(utils): do not require a reaped orphan to leave the process table reapOrphans() counts a stopped orphan its new parent has not reaped yet (a zombie) as stopped, but the spec then checked it with kill(pid, 0), which still succeeds on a zombie. The result of reapOrphans() already asserts it. --- apps/e2e/demo-e2e-local-runtime/e2e/reap-orphans.e2e.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/e2e/demo-e2e-local-runtime/e2e/reap-orphans.e2e.spec.ts b/apps/e2e/demo-e2e-local-runtime/e2e/reap-orphans.e2e.spec.ts index 7a17ea545..76b83bb20 100644 --- a/apps/e2e/demo-e2e-local-runtime/e2e/reap-orphans.e2e.spec.ts +++ b/apps/e2e/demo-e2e-local-runtime/e2e/reap-orphans.e2e.spec.ts @@ -37,7 +37,6 @@ function running(pid: number): boolean { const reaped = await reapOrphans(marker); expect(reaped).toEqual([marked]); - expect(running(marked)).toBe(false); expect(running(unmarked)).toBe(true); expect(ours.child.exitCode).toBeNull(); await expect(reapOrphans(marker)).resolves.toEqual([]);