Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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([]);
});
Expand Down
54 changes: 54 additions & 0 deletions apps/e2e/demo-e2e-dts-consumer/fixture/process-kit-must-compile.ts
Original file line number Diff line number Diff line change
@@ -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<number[]> {
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') }),
];
}
1 change: 1 addition & 0 deletions apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions apps/e2e/demo-e2e-local-runtime/e2e/reap-orphans.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/** 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<OrphanOwnerResult>('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(unmarked)).toBe(true);
expect(ours.child.exitCode).toBeNull();
await expect(reapOrphans(marker)).resolves.toEqual([]);
});
});
25 changes: 25 additions & 0 deletions apps/e2e/demo-e2e-local-runtime/fixture/orphan-owner.ts
Original file line number Diff line number Diff line change
@@ -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);
});
21 changes: 20 additions & 1 deletion apps/e2e/demo-e2e-stdio-transport/e2e/helpers/stdio-json-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,27 @@ export class StdioJsonRpcProcess {
return exited;
}

async stop(): Promise<void> {
/** End stdin, leaving the process unsignalled, and resolve with the exit code. */
async closeStdin(): Promise<number | null> {
if (this.hasExited()) return this.child.exitCode;
const exited = new Promise<number | null>((resolve) => this.child.once('exit', (code) => resolve(code)));
this.child.stdin.end();
return exited;
}

/** Kill the process outright, leaving stdin open. */
async kill(): Promise<void> {
if (this.hasExited()) return;
const exited = new Promise((resolve) => this.child.once('exit', resolve));
this.child.kill('SIGKILL');
await exited;
}

async stop(): Promise<void> {
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');
Expand Down
124 changes: 124 additions & 0 deletions apps/e2e/demo-e2e-stdio-transport/e2e/stdio-orphaned.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* 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<T>(read: () => T | undefined, timeoutMs: number): Promise<T | undefined> {
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<string>((resolve) => setTimeout(() => resolve('still running'), EXIT_BOUND_MS)),
]);

expect(exitCode).toBe(0);
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<string>((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, {
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);
});
41 changes: 41 additions & 0 deletions apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-entrypoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* 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. `slow-echo` answers after
* `ms` milliseconds, to leave a request in flight.
*/
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';

@Provider({ name: 'KeepAlive' })
class KeepAlive implements OnReady, OnShutdown {
private timer?: ReturnType<typeof setInterval>;

onReady(): void {
this.timer = setInterval(() => undefined, 60_000);
}

async onShutdown(): Promise<void> {
clearInterval(this.timer);
const markerPath = process.env['SHUTDOWN_MARKER'];
if (markerPath) await writeFile(markerPath, 'shutdown');
}
}

@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) => {
console.error('Failed to start stdio server:', err);
process.exit(1);
});
19 changes: 19 additions & 0 deletions apps/e2e/demo-e2e-stdio-transport/src/stdio-orphan-parent.ts
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 2 additions & 1 deletion libs/cli/src/commands/dev/bridge/child-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 2 additions & 1 deletion libs/cli/src/commands/dev/dev.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down
Loading
Loading