Skip to content

Use-after-free in ai_logger: closing a connection segfaults other connections still using the extension #32

Description

@andinux

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_destroyai_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions