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.
105 lines
4.3 KiB
TypeScript
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",
|
|
]);
|
|
});
|
|
});
|