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); + } +})();