fix: start bun daemon children through nohup

This commit is contained in:
Peter Steinberger 2026-05-04 07:32:27 +01:00
parent 0e50f2b564
commit 07ac8ea4c0
No known key found for this signature in database
4 changed files with 140 additions and 15 deletions

View File

@ -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

View File

@ -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.

View File

@ -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.');
}

View 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');
});
});