qmd/src/test-preload.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

50 lines
2.0 KiB
TypeScript

/**
* Test preload file to ensure proper cleanup of native resources.
*
* Uses bun:test afterAll to properly dispose of llama.cpp Metal
* resources before the process exits, avoiding GGML_ASSERT failures.
*/
// Mirror bin/qmd's darwin Metal residency mitigation so `bun test` and
// `vitest` runs exit cleanly. The test runners load node-llama-cpp directly
// without going through the launcher, so the libggml-metal static destructor
// asserts on a non-empty residency set during __cxa_finalize_ranges and dumps
// a multi-kB backtrace at process exit (ggml-org/llama.cpp#17869). Opt back
// in with QMD_METAL_KEEP_RESIDENCY=1 if you're triaging the upstream Metal
// teardown bug.
//
// Two-step propagation, because:
// - Native code in libggml-metal reads via C getenv() at module load time.
// - Node syncs process.env mutations to libc via uv_os_setenv automatically.
// - Bun does NOT — `process.env.X = "1"` only updates the JS-level object,
// so getenv() in the C++ binding still sees nothing (verified empirically
// with bun:ffi). We have to call setenv() ourselves on Bun.
if (process.platform === "darwin" && process.env.QMD_METAL_KEEP_RESIDENCY !== "1") {
process.env.GGML_METAL_NO_RESIDENCY = process.env.GGML_METAL_NO_RESIDENCY || "1";
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
try {
const { dlopen, FFIType, suffix } = await import("bun:ffi");
const libc = dlopen(`libSystem.${suffix}`, {
setenv: { args: [FFIType.cstring, FFIType.cstring, FFIType.i32], returns: FFIType.i32 },
});
libc.symbols.setenv(
Buffer.from("GGML_METAL_NO_RESIDENCY\0", "utf8"),
Buffer.from("1\0", "utf8"),
1,
);
} catch {
// FFI unavailable on this Bun build — the backtrace dump at exit is
// cosmetic; tests still pass.
}
}
}
import { afterAll } from "bun:test";
import { disposeDefaultLlamaCpp } from "./llm";
// Global afterAll runs after all test files complete
afterAll(async () => {
await disposeDefaultLlamaCpp();
});