fix: start bun daemon children through nohup
This commit is contained in:
parent
0e50f2b564
commit
07ac8ea4c0
@ -19,6 +19,7 @@
|
||||
- Support `mcporter list server.tool --schema` to print a single tool's schema instead of the whole server. (Issue #116)
|
||||
- Surface MCP server `instructions` from the initialize response in single-server `mcporter list` text and JSON output. (Issue #76)
|
||||
- Add compact `mcporter list <server> --brief` / `--signatures` output for scanning signatures without doc blocks, examples, or schemas. (PR #144, thanks @yuhp)
|
||||
- Launch Bun-compiled macOS daemon children through `nohup` so Homebrew binaries can start keep-alive daemons in the background on macOS 26. (Issue #66)
|
||||
|
||||
### Config
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@ read_when:
|
||||
## Lifecycle & Fault Handling
|
||||
|
||||
- **Auto start:** First call requiring the daemon triggers a lightweight bootstrap (fork/exec via `child_process.spawn` inside the CLI). We ensure the original command waits for the socket to become available (with a short timeout).
|
||||
- **macOS Bun binaries:** Homebrew/Bun-compiled binaries wrap the detached child launch with `nohup` so the background daemon survives the parent CLI exit on macOS 26.
|
||||
- **Auto restart:** The client shim treats `ECONNREFUSED`/broken pipe as a signal that the daemon died. It retries once by re-launching the daemon before surfacing the error.
|
||||
- **Idle timeout:** Each keep-alive server can specify `idleTimeoutMs` (default `null` = never). The daemon tracks last activity timestamps and auto-closes transports (and associated external processes) after the idle window. A global `daemonIdleTimeoutMs` can shut down the entire daemon after long inactivity.
|
||||
- **Logging:** Daemon writes structured logs under `~/.mcporter/logs/daemon.log` plus per-server logs for STDIO stderr so users can debug crashing servers.
|
||||
|
||||
@ -10,11 +10,44 @@ export interface DaemonLaunchOptions {
|
||||
readonly extraArgs?: string[];
|
||||
}
|
||||
|
||||
interface DaemonLaunchProcessInfo {
|
||||
readonly argvEntry?: string;
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
readonly execArgv: string[];
|
||||
readonly execPath: string;
|
||||
readonly platform: NodeJS.Platform;
|
||||
}
|
||||
|
||||
interface DaemonLaunchInvocation {
|
||||
readonly command: string;
|
||||
readonly args: string[];
|
||||
readonly env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export function launchDaemonDetached(options: DaemonLaunchOptions): void {
|
||||
const cliEntry = resolveCliEntry();
|
||||
const invocation = buildDaemonLaunchInvocation(options);
|
||||
const child = spawn(invocation.command, invocation.args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: invocation.env,
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
export function buildDaemonLaunchInvocation(
|
||||
options: DaemonLaunchOptions,
|
||||
processInfo: DaemonLaunchProcessInfo = {
|
||||
argvEntry: process.argv[1],
|
||||
env: process.env,
|
||||
execArgv: process.execArgv,
|
||||
execPath: process.execPath,
|
||||
platform: process.platform,
|
||||
}
|
||||
): DaemonLaunchInvocation {
|
||||
const cliEntry = resolveCliEntry(processInfo.argvEntry);
|
||||
const configArgs = options.configExplicit ? ['--config', options.configPath] : [];
|
||||
const args = [
|
||||
...process.execArgv,
|
||||
...processInfo.execArgv,
|
||||
...(cliEntry ? [cliEntry] : []),
|
||||
...configArgs,
|
||||
...(options.rootDir ? ['--root', options.rootDir] : []),
|
||||
@ -23,21 +56,31 @@ export function launchDaemonDetached(options: DaemonLaunchOptions): void {
|
||||
'--foreground',
|
||||
...(options.extraArgs ?? []),
|
||||
];
|
||||
const child = spawn(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
MCPORTER_DAEMON_CHILD: '1',
|
||||
MCPORTER_DAEMON_SOCKET: options.socketPath,
|
||||
MCPORTER_DAEMON_METADATA: options.metadataPath,
|
||||
},
|
||||
});
|
||||
child.unref();
|
||||
const env = {
|
||||
...processInfo.env,
|
||||
MCPORTER_DAEMON_CHILD: '1',
|
||||
MCPORTER_DAEMON_SOCKET: options.socketPath,
|
||||
MCPORTER_DAEMON_METADATA: options.metadataPath,
|
||||
};
|
||||
if (shouldWrapDetachedLaunchWithNohup(processInfo.platform, cliEntry)) {
|
||||
return {
|
||||
command: 'nohup',
|
||||
args: [processInfo.execPath, ...args],
|
||||
env,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: processInfo.execPath,
|
||||
args,
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCliEntry(): string | undefined {
|
||||
const entry = process.argv[1];
|
||||
function shouldWrapDetachedLaunchWithNohup(platform: NodeJS.Platform, cliEntry: string | undefined): boolean {
|
||||
return platform === 'darwin' && cliEntry === undefined;
|
||||
}
|
||||
|
||||
function resolveCliEntry(entry = process.argv[1]): string | undefined {
|
||||
if (!entry) {
|
||||
throw new Error('Unable to resolve mcporter entry script.');
|
||||
}
|
||||
|
||||
80
tests/daemon-launch.test.ts
Normal file
80
tests/daemon-launch.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDaemonLaunchInvocation, type DaemonLaunchOptions } from '../src/daemon/launch.js';
|
||||
|
||||
const options: DaemonLaunchOptions = {
|
||||
configPath: '/tmp/mcporter/config.json',
|
||||
configExplicit: true,
|
||||
rootDir: '/tmp/project',
|
||||
socketPath: '/tmp/mcporter/daemon.sock',
|
||||
metadataPath: '/tmp/mcporter/daemon.json',
|
||||
extraArgs: ['--log-file', '/tmp/mcporter/daemon.log'],
|
||||
};
|
||||
|
||||
describe('buildDaemonLaunchInvocation', () => {
|
||||
it('launches Node entrypoints directly with the CLI script path', () => {
|
||||
const invocation = buildDaemonLaunchInvocation(options, {
|
||||
argvEntry: '/repo/dist/cli.js',
|
||||
env: { PATH: '/usr/bin' },
|
||||
execArgv: ['--enable-source-maps'],
|
||||
execPath: '/usr/local/bin/node',
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
expect(invocation.command).toBe('/usr/local/bin/node');
|
||||
expect(invocation.args).toEqual([
|
||||
'--enable-source-maps',
|
||||
path.resolve('/repo/dist/cli.js'),
|
||||
'--config',
|
||||
'/tmp/mcporter/config.json',
|
||||
'--root',
|
||||
'/tmp/project',
|
||||
'daemon',
|
||||
'start',
|
||||
'--foreground',
|
||||
'--log-file',
|
||||
'/tmp/mcporter/daemon.log',
|
||||
]);
|
||||
expect(invocation.env.MCPORTER_DAEMON_CHILD).toBe('1');
|
||||
expect(invocation.env.MCPORTER_DAEMON_SOCKET).toBe('/tmp/mcporter/daemon.sock');
|
||||
expect(invocation.env.MCPORTER_DAEMON_METADATA).toBe('/tmp/mcporter/daemon.json');
|
||||
});
|
||||
|
||||
it('wraps compiled Bun binaries with nohup on macOS so detached self-spawn survives Tahoe', () => {
|
||||
const invocation = buildDaemonLaunchInvocation(options, {
|
||||
argvEntry: '/$bunfs/root/mcporter',
|
||||
env: { PATH: '/usr/bin' },
|
||||
execArgv: [],
|
||||
execPath: '/opt/homebrew/bin/mcporter',
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
expect(invocation.command).toBe('nohup');
|
||||
expect(invocation.args).toEqual([
|
||||
'/opt/homebrew/bin/mcporter',
|
||||
'--config',
|
||||
'/tmp/mcporter/config.json',
|
||||
'--root',
|
||||
'/tmp/project',
|
||||
'daemon',
|
||||
'start',
|
||||
'--foreground',
|
||||
'--log-file',
|
||||
'/tmp/mcporter/daemon.log',
|
||||
]);
|
||||
expect(invocation.env.MCPORTER_DAEMON_CHILD).toBe('1');
|
||||
});
|
||||
|
||||
it('keeps non-macOS compiled launches on the direct exec path', () => {
|
||||
const invocation = buildDaemonLaunchInvocation(options, {
|
||||
argvEntry: '/$bunfs/root/mcporter',
|
||||
env: {},
|
||||
execArgv: [],
|
||||
execPath: '/usr/local/bin/mcporter',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
expect(invocation.command).toBe('/usr/local/bin/mcporter');
|
||||
expect(invocation.args[0]).toBe('--config');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user