From 0ad15ec28cde85da592fae87230e0102a2c5f54d Mon Sep 17 00:00:00 2001 From: fa1k3 <268878418+fa1k3@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:54:48 +0200 Subject: [PATCH] fix(mcp): shut down gracefully when stdin reaches EOF (#751) The SDK's StdioServerTransport subscribes to stdin 'data'/'error' only and never notices 'end'/'close'. When the parent MCP client dies, no teardown runs: whenever anything still references the event loop (a warm llama.cpp model's native handles being the common case), the server reparents to PID 1, leaks RAM, and keeps the SQLite index open. Treat stdin EOF as a client disconnect: close the server/transport, give in-flight request handlers a bounded window to settle (a new InflightGate counts running tool/resource handlers so their store/llm dependencies are not torn down underneath them), release the llama.cpp resources, close the store, and set process.exitCode instead of calling process.exit() so beforeExit still fires and node-llama-cpp's auto-dispose runs before libc's static destructors - forced exits during native-addon unload have caused exit-time crashes before (#59, #129). Same rationale as finishSuccessfulCliCommand on the CLI side, and mirrors the HTTP transport's idempotent stop() including its stderr breadcrumb. The shutdown is a shared promise that never rejects: 'end', 'close', an already-ended stdin at registration time, and manual invocations all collapse into one run. Every step including logging is failure-tolerant (the parent's death may have closed stderr too), and a successful shutdown preserves an earlier nonzero exit code instead of masking it. Related: modelcontextprotocol/typescript-sdk#2003 fixes the same gap at the transport layer; this stays correct alongside it because the teardown is idempotent, and qmd needs its own store/llm cleanup either way. Tests: unit tests drive registerStdioEofShutdown via injected stdin (ordering incl. in-flight drain, idempotency, failure tolerance, exit-code preservation, throwing stderr, already-ended stdin) plus createInflightGate unit tests; an end-to-end test spawns the real server, completes an initialize round-trip, closes stdin, and asserts a prompt code-0 exit through the EOF shutdown path (stderr breadcrumb). --- CHANGELOG.md | 9 + src/mcp/server.ts | 244 ++++++++++++++++++++- test/mcp-stdio-lifecycle.test.ts | 363 +++++++++++++++++++++++++++++++ 3 files changed, 604 insertions(+), 12 deletions(-) create mode 100644 test/mcp-stdio-lifecycle.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 382db1efd..746f8fdef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Fixed + +- `qmd mcp` (stdio) now shuts down gracefully when stdin reaches EOF instead + of orphaning to PID 1 when the parent MCP client dies (#751): the server + closes its transport, gives in-flight request handlers a bounded window to + settle, closes the store (which disposes its llama.cpp instance), and lets + the process drain via `process.exitCode` (no forced `process.exit()`, which + has caused exit-time native crashes before). + ## [2.6.3] - 2026-06-24 ### Added diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d4fc6a4cc..9a2ff0e56 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -168,7 +168,10 @@ async function buildInstructions(store: QMDStore): Promise { * Create an MCP server with all QMD tools, resources, and prompts registered. * Shared by both stdio and HTTP transports. */ -async function createMcpServer(store: QMDStore): Promise { +async function createMcpServer(store: QMDStore, inflight?: InflightGate): Promise { + // Wraps request handlers so a stdio EOF shutdown can wait for in-flight + // work to settle before disposing the store/llm underneath it. + const track = inflight?.track ?? ((fn: T): T => fn); const server = new McpServer( { name: "qmd", version: getPackageVersion() }, { instructions: await buildInstructions(store) }, @@ -190,7 +193,7 @@ async function createMcpServer(store: QMDStore): Promise { description: "A markdown document from your QMD knowledge base. Use search tools to discover documents.", mimeType: "text/markdown", }, - async (uri, { path }) => { + track(async (uri, { path }) => { // Decode URL-encoded path (MCP clients send encoded URIs) const pathStr = Array.isArray(path) ? path.join('/') : (path || ''); const decodedPath = decodeURIComponent(pathStr); @@ -219,7 +222,7 @@ async function createMcpServer(store: QMDStore): Promise { text, }], }; - } + }) ); // --------------------------------------------------------------------------- @@ -325,7 +328,7 @@ Intent-aware lex (C++ performance, not sports): ), }, }, - async ({ query, searches, limit, minScore, candidateLimit, collections, intent, rerank }) => { + track(async ({ query, searches, limit, minScore, candidateLimit, collections, intent, rerank }) => { // Require exactly one of `query` (plain text, auto-expanded) or `searches` (typed sub-queries). if (!query && (!searches || searches.length === 0)) { return { @@ -383,7 +386,7 @@ Intent-aware lex (C++ performance, not sports): content: [{ type: "text", text: formatSearchSummary(filtered, primaryQuery) }], structuredContent: { results: filtered }, }; - } + }) ); // --------------------------------------------------------------------------- @@ -403,7 +406,7 @@ Intent-aware lex (C++ performance, not sports): lineNumbers: z.boolean().optional().default(true).describe("Add line numbers to output (format: 'N: content'). On by default; set false for raw content."), }, }, - async ({ file, fromLine, maxLines, lineNumbers }) => { + track(async ({ file, fromLine, maxLines, lineNumbers }) => { // Support :line and :from:count suffixes in `file` (e.g. "foo.md:120" or // "foo.md:120:40"). Explicit fromLine/maxLines args take precedence. let parsedFromLine = fromLine; @@ -460,7 +463,7 @@ Intent-aware lex (C++ performance, not sports): }, }], }; - } + }) ); // --------------------------------------------------------------------------- @@ -480,7 +483,7 @@ Intent-aware lex (C++ performance, not sports): lineNumbers: z.boolean().optional().default(true).describe("Add line numbers to output (format: 'N: content'). On by default; set false for raw content."), }, }, - async ({ pattern, maxLines, maxBytes, lineNumbers }) => { + track(async ({ pattern, maxLines, maxBytes, lineNumbers }) => { const { docs, errors } = await store.multiGet(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES }); if (docs.length === 0 && errors.length === 0) { @@ -533,7 +536,7 @@ Intent-aware lex (C++ performance, not sports): } return { content }; - } + }) ); // --------------------------------------------------------------------------- @@ -548,7 +551,7 @@ Intent-aware lex (C++ performance, not sports): annotations: { readOnlyHint: true, openWorldHint: false }, inputSchema: {}, }, - async () => { + track(async () => { const status: StatusResult = await store.getStatus(); const summary = [ @@ -567,7 +570,7 @@ Intent-aware lex (C++ performance, not sports): content: [{ type: "text", text: summary.join('\n') }], structuredContent: status, }; - } + }) ); return server; @@ -581,6 +584,211 @@ export type McpStartupOptions = { dbPath?: string; }; +/** + * Counts running request handlers so shutdown can wait for them to settle + * before tearing down their llm/store dependencies. The SDK aborts in-flight + * request controllers on close, but qmd's handlers finish their current + * store/llm work rather than observing the signal mid-operation. + */ +export type InflightGate = { + /** Wraps a handler so the gate counts it while it runs. */ + track unknown>(fn: T): T; + /** Resolves once no tracked handler runs, or after timeoutMs. Returns whether idle was reached. */ + waitForIdle(timeoutMs: number): Promise; +}; + +export function createInflightGate(): InflightGate { + // `active` is a running-handler counter, not a closed admission barrier. + // The barrier comes from the caller's ordering: registerStdioEofShutdown + // runs closeServer() (which stops the transport from dispatching new + // requests) BEFORE waitForIdle(), so by the time we wait, the only handlers + // that can still be running are ones already dispatched — there is no source + // of late admissions to guard against under the stdio transport. + let active = 0; + const waiters: Array<() => void> = []; + return { + track(fn) { + const wrapped = async (...args: never[]) => { + active += 1; + try { + return await fn(...args); + } finally { + active -= 1; + if (active === 0) { + while (waiters.length > 0) waiters.shift()!(); + } + } + }; + return wrapped as typeof fn; + }, + waitForIdle(timeoutMs: number): Promise { + if (active === 0) return Promise.resolve(true); + return new Promise((resolve) => { + const onIdle = () => { + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + const i = waiters.indexOf(onIdle); + if (i >= 0) waiters.splice(i, 1); + resolve(false); + }, timeoutMs); + timer.unref?.(); + waiters.push(onIdle); + }); + }, + }; +} + +/** Minimal stdin surface consumed by registerStdioEofShutdown, injectable for tests. */ +export type StdioShutdownStdin = { + once(event: "end" | "close", listener: () => void): unknown; + off(event: "end" | "close", listener: () => void): unknown; + readableEnded?: boolean; + destroyed?: boolean; +}; + +export type StdioShutdownOptions = { + /** Closes the MCP server and its transport. */ + closeServer: () => Promise; + /** Closes the SQLite store (owns disposing the per-store llama.cpp instance). */ + closeStore: () => void | Promise; + /** + * Optional extra llama.cpp teardown, run before closeStore. The MCP store + * disposes its own per-store LlamaCpp inside closeStore, so this is left + * unset there; it exists for callers that own a separate instance. If + * omitted, the step is skipped (do NOT default it to the global + * disposeDefaultLlamaCpp — that would tear down an unrelated instance in an + * embedded process). + */ + disposeLlm?: () => Promise; + /** Waits for in-flight handlers to settle (see InflightGate.waitForIdle). */ + waitForIdle?: (timeoutMs: number) => Promise; + /** Deadline for the in-flight wait. Defaults to 5000 ms. */ + idleTimeoutMs?: number; + /** Defaults to process.stdin. */ + stdin?: StdioShutdownStdin; + /** Defaults to assigning process.exitCode. */ + setExitCode?: (code: number) => void; + /** Defaults to reading process.exitCode. */ + getExitCode?: () => number | undefined; + /** Defaults to process.stderr. */ + stderr?: { write(chunk: string): unknown; on?(event: "error", listener: (err: unknown) => void): unknown }; +}; + +/** + * Shut the stdio MCP server down when stdin reaches EOF (#751). + * + * The SDK's StdioServerTransport subscribes to stdin "data"/"error" only and + * never notices "end"/"close". When the parent MCP client dies, nothing tears + * the process down: the warm llama.cpp model's native handles keep the event + * loop alive, so the server reparents to PID 1, leaks RAM, and keeps the + * SQLite index open. stdin EOF means the client is gone, so this treats it as + * a disconnect: no new requests are accepted and nobody is left to read a + * response — but handlers that are already running get a bounded window to + * settle (waitForIdle) before their llm/store dependencies are torn down. + * + * Teardown order matters. Close the transport first so no further requests + * are dispatched, wait for in-flight handlers, then close the store last — + * which disposes the store's own llama.cpp instance and then the database, so + * the dispose path cannot hit an already-closed DB. (disposeLlm is an optional + * extra step for callers that own a separate instance; the MCP store does + * not.) Failures are logged best-effort (the parent's death may have closed + * stderr too) and do not stop the remaining steps. The function sets process.exitCode + * instead of calling process.exit() so `beforeExit` still fires and + * node-llama-cpp's auto-dispose runs before libc's static destructors — + * process.exit() during native-addon unload has caused exit-time crashes + * before (#59, #129; same rationale as finishSuccessfulCliCommand in the CLI). + * + * Returns the idempotent shutdown function: every invocation (manual, "end", + * "close", or already-ended stdin) shares one promise, and the promise never + * rejects. + */ +export function registerStdioEofShutdown(options: StdioShutdownOptions): () => Promise { + const stdin = options.stdin ?? process.stdin; + const stderr = options.stderr ?? process.stderr; + const setExitCode = options.setExitCode ?? ((code: number) => { process.exitCode = code; }); + const getExitCode = options.getExitCode ?? (() => (typeof process.exitCode === "number" ? process.exitCode : undefined)); + let shutdownPromise: Promise | null = null; + + // If the parent died, its stderr pipe may be gone: writes can throw + // synchronously or emit an async stream error. Logging must never take the + // teardown down with it. + stderr.on?.("error", () => {}); + const safeWrite = (chunk: string): void => { + try { + stderr.write(chunk); + } catch { + // stderr went away with the parent + } + }; + + const performShutdown = async (): Promise => { + try { + stdin.off("end", onStdinEof); + stdin.off("close", onStdinEof); + } catch { + // an exotic stdin may throw on off(); shutdown continues regardless + } + + // Same stderr breadcrumb style as the HTTP transport's SIGTERM/SIGINT + // handlers; also gives tests an observable signal that the EOF path ran. + safeWrite("Shutting down (stdin closed)...\n"); + + let failed = false; + const step = async (name: string, run: () => void | Promise): Promise => { + try { + await run(); + } catch (error) { + failed = true; + safeWrite( + `QMD Warning: ${name} failed during stdio shutdown (${error instanceof Error ? error.message : String(error)}); continuing shutdown.\n` + ); + } + }; + + await step("server.close()", options.closeServer); + if (options.waitForIdle) { + await step("in-flight drain", async () => { + const idle = await options.waitForIdle!(options.idleTimeoutMs ?? 5000); + if (!idle) { + safeWrite("QMD Warning: in-flight request did not settle before the shutdown deadline; continuing shutdown.\n"); + } + }); + } + if (options.disposeLlm) { + await step("llama disposal", options.disposeLlm); + } + await step("store.close()", options.closeStore); + + try { + const prior = getExitCode(); + if (failed) { + setExitCode(1); + } else if (prior === undefined || prior === 0) { + setExitCode(0); + } + // else: keep an earlier nonzero status instead of masking it + } catch { + // injected setExitCode/getExitCode must not break the shutdown promise + } + }; + + const shutdown = (): Promise => (shutdownPromise ??= performShutdown()); + const onStdinEof = (): void => { void shutdown().catch(() => {}); }; + + stdin.once("end", onStdinEof); + stdin.once("close", onStdinEof); + + // The parent can die between spawn and listener registration; check the + // stream flags after subscribing so an already-ended stdin still shuts down. + if (stdin.readableEnded || stdin.destroyed) { + onStdinEof(); + } + + return shutdown; +} + export async function startMcpServer(options: McpStartupOptions = {}): Promise { // Opt into production mode when the MCP server is actually started, not // when this module is merely imported for its exports. Importing the module @@ -593,9 +801,21 @@ export async function startMcpServer(options: McpStartupOptions = {}): Promise server.close(), + waitForIdle: (timeoutMs) => inflight.waitForIdle(timeoutMs), + closeStore: () => store.close(), + }); } // ============================================================================= diff --git a/test/mcp-stdio-lifecycle.test.ts b/test/mcp-stdio-lifecycle.test.ts new file mode 100644 index 000000000..e956ba3c6 --- /dev/null +++ b/test/mcp-stdio-lifecycle.test.ts @@ -0,0 +1,363 @@ +/** + * Lifecycle tests for the stdio MCP server's EOF shutdown (#751). + * + * Unit tests drive registerStdioEofShutdown with an injected fake stdin + * (mirroring the DI style of the CLI's finishSuccessfulCliCommand tests). + * The end-to-end test spawns the real server with a piped stdin and proves + * the process exits once stdin closes instead of orphaning to PID 1. + */ + +import { describe, test, expect } from "vitest"; +import { EventEmitter } from "node:events"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { registerStdioEofShutdown, createInflightGate } from "../src/mcp/server"; + +class FakeStdin extends EventEmitter { + readableEnded = false; + destroyed = false; +} + +type Recorded = { + stdin: FakeStdin; + calls: string[]; + exitCodes: number[]; + warnings: string[]; + shutdown: () => Promise; +}; + +function register(overrides: { + closeServer?: () => Promise; + disposeLlm?: () => Promise; + closeStore?: () => void | Promise; + waitForIdle?: (timeoutMs: number) => Promise; + idleTimeoutMs?: number; + getExitCode?: () => number | undefined; + stderrWrite?: (chunk: string) => unknown; + stdin?: FakeStdin; +} = {}): Recorded { + const stdin = overrides.stdin ?? new FakeStdin(); + const calls: string[] = []; + const exitCodes: number[] = []; + const warnings: string[] = []; + + const shutdown = registerStdioEofShutdown({ + stdin, + closeServer: overrides.closeServer ?? (async () => { calls.push("server-close"); }), + disposeLlm: overrides.disposeLlm ?? (async () => { calls.push("llm-dispose"); }), + closeStore: overrides.closeStore ?? (() => { calls.push("store-close"); }), + waitForIdle: overrides.waitForIdle ?? (async () => { calls.push("idle-wait"); return true; }), + idleTimeoutMs: overrides.idleTimeoutMs, + setExitCode: (code) => { exitCodes.push(code); }, + getExitCode: overrides.getExitCode ?? (() => undefined), + stderr: { write: overrides.stderrWrite ?? ((chunk: string) => { warnings.push(chunk); return true; }) }, + }); + + return { stdin, calls, exitCodes, warnings, shutdown }; +} + +describe("registerStdioEofShutdown", () => { + test("stdin 'end' tears down in order: server, llm, store, then exitCode 0", async () => { + const r = register(); + + r.stdin.emit("end"); + await r.shutdown(); // same shared promise as the event-triggered run + + expect(r.calls).toEqual(["server-close", "idle-wait", "llm-dispose", "store-close"]); + expect(r.exitCodes).toEqual([0]); + expect(r.warnings.join("")).toContain("Shutting down (stdin closed)"); + expect(r.warnings.filter((w) => w.startsWith("QMD Warning"))).toEqual([]); + }); + + test("stdin 'close' triggers the same teardown", async () => { + const r = register(); + + r.stdin.emit("close"); + await r.shutdown(); + + expect(r.calls).toEqual(["server-close", "idle-wait", "llm-dispose", "store-close"]); + expect(r.exitCodes).toEqual([0]); + }); + + test("is idempotent: 'end' + 'close' + manual calls share one run", async () => { + const r = register(); + + r.stdin.emit("end"); + r.stdin.emit("close"); + const first = r.shutdown(); + const second = r.shutdown(); + expect(first).toBe(second); + await first; + + expect(r.calls).toEqual(["server-close", "idle-wait", "llm-dispose", "store-close"]); + expect(r.exitCodes).toEqual([0]); + // Listeners are removed during shutdown, so late events cannot re-enter. + r.stdin.emit("end"); + r.stdin.emit("close"); + await r.shutdown(); + expect(r.exitCodes).toEqual([0]); + }); + + test("a failing step is logged, later steps still run, exit code is 1", async () => { + const r = register({ + closeServer: async () => { throw new Error("transport already gone"); }, + }); + + r.stdin.emit("end"); + await r.shutdown(); + + expect(r.calls).toEqual(["idle-wait", "llm-dispose", "store-close"]); + expect(r.warnings.join("")).toContain("server.close() failed during stdio shutdown"); + expect(r.warnings.join("")).toContain("transport already gone"); + expect(r.exitCodes).toEqual([1]); + }); + + test("every step failing still finishes the chain instead of throwing", async () => { + const r = register({ + closeServer: async () => { throw new Error("boom-server"); }, + disposeLlm: async () => { throw new Error("boom-llm"); }, + closeStore: () => { throw new Error("boom-store"); }, + }); + + r.stdin.emit("end"); + await expect(r.shutdown()).resolves.toBeUndefined(); + + expect(r.warnings.filter((w) => w.startsWith("QMD Warning"))).toHaveLength(3); + expect(r.exitCodes).toEqual([1]); + }); + + test("stdin that already ended before registration still shuts down", async () => { + const stdin = new FakeStdin(); + stdin.readableEnded = true; + + const r = register({ stdin }); + await r.shutdown(); + + expect(r.calls).toEqual(["server-close", "idle-wait", "llm-dispose", "store-close"]); + expect(r.exitCodes).toEqual([0]); + }); + + test("stdin destroyed before registration still shuts down", async () => { + const stdin = new FakeStdin(); + stdin.destroyed = true; + + const r = register({ stdin }); + await r.shutdown(); + + expect(r.exitCodes).toEqual([0]); + }); + + test("a drain deadline miss is logged but does not fail the shutdown", async () => { + const r = register({ + waitForIdle: async () => false, + }); + + r.stdin.emit("end"); + await r.shutdown(); + + expect(r.warnings.join("")).toContain("in-flight request did not settle"); + expect(r.exitCodes).toEqual([0]); + }); + + test("a successful shutdown preserves an earlier nonzero exit code", async () => { + const r = register({ getExitCode: () => 1 }); + + r.stdin.emit("end"); + await r.shutdown(); + + // No setExitCode(0) call — the earlier failure status stays visible. + expect(r.exitCodes).toEqual([]); + }); + + test("a failing shutdown still sets exit code 1 over an earlier 0", async () => { + const r = register({ + getExitCode: () => 0, + closeStore: () => { throw new Error("boom-store"); }, + }); + + r.stdin.emit("end"); + await r.shutdown(); + + expect(r.exitCodes).toEqual([1]); + }); + + test("a throwing stderr cannot break the teardown chain", async () => { + const r = register({ + stderrWrite: () => { throw new Error("EPIPE"); }, + }); + + r.stdin.emit("end"); + await expect(r.shutdown()).resolves.toBeUndefined(); + + expect(r.calls).toEqual(["server-close", "idle-wait", "llm-dispose", "store-close"]); + expect(r.exitCodes).toEqual([0]); + }); + + test("skips the llm-dispose step when disposeLlm is omitted (store owns it)", async () => { + const stdin = new FakeStdin(); + const calls: string[] = []; + const exitCodes: number[] = []; + + // Mirror how startMcpServer wires it: no disposeLlm — store.close() disposes + // the store's own LlamaCpp, so there must be no extra global disposal step. + const shutdown = registerStdioEofShutdown({ + stdin, + closeServer: async () => { calls.push("server-close"); }, + waitForIdle: async () => { calls.push("idle-wait"); return true; }, + closeStore: () => { calls.push("store-close"); }, + setExitCode: (code) => { exitCodes.push(code); }, + getExitCode: () => undefined, + stderr: { write: () => true }, + }); + + stdin.emit("end"); + await shutdown(); + + expect(calls).toEqual(["server-close", "idle-wait", "store-close"]); + expect(exitCodes).toEqual([0]); + }); +}); + +describe("createInflightGate", () => { + test("waitForIdle resolves immediately when nothing is tracked", async () => { + const gate = createInflightGate(); + await expect(gate.waitForIdle(1000)).resolves.toBe(true); + }); + + test("waitForIdle waits for a tracked handler to settle", async () => { + const gate = createInflightGate(); + let release!: () => void; + const handler = gate.track(() => new Promise((resolve) => { release = resolve; })); + + const running = handler(); + const idle = gate.waitForIdle(5000); + + release(); + await running; + await expect(idle).resolves.toBe(true); + }); + + test("waitForIdle reports a missed deadline without throwing", async () => { + const gate = createInflightGate(); + let release!: () => void; + const handler = gate.track(() => new Promise((resolve) => { release = resolve; })); + + const running = handler(); + await expect(gate.waitForIdle(20)).resolves.toBe(false); + + release(); + await running; + await expect(gate.waitForIdle(20)).resolves.toBe(true); + }); + + test("a rejecting handler still releases the gate and keeps rejecting", async () => { + const gate = createInflightGate(); + const handler = gate.track(async () => { throw new Error("handler failed"); }); + + await expect(handler()).rejects.toThrow("handler failed"); + await expect(gate.waitForIdle(1000)).resolves.toBe(true); + }); +}); + +describe("qmd mcp stdio process lifecycle", () => { + const repoRoot = fileURLToPath(new URL("..", import.meta.url)); + const cliPath = join(repoRoot, "src", "cli", "qmd.ts"); + + test("exits cleanly after serving a request once stdin closes", async () => { + const workDir = await mkdtemp(join(tmpdir(), "qmd-stdio-lifecycle-")); + // Declared outside try so the finally can always reap the child — a failure + // (timeout, assertion) before stdin.end() would otherwise leak exactly the + // orphan process this test is about. + let child: ReturnType | undefined; + try { + await writeFile(join(workDir, "index.yml"), "collections: {}\n"); + + const runtimeArgs = process.versions.bun + ? [cliPath, "mcp"] + : ["--import", "tsx", cliPath, "mcp"]; + + child = spawn(process.execPath, runtimeArgs, { + cwd: repoRoot, + env: { + ...process.env, + INDEX_PATH: join(workDir, "lifecycle.sqlite"), + QMD_CONFIG_DIR: workDir, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const stderrChunks: string[] = []; + child.stderr.on("data", (chunk) => stderrChunks.push(String(chunk))); + + // Complete one request/response round-trip so EOF arrives on a live, + // already-connected server rather than during startup. + const response = await new Promise((resolve, reject) => { + let buffer = ""; + const onData = (chunk: Buffer) => { + buffer += String(chunk); + if (buffer.includes("\n")) { + child.stdout.off("data", onData); + resolve(buffer); + } + }; + child.stdout.on("data", onData); + child.once("error", reject); + child.once("exit", (code) => + reject(new Error(`server exited before responding (code ${code}): ${stderrChunks.join("")}`)) + ); + child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "lifecycle-test", version: "1.0.0" }, + }, + }) + "\n" + ); + }); + expect(response).toContain('"jsonrpc":"2.0"'); + + // Parent goes away: close stdin and require a clean, prompt exit. + const exitCode = await new Promise((resolve, reject) => { + child.removeAllListeners("exit"); + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`server did not exit after stdin EOF: ${stderrChunks.join("")}`)); + }, 30_000); + child.once("exit", (code) => { + clearTimeout(timer); + resolve(code); + }); + child.stdin.end(); + }); + + expect(exitCode).toBe(0); + + // The exit must have come from the EOF shutdown path, not from the event + // loop happening to drain on its own (which also exits 0 whenever no + // model is loaded — the pre-#751-fix false-negative). The breadcrumb is + // written by registerStdioEofShutdown before teardown starts. + expect(stderrChunks.join("")).toContain("Shutting down (stdin closed)"); + + // Sanity: no WAL sidecar survives a clean database shutdown on the node + // child (explicit close or final-connection teardown both checkpoint). + // bun:sqlite can retain the sidecar after a clean close depending on + // platform, so the bun child is not asserted on. + if (!process.versions.bun) { + expect(existsSync(join(workDir, "lifecycle.sqlite-wal"))).toBe(false); + } + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + await rm(workDir, { recursive: true, force: true }); + } + }, 60_000); +});