diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c3490..e810e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --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 diff --git a/docs/daemon.md b/docs/daemon.md index 207a300..5d53da4 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -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. diff --git a/src/daemon/launch.ts b/src/daemon/launch.ts index 162369e..dab9e36 100644 --- a/src/daemon/launch.ts +++ b/src/daemon/launch.ts @@ -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.'); } diff --git a/tests/daemon-launch.test.ts b/tests/daemon-launch.test.ts new file mode 100644 index 0000000..eb2e988 --- /dev/null +++ b/tests/daemon-launch.test.ts @@ -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'); + }); +});