From 86fb1bc9d80f8cf2d11ec7c72c1acbfd3ffa4dbf Mon Sep 17 00:00:00 2001 From: JLMA-Pro-Trading Date: Tue, 19 May 2026 22:32:32 +0000 Subject: [PATCH] fix(mcp): exit cleanly on SIGTERM/SIGINT/stdin-end in MCP server bin/mcp-server.js had no termination signal handlers. When the parent process is killed with SIGKILL or the connection drops without closing stdin gracefully, the MCP server continues running and is reparented to init (PPID=1), where it accumulates indefinitely and consumes RSS for the lifetime of the user session. Add SIGINT, SIGTERM, and stdin 'end' handlers in main() that call process.exit(0). Adds a regression test (test/sigterm-cleanup.js) that spawns the MCP server, sends each signal, and asserts a clean exit within 5 seconds. Wires the new test into the npm test script. --- npm/packages/ruvector/bin/mcp-server.js | 9 ++ npm/packages/ruvector/package.json | 2 +- npm/packages/ruvector/test/sigterm-cleanup.js | 108 ++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 npm/packages/ruvector/test/sigterm-cleanup.js diff --git a/npm/packages/ruvector/bin/mcp-server.js b/npm/packages/ruvector/bin/mcp-server.js index 166d593649..2b77c3adfd 100644 --- a/npm/packages/ruvector/bin/mcp-server.js +++ b/npm/packages/ruvector/bin/mcp-server.js @@ -3837,6 +3837,15 @@ async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('RuVector MCP server running on stdio'); + + // Exit cleanly when the parent process closes the stdio pipe or sends a + // termination signal. Without these handlers, the MCP server can survive + // the parent's death (e.g. when the client is killed with SIGKILL) and + // accumulate as an orphaned process under PPID=1, consuming RSS for the + // lifetime of the user session. + process.stdin.on('end', () => process.exit(0)); + process.on('SIGINT', () => process.exit(0)); + process.on('SIGTERM', () => process.exit(0)); } main().catch(console.error); diff --git a/npm/packages/ruvector/package.json b/npm/packages/ruvector/package.json index 2d77cb3683..67aa0c1935 100644 --- a/npm/packages/ruvector/package.json +++ b/npm/packages/ruvector/package.json @@ -12,7 +12,7 @@ "verify-dist": "node scripts/verify-dist.js", "prepack": "npm run build && npm run verify-dist", "prepublishOnly": "npm run build && npm run verify-dist", - "test": "node test/integration.js && node test/cli-commands.js" + "test": "node test/integration.js && node test/cli-commands.js && node test/sigterm-cleanup.js" }, "keywords": [ "vector", diff --git a/npm/packages/ruvector/test/sigterm-cleanup.js b/npm/packages/ruvector/test/sigterm-cleanup.js new file mode 100644 index 0000000000..e3457415d3 --- /dev/null +++ b/npm/packages/ruvector/test/sigterm-cleanup.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +/** + * Regression test: bin/mcp-server.js must exit on SIGTERM/SIGINT. + * + * Catches the case where the MCP server kept alive by stdin does not + * respond to termination signals and survives parent death as an + * orphaned process (PPID=1). + */ + +const { spawn } = require('child_process'); +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); + +const MCP_SERVER = path.join(__dirname, '..', 'bin', 'mcp-server.js'); +const SDK_PATH = path.join(__dirname, '..', 'node_modules', '@modelcontextprotocol', 'sdk'); + +let passed = 0; +let failed = 0; +let skipped = 0; +const failures = []; + +function pass(name) { + passed++; + console.log(` PASS ${name}`); +} + +function fail(name, err) { + failed++; + failures.push({ name, error: err && err.message ? err.message : String(err) }); + console.log(` FAIL ${name}`); + console.log(` ${err && err.message ? err.message : err}`); +} + +function skip(name, reason) { + skipped++; + console.log(` SKIP ${name} -- ${reason}`); +} + +function waitForExit(child, timeoutMs) { + return new Promise((resolve) => { + const timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch (_) {} + resolve(-1); + }, timeoutMs); + child.once('exit', (code) => { + clearTimeout(timer); + resolve(code === null ? -1 : code); + }); + }); +} + +function waitForReady(child, marker, timeoutMs) { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + const onData = (buf) => { + if (buf.toString().includes(marker)) { + clearTimeout(timer); + child.stderr.off('data', onData); + resolve(); + } + }; + child.stderr.on('data', onData); + }); +} + +async function testSignal(name, signal) { + const child = spawn(process.execPath, [MCP_SERVER], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1' }, + }); + + await waitForReady(child, 'running on stdio', 2000); + child.kill(signal); + const code = await waitForExit(child, 5000); + + try { + assert.strictEqual(code, 0, `Expected clean exit on ${signal}, got code ${code}`); + pass(name); + } catch (err) { + fail(name, err); + } +} + +(async () => { + console.log('\nruvector MCP server signal-handling tests'); + console.log('='.repeat(60)); + + if (!fs.existsSync(SDK_PATH)) { + skip('SIGTERM cleanup', 'MCP SDK not installed (run npm install)'); + skip('SIGINT cleanup', 'MCP SDK not installed (run npm install)'); + } else { + await testSignal('SIGTERM cleanup', 'SIGTERM'); + await testSignal('SIGINT cleanup', 'SIGINT'); + } + + console.log(); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log(`Skipped: ${skipped}`); + + if (failed > 0) { + console.log('\nFailures:'); + failures.forEach(({ name, error }) => console.log(` - ${name}: ${error}`)); + process.exit(1); + } +})();