Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions npm/packages/ruvector/bin/mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
2 changes: 1 addition & 1 deletion npm/packages/ruvector/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 108 additions & 0 deletions npm/packages/ruvector/test/sigterm-cleanup.js
Original file line number Diff line number Diff line change
@@ -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);
}
})();
Loading