qmd/test/cli-exit-lifecycle.test.ts
Tobi Lutke c162ed1319
fix: disable libggml-metal residency sets on darwin
The libggml-metal static device destructor asserts on a non-empty
residency set during libc `exit()` → `__cxa_finalize_ranges`
(ggml-org/llama.cpp#17869). The residency set's 180 s keep_alive timer
hasn't expired by exit, so `GGML_ASSERT([rsets->data count] == 0)`
fails and `ggml_abort` dumps a multi-kB backtrace to stderr after the
user-visible output. Every llama-using CLI command (`query`,
`vsearch`, `embed`) was affected, plus the `bun test` runner.

No JS-side dispose path can prevent it: the static destructor runs
after every JS-reachable cleanup, and Node's `reallyExit` calls libc
`exit()` not `_exit()` (verified in node/src/api/environment.cc),
so it does NOT skip C++ static destructors as we'd assumed.

The actual fix is to disable residency sets via
`GGML_METAL_NO_RESIDENCY=1` before the native binding loads. For
QMD's short-lived CLI workflow there's no measurable cost
(benchmarked: identical wall time with and without on M3 Pro).

Three propagation points are needed:
- `bin/qmd` exports the env var before spawning node/bun. This
  covers all production CLI invocations.
- `src/test-preload.ts` mirrors the launcher for `bun test` runs.
  Bun does NOT sync `process.env` mutations to libc `setenv()`
  (verified empirically — Node does, via uv_os_setenv), so on Bun we
  reach for `bun:ffi` to call `setenv()` directly. vitest forks
  per-test-file so its parent never loads the binding.
- `qmd doctor` reports the mitigation state via the new
  `isDarwinMetalMitigationActive()` predicate so users can verify it
  in their environment.

Opt back in with `QMD_METAL_KEEP_RESIDENCY=1` (long-lived qmd
processes, MCP daemon hot reload, upstream fix triage). The old
`QMD_DISABLE_DARWIN_QUERY_JSON_SAFE_EXIT` is removed — its per-command
bypass mechanism didn't actually work on Node (it called
`process.reallyExit` which goes through libc exit) and is fully
replaced by the launcher env var.

Removed the old broken `installDarwinExitGuard()` mechanism from
LlamaCpp; kept the function name as a no-op shim for back-compat.
2026-05-28 13:40:14 -07:00

105 lines
4.3 KiB
TypeScript

import { describe, expect, test } from "vitest";
import { finishSuccessfulCliCommand } from "../src/cli/qmd.ts";
import { LlamaCpp, isDarwinMetalMitigationActive } from "../src/llm.ts";
describe("CLI successful-exit lifecycle", () => {
test("exits 0 after successful output when post-output LLM cleanup fails", async () => {
const exitCodes: number[] = [];
const stderr: string[] = [];
const flushed: string[] = [];
await finishSuccessfulCliCommand({
command: "query",
format: "json",
cleanup: async () => {
throw new Error("ggml_metal_device_free abort simulation");
},
exit: (code) => {
exitCodes.push(code);
},
stdout: { write: (chunk: string | Uint8Array, cb?: (error?: Error | null) => void) => { flushed.push(String(chunk)); cb?.(); return true; } },
stderr: { write: (chunk: string | Uint8Array, cb?: (error?: Error | null) => void) => { stderr.push(String(chunk)); cb?.(); return true; } },
});
expect(exitCodes).toEqual([0]);
expect(stderr.join("")).toContain("QMD Warning: cleanup after successful output failed");
expect(flushed).toEqual([""]);
});
test("flushes stdout then stderr then exits, disposing along the way", async () => {
// After widening the safe-exit into a process-wide guard installed by the
// LlamaCpp constructor, the per-command 'immediate exit' branch is gone:
// every command takes the same flush → dispose → exit(0) path, and the
// darwin guard catches the C++ static dtor crash at process-exit time.
const calls: string[] = [];
await finishSuccessfulCliCommand({
command: "query",
format: "json",
cleanup: async () => { calls.push("cleanup"); },
exit: (code) => { calls.push(`exit:${code}`); },
stdout: { write: (_chunk: string | Uint8Array, cb?: (error?: Error | null) => void) => { calls.push("stdout-flush"); cb?.(); return true; } },
stderr: { write: (_chunk: string | Uint8Array, cb?: (error?: Error | null) => void) => { calls.push("stderr-flush"); cb?.(); return true; } },
});
expect(calls).toEqual(["stdout-flush", "cleanup", "stderr-flush", "exit:0"]);
});
test("darwin Metal mitigation reflects launcher-exported env on darwin", () => {
// The real mitigation lives in bin/qmd, which sets GGML_METAL_NO_RESIDENCY=1
// before Node loads the llama.cpp native binding. The JS-side predicate
// just reports whether that env was set (and not overridden by
// QMD_METAL_KEEP_RESIDENCY). On non-darwin the function returns false.
const expected =
process.platform === "darwin" &&
process.env.QMD_METAL_KEEP_RESIDENCY !== "1" &&
process.env.GGML_METAL_NO_RESIDENCY === "1";
expect(isDarwinMetalMitigationActive()).toBe(expected);
});
test("QMD_METAL_KEEP_RESIDENCY=1 disables the mitigation even when GGML_METAL_NO_RESIDENCY is set", () => {
const prevKeep = process.env.QMD_METAL_KEEP_RESIDENCY;
const prevNoRes = process.env.GGML_METAL_NO_RESIDENCY;
try {
process.env.QMD_METAL_KEEP_RESIDENCY = "1";
process.env.GGML_METAL_NO_RESIDENCY = "1";
expect(isDarwinMetalMitigationActive()).toBe(false);
} finally {
if (prevKeep === undefined) delete process.env.QMD_METAL_KEEP_RESIDENCY;
else process.env.QMD_METAL_KEEP_RESIDENCY = prevKeep;
if (prevNoRes === undefined) delete process.env.GGML_METAL_NO_RESIDENCY;
else process.env.GGML_METAL_NO_RESIDENCY = prevNoRes;
}
});
test("disposes Llama resources in dependency order before CLI exit", async () => {
const calls: string[] = [];
const llm = new LlamaCpp({ inactivityTimeoutMs: 0 });
const disposable = (name: string) => ({
dispose: async () => {
calls.push(name);
},
});
Object.assign(llm as unknown as Record<string, unknown>, {
embedContexts: [disposable("embed-context")],
rerankContexts: [disposable("rerank-context")],
embedModel: disposable("embed-model"),
generateModel: disposable("generate-model"),
rerankModel: disposable("rerank-model"),
llama: disposable("llama"),
});
await llm.dispose();
expect(calls).toEqual([
"embed-context",
"rerank-context",
"embed-model",
"generate-model",
"rerank-model",
"llama",
]);
});
});