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