Affects: sqlite-ai 1.0.7, macOS arm64 (mechanism is platform-independent)
Summary
sqlite3_ai_init() registers the llama.cpp log callback with a pointer to that
connection's ai_context:
// src/sqlite-ai.c:3806
llama_log_set(ai_logger, ctx);
llama_log_set is process-global, so whichever connection loaded the
extension most recently owns the log callback for every connection in the
process. When that connection closes, ai_destroy → ai_free frees its
ai_context, but the global callback still points at it. The next log line
llama.cpp emits dereferences freed memory:
// src/sqlite-ai.c:895
void ai_logger (enum ggml_log_level level, const char *text, void *user_data) {
ai_context *ai = (ai_context *)user_data;
if (ai->db == NULL) return; // <-- ai itself is already freed; this READS freed memory
The ai->db == NULL guard cannot help — ai is the dangling pointer, so the
guard itself is the use-after-free.
llama_context's destructor emits a log line, so simply freeing a context on a
surviving connection is enough to crash.
Reproduction
Two connections in one process, both load the extension and a model. Close the
younger one (the callback owner) first, then free resources on the older one:
a, b = connect(), connect() # both load ai.dylib, both llm_model_load(...)
b.execute("SELECT llm_context_free()")
b.execute("SELECT llm_model_free()")
b.close() # b owned the global log callback; ai_context freed
a.execute("SELECT llm_context_free()") # SIGSEGV
Stack:
sqlite3LockAndPrepare
sqlite_db_write
ai_logger
llama_log_internal
llama_context::~llama_context()
llama_free
Reproduced deterministically both ways: closing oldest-first is clean,
youngest-first segfaults every time.
A milder manifestation of the same mechanism: ai_log ... API misuse lines
printed at process teardown — the callback firing against a connection in the
wrong lifecycle state.
Why it matters
Any host application using more than one connection hits this — and multiple
connections are required for real apps, because one connection holds one model
(e.g. a textgen model on one connection, an embedding model on another).
Connection close order is not a contract application developers expect to carry.
Proposed fix
Smallest correct fix — track the registered owner and deregister on destroy:
static ai_context *ai_log_owner = NULL; // process-global, guarded if needed
// in sqlite3_ai_init:
llama_log_set(ai_logger, ctx);
ai_log_owner = ctx;
// in ai_destroy (or ai_free when free_ai is true):
if (ai_log_owner == ctx) {
llama_log_set(NULL, NULL); // llama.cpp falls back to stderr logging
ai_log_owner = NULL;
}
Alternatives: a registry keyed by sqlite3 * so ai_logger looks up a live
context instead of trusting user_data; or re-pointing the callback to another
still-live connection on close. The llama_log_set(NULL, NULL) variant is the
least code and loses only per-connection log routing after the owner closes.
Workaround until fixed
Close model-holding connections oldest first, so the youngest (the callback
owner) is closed last.
Affects: sqlite-ai 1.0.7, macOS arm64 (mechanism is platform-independent)
Summary
sqlite3_ai_init()registers the llama.cpp log callback with a pointer to thatconnection's
ai_context:llama_log_setis process-global, so whichever connection loaded theextension most recently owns the log callback for every connection in the
process. When that connection closes,
ai_destroy→ai_freefrees itsai_context, but the global callback still points at it. The next log linellama.cpp emits dereferences freed memory:
The
ai->db == NULLguard cannot help —aiis the dangling pointer, so theguard itself is the use-after-free.
llama_context's destructor emits a log line, so simply freeing a context on asurviving connection is enough to crash.
Reproduction
Two connections in one process, both load the extension and a model. Close the
younger one (the callback owner) first, then free resources on the older one:
Stack:
Reproduced deterministically both ways: closing oldest-first is clean,
youngest-first segfaults every time.
A milder manifestation of the same mechanism:
ai_log ... API misuselinesprinted at process teardown — the callback firing against a connection in the
wrong lifecycle state.
Why it matters
Any host application using more than one connection hits this — and multiple
connections are required for real apps, because one connection holds one model
(e.g. a textgen model on one connection, an embedding model on another).
Connection close order is not a contract application developers expect to carry.
Proposed fix
Smallest correct fix — track the registered owner and deregister on destroy:
Alternatives: a registry keyed by
sqlite3 *soai_loggerlooks up a livecontext instead of trusting
user_data; or re-pointing the callback to anotherstill-live connection on close. The
llama_log_set(NULL, NULL)variant is theleast code and loses only per-connection log routing after the owner closes.
Workaround until fixed
Close model-holding connections oldest first, so the youngest (the callback
owner) is closed last.