|
1 | 1 | #!/usr/bin/env node |
2 | 2 |
|
3 | | -const { execFileSync } = require('child_process') |
| 3 | +const { spawn, execFileSync } = require('child_process') |
4 | 4 | const { existsSync } = require('fs') |
5 | 5 | const path = require('path') |
6 | 6 |
|
@@ -48,15 +48,96 @@ if (!existsSync(binPath)) { |
48 | 48 | process.exit(1) |
49 | 49 | } |
50 | 50 |
|
51 | | -try { |
52 | | - execFileSync(binPath, process.argv.slice(2), { |
53 | | - stdio: 'inherit', |
54 | | - env: process.env, |
55 | | - }) |
56 | | - process.exit(0) |
57 | | -} catch (e) { |
58 | | - if (e.signal) { |
59 | | - process.kill(process.pid, e.signal) |
| 51 | +// Run the binary as a CHILD we own, not a synchronous block, so we can forward |
| 52 | +// stop signals and — critically on Windows — tear down the WHOLE process tree. |
| 53 | +// |
| 54 | +// Why this matters: the binary runs in "router" mode and spawns one child |
| 55 | +// "worker" process per repo. Those workers hold a RocksDB lock AND keep the |
| 56 | +// `context-engine-rs.exe` image open. A worker only self-exits after an idle |
| 57 | +// window (default 300s), so if the router dies but its workers linger, the next |
| 58 | +// `npx vibervn-context-engine@latest` cannot replace the cached binary and npm |
| 59 | +// fails with `EPERM: operation not permitted, unlink ...context-engine-rs.exe`. |
| 60 | +// |
| 61 | +// On Windows there is no real SIGINT to forward: Node's `child.kill('SIGINT')` |
| 62 | +// maps to TerminateProcess, which kills ONLY the router and leaves the workers |
| 63 | +// orphaned. So on Windows we kill the whole tree with `taskkill /T /F` (the |
| 64 | +// router is still alive when we snapshot, so its workers are in the tree). On |
| 65 | +// POSIX we forward the real signal to the child, which the router handles to |
| 66 | +// kill its workers before exiting. The binary's Job Object (Windows |
| 67 | +// kill-on-close) remains the backstop for anything either path races past. |
| 68 | +const child = spawn(binPath, process.argv.slice(2), { |
| 69 | + stdio: 'inherit', |
| 70 | + env: process.env, |
| 71 | +}) |
| 72 | + |
| 73 | +// Stop signals we forward / translate. Listed once so we can both register and |
| 74 | +// (in the exit handler) DE-register them — see the re-raise note below. |
| 75 | +const STOP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] |
| 76 | + |
| 77 | +let terminating = false |
| 78 | +let exited = false |
| 79 | + |
| 80 | +function terminate(signal) { |
| 81 | + if (terminating) return |
| 82 | + terminating = true |
| 83 | + |
| 84 | + // Guard: if the child is already gone, do NOTHING. Two reasons: |
| 85 | + // - there is nothing left to kill, and |
| 86 | + // - on Windows `taskkill /T /F <pid>` against a dead PID could match a REUSED |
| 87 | + // pid and kill an unrelated process tree. `exited` is set by the 'exit' |
| 88 | + // handler; `child.exitCode !== null` also means it has exited; `child.pid` |
| 89 | + // is undefined if spawn never succeeded (the 'error' handler ran). |
| 90 | + if (exited || child.exitCode !== null || child.pid === undefined) return |
| 91 | + |
| 92 | + if (process.platform === 'win32') { |
| 93 | + // Kill the router AND its worker children as one tree. /F so a worker that |
| 94 | + // ignores the polite close still dies and releases the .exe + RocksDB lock. |
| 95 | + // The child is still alive here (guarded above), so its workers are in the |
| 96 | + // tree snapshot taskkill takes. Wrapped: a child that dies in the gap makes |
| 97 | + // taskkill exit non-zero (execFileSync throws) — caught, then a best-effort |
| 98 | + // direct kill (also caught) so the handler can never crash the wrapper. |
| 99 | + try { |
| 100 | + execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { |
| 101 | + stdio: 'ignore', |
| 102 | + }) |
| 103 | + } catch { |
| 104 | + try { |
| 105 | + child.kill() |
| 106 | + } catch {} |
| 107 | + } |
| 108 | + } else { |
| 109 | + // POSIX: forward the real signal; the router kills its workers, then exits. |
| 110 | + try { |
| 111 | + child.kill(signal || 'SIGTERM') |
| 112 | + } catch {} |
60 | 113 | } |
61 | | - process.exit(e.status ?? 1) |
62 | 114 | } |
| 115 | + |
| 116 | +// Forward the usual stop signals. SIGINT = Ctrl+C; SIGTERM = supervisor/kill; |
| 117 | +// SIGHUP = terminal closed (POSIX). Windows only delivers SIGINT/SIGBREAK here. |
| 118 | +// Each is registered exactly once; the `terminating` guard makes the body |
| 119 | +// idempotent if several arrive (or one arrives twice). |
| 120 | +for (const sig of STOP_SIGNALS) { |
| 121 | + process.on(sig, () => terminate(sig)) |
| 122 | +} |
| 123 | + |
| 124 | +child.on('error', (err) => { |
| 125 | + console.error(`Failed to launch context-engine binary: ${err.message}`) |
| 126 | + process.exit(1) |
| 127 | +}) |
| 128 | + |
| 129 | +child.on('exit', (code, signal) => { |
| 130 | + exited = true |
| 131 | + if (signal) { |
| 132 | + // Re-raise the signal on ourselves so our exit status reflects it, matching |
| 133 | + // normal child-process signal semantics. We MUST drop our own signal |
| 134 | + // listeners first: otherwise re-raising e.g. SIGINT would just re-enter |
| 135 | + // `terminate()` (a no-op now, but it would also stop the default |
| 136 | + // termination), leaving the wrapper hung instead of exiting. (Mostly a POSIX |
| 137 | + // path — on Windows child death surfaces as an exit code, not a signal.) |
| 138 | + for (const sig of STOP_SIGNALS) process.removeAllListeners(sig) |
| 139 | + process.kill(process.pid, signal) |
| 140 | + return |
| 141 | + } |
| 142 | + process.exit(code ?? 0) |
| 143 | +}) |
0 commit comments