From 41a9fcd3cd9b57884ed21624b18c39086e23adf3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 15 May 2026 18:39:14 +0100 Subject: [PATCH] fix(mcp): harden wrapper restart handling --- CHANGELOG.md | 3 + peekaboo-mcp.js | 97 +++++++++++++++++++--------- tests/peekaboo-mcp-wrapper.test.mjs | 98 +++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 31 deletions(-) create mode 100644 tests/peekaboo-mcp-wrapper.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 21bbd26b..4ac61b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [3.2.1] - Unreleased +### Fixed +- `peekaboo-mcp` now shuts down cleanly during restart backoff and repairs executable permissions without shelling out through an install path. + ## [3.2.0] - 2026-05-15 ### Added diff --git a/peekaboo-mcp.js b/peekaboo-mcp.js index 9c364152..59ccdd88 100755 --- a/peekaboo-mcp.js +++ b/peekaboo-mcp.js @@ -1,30 +1,37 @@ #!/usr/bin/env node // Peekaboo MCP wrapper that restarts the Swift server on crash -import { spawn, execSync } from 'child_process'; -import { fileURLToPath } from 'url'; +import { spawn } from 'child_process'; +import { chmodSync, existsSync, realpathSync, statSync } from 'fs'; +import { fileURLToPath, pathToFileURL } from 'url'; import { dirname, join } from 'path'; -import { existsSync } from 'fs'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const binaryPath = join(__dirname, 'peekaboo'); +const modulePath = realpathSync(fileURLToPath(import.meta.url)); +const __dirname = dirname(modulePath); +const defaultBinaryPath = join(__dirname, 'peekaboo'); const MAX_RESTARTS = 5; const RESTART_WINDOW_MS = 60_000; const INITIAL_DELAY_MS = 1000; const MAX_DELAY_MS = 30_000; -class PeekabooMCPWrapper { - constructor() { +export class PeekabooMCPWrapper { + constructor(options = {}) { + this.binaryPath = options.binaryPath || defaultBinaryPath; + this.initialDelayMs = options.initialDelayMs || INITIAL_DELAY_MS; + this.maxDelayMs = options.maxDelayMs || MAX_DELAY_MS; this.restartTimestamps = []; - this.delay = INITIAL_DELAY_MS; + this.delay = this.initialDelayMs; this.child = null; + this.restartTimer = null; this.shuttingDown = false; } start() { - if (!existsSync(binaryPath)) { - console.error(`[Peekaboo MCP] Binary not found at ${binaryPath}`); + if (this.shuttingDown) return; + + if (!existsSync(this.binaryPath)) { + console.error(`[Peekaboo MCP] Binary not found at ${this.binaryPath}`); process.exit(1); } @@ -36,7 +43,7 @@ class PeekabooMCPWrapper { } console.error('[Peekaboo MCP] Starting Swift server...'); - this.child = spawn(binaryPath, ['mcp', 'serve'], { + this.child = spawn(this.binaryPath, ['mcp', 'serve'], { stdio: 'inherit', env: { ...process.env, @@ -45,6 +52,7 @@ class PeekabooMCPWrapper { }); this.child.on('exit', (code, signal) => { + this.child = null; if (this.shuttingDown) return process.exit(code || 0); if (code === 0 || signal === 'SIGINT' || signal === 'SIGTERM') { @@ -56,10 +64,12 @@ class PeekabooMCPWrapper { }); this.child.on('error', (err) => { + this.child = null; console.error('[Peekaboo MCP] Failed to launch:', err.message); if (err.code === 'EACCES') { try { - execSync(`chmod +x "${binaryPath}"`); + const mode = statSync(this.binaryPath).mode; + chmodSync(this.binaryPath, mode | 0o111); console.error('[Peekaboo MCP] Fixed executable bit, retrying...'); this.handleCrash(1); return; @@ -72,40 +82,65 @@ class PeekabooMCPWrapper { } handleCrash(code, signal) { + if (this.shuttingDown) return; + console.error(`[Peekaboo MCP] Server crashed (code ${code}${signal ? `, signal ${signal}` : ''}).`); this.restartTimestamps.push(Date.now()); - setTimeout(() => { - this.delay = Math.min(this.delay * 2, MAX_DELAY_MS); + this.clearRestartTimer(); + this.restartTimer = setTimeout(() => { + this.restartTimer = null; + if (this.shuttingDown) return; + this.delay = Math.min(this.delay * 2, this.maxDelayMs); this.start(); }, this.delay); } shutdown() { this.shuttingDown = true; + this.clearRestartTimer(); if (this.child && !this.child.killed) { this.child.kill('SIGTERM'); } } + + clearRestartTimer() { + if (this.restartTimer) { + clearTimeout(this.restartTimer); + this.restartTimer = null; + } + } } -const wrapper = new PeekabooMCPWrapper(); -wrapper.start(); +if (isMainModule()) { + const wrapper = new PeekabooMCPWrapper(); + wrapper.start(); -process.on('SIGINT', () => { - console.error('\n[Peekaboo MCP] SIGINT received, shutting down...'); - wrapper.shutdown(); -}); + process.on('SIGINT', () => { + console.error('\n[Peekaboo MCP] SIGINT received, shutting down...'); + wrapper.shutdown(); + }); -process.on('SIGTERM', () => { - console.error('[Peekaboo MCP] SIGTERM received, shutting down...'); - wrapper.shutdown(); -}); + process.on('SIGTERM', () => { + console.error('[Peekaboo MCP] SIGTERM received, shutting down...'); + wrapper.shutdown(); + }); -process.on('uncaughtException', (err) => { - console.error('[Peekaboo MCP] Uncaught exception:', err); - wrapper.shutdown(); -}); + process.on('uncaughtException', (err) => { + console.error('[Peekaboo MCP] Uncaught exception:', err); + wrapper.shutdown(); + }); -process.on('unhandledRejection', (reason) => { - console.error('[Peekaboo MCP] Unhandled rejection:', reason); -}); + process.on('unhandledRejection', (reason) => { + console.error('[Peekaboo MCP] Unhandled rejection:', reason); + }); +} + +function isMainModule() { + const entry = process.argv[1]; + if (entry === undefined) return false; + + const entryUrl = pathToFileURL(entry).href; + if (import.meta.url === entryUrl) return true; + + return pathToFileURL(realpathSync(modulePath)).href === pathToFileURL(realpathSync(entry)).href; +} diff --git a/tests/peekaboo-mcp-wrapper.test.mjs b/tests/peekaboo-mcp-wrapper.test.mjs new file mode 100644 index 00000000..1ab06be9 --- /dev/null +++ b/tests/peekaboo-mcp-wrapper.test.mjs @@ -0,0 +1,98 @@ +import { strict as assert } from 'node:assert'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { copyFile, mkdir, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { PeekabooMCPWrapper } from '../peekaboo-mcp.js'; + +test('shutdown clears pending restart backoff', async () => { + const root = await mkdtemp(join(tmpdir(), 'peekaboo-mcp-restart-')); + const countPath = join(root, 'count'); + const binaryPath = join(root, 'peekaboo'); + await writeFile( + binaryPath, + `#!/bin/sh\ncount=$(cat "${countPath}" 2>/dev/null || echo 0)\nexpr "$count" + 1 > "${countPath}"\nexit 1\n`, + { mode: 0o755 }, + ); + + const wrapper = new PeekabooMCPWrapper({ binaryPath, initialDelayMs: 50, maxDelayMs: 50 }); + wrapper.start(); + await waitFor(async () => Number(await readFile(countPath, 'utf8')) === 1); + await waitFor(() => wrapper.restartTimer !== null); + + wrapper.shutdown(); + await new Promise(resolve => setTimeout(resolve, 120)); + + assert.equal(Number(await readFile(countPath, 'utf8')), 1); + assert.equal(wrapper.restartTimer, null); +}); + +test('EACCES recovery chmods without shell expansion', async () => { + const root = await mkdtemp(join(tmpdir(), 'peekaboo-mcp-eacces-')); + const sideEffectPath = join(root, 'shell-expanded'); + const shellPath = join(root, 'shell-$(touch shell-expanded)'); + const binaryPath = join(shellPath, 'peekaboo'); + await mkdir(shellPath); + await writeFile(binaryPath, '#!/bin/sh\nexit 0\n', { mode: 0o644 }); + + const wrapper = new PeekabooMCPWrapper({ binaryPath, initialDelayMs: 1000 }); + const previousCwd = process.cwd(); + process.chdir(root); + try { + wrapper.start(); + await waitFor(async () => ((await stat(binaryPath)).mode & 0o111) !== 0); + wrapper.shutdown(); + } finally { + process.chdir(previousCwd); + } + + await assert.rejects(stat(sideEffectPath)); +}); + +test('symlink-preserved entrypoint still starts as main module', async () => { + const root = await mkdtemp(join(tmpdir(), 'peekaboo-mcp-symlink-')); + const packageDir = join(root, 'pkg'); + const binDir = join(root, 'bin'); + await mkdir(packageDir); + await mkdir(binDir); + + const countPath = join(root, 'count'); + const binaryPath = join(packageDir, 'peekaboo'); + const wrapperTarget = join(packageDir, 'peekaboo-mcp.js'); + const wrapperPath = join(binDir, 'peekaboo-mcp'); + await writeFile( + binaryPath, + `#!/bin/sh\ncount=$(cat "${countPath}" 2>/dev/null || echo 0)\nexpr "$count" + 1 > "${countPath}"\nexit 0\n`, + { mode: 0o755 }, + ); + await copyFile(fileURLToPath(new URL('../peekaboo-mcp.js', import.meta.url)), wrapperTarget); + await symlink(wrapperTarget, wrapperPath); + + const child = spawn(process.execPath, ['--preserve-symlinks-main', wrapperPath], { + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk; }); + const [code] = await once(child, 'exit'); + + assert.equal(code, 0, stderr); + assert.equal(Number(await readFile(countPath, 'utf8')), 1); +}); + +async function waitFor(predicate) { + const deadline = Date.now() + 2000; + let lastError; + while (Date.now() < deadline) { + try { + if (await predicate()) return; + } catch (error) { + lastError = error; + } + await new Promise(resolve => setTimeout(resolve, 10)); + } + if (lastError) throw lastError; + throw new Error('timed out waiting for condition'); +}