From 624feaf37199cae5ad4025645f3a3253302ab34c Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Fri, 21 Aug 2026 12:49:47 +0200 Subject: [PATCH 1/5] ci: pin the Vulkan SDK, and stop one test failure cascading to the suite Three unrelated-to-each-other CI problems surfaced by run 32390030022. 1. linux-gpu-x86_64 could not build llama.cpp: Could NOT find Vulkan (missing: Vulkan_LIBRARY) (found version "1.4.357") ggml/src/ggml-vulkan/CMakeLists.txt:9 (find_package) The install step fetches the SDK from a "latest" URL. LunarG 1.4.357.1 moved the loader to lib/VulkanLoader/lib, where CMake's FindVulkan does not look, so CI broke on LunarG's release schedule rather than on any change of ours. Pin to 1.4.350.1, the release before the move, via a VULKAN_SDK_VERSION workflow variable so bumping is a deliberate edit. 2. A single failing test reported 37 failures. assert_sqlite_memory_clean() asserts that SQLITE_STATUS_MEMORY_USED is back to zero, but that counter is process-global: once one test bails out through its fail: label without a clean teardown, every later test sees the same leaked bytes and fails too - including tests that share nothing with it, down to the whisper ones. Capture a baseline before each test and compare against it, so a leak is attributed to the test that caused it. Verified by injecting one deliberately unclosed connection: 32 tests failed before this change, 1 after. The summary line now also names the first failure, which is what you actually want from a CI log. 3. The tests called sqlite3_close(), which returns SQLITE_BUSY and leaves the connection - and with it the extension's whole ai_context - alive if anything is unfinalized. Use sqlite3_close_v2() so teardown cannot be silently skipped. Also unpacks llama_decode()'s return code at every call site. It distinguishes "no KV slot" (1), "aborted" (2), "invalid batch" (-1) and fatal (< -1), and we were discarding it, which is why the arm64 failure in that run could only be diagnosed as "Failed to decode prompt batch". The embedding path already printed the code; it now prints the reason too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt --- .github/workflows/main.yml | 9 +- src/sqlite-ai.c | 39 +++++-- tests/c/unittest.c | 201 +++++++++++++++++++++---------------- 3 files changed, 147 insertions(+), 102 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8fbd2f2..5293334 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,6 +21,8 @@ env: AUDIO_TEST_DIR: tests/audio AUDIO_TEST_WAV: tests/audio/jfk.wav AUDIO_TEST_WAV_URL: https://github.com/ggml-org/whisper.cpp/raw/master/samples/jfk.wav + # Pinned deliberately - see the "linux-x86_64 install vulkan" step. + VULKAN_SDK_VERSION: 1.4.350.1 jobs: download-models: @@ -308,8 +310,11 @@ jobs: sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-jammy.list https://packages.lunarg.com/vulkan/lunarg-vulkan-jammy.list sudo apt-get update -y sudo apt-get install -y mesa-vulkan-drivers - # Vulkan is no longer packed for Ubuntu - wget https://sdk.lunarg.com/sdk/download/latest/linux/vulkan-sdk.tar.xz?Human=true -O vulkan-sdk.tar.xz + # Vulkan is no longer packed for Ubuntu. + # Pinned: the "latest" URL moved the loader to lib/VulkanLoader/lib in + # 1.4.357.1, which CMake's FindVulkan cannot locate ("Could NOT find + # Vulkan (missing: Vulkan_LIBRARY)"). Bump deliberately, not implicitly. + wget https://sdk.lunarg.com/sdk/download/${VULKAN_SDK_VERSION}/linux/vulkan-sdk.tar.xz?Human=true -O vulkan-sdk.tar.xz tar -xf vulkan-sdk.tar.xz cd $(ls -d 1.* | head -n1) source setup-env.sh diff --git a/src/sqlite-ai.c b/src/sqlite-ai.c index 566b403..11aba03 100644 --- a/src/sqlite-ai.c +++ b/src/sqlite-ai.c @@ -675,6 +675,18 @@ static bool llm_context_options_callback (void *ctx, void *xdata, const char *ke return true; } +// llama_decode() return codes (llama.h): 1 no KV slot, 2 aborted, +// -1 invalid batch, < -1 fatal. Collapsing them into one message makes +// four different failures indistinguishable in a CI log. +static const char *llm_decode_error_string (int32_t rc) { + switch (rc) { + case 1: return "could not find a KV slot for the batch, context is full"; + case 2: return "decoding was aborted"; + case -1: return "invalid input batch"; + default: return "fatal error"; + } +} + struct llama_sampler *llm_sampler_check (ai_context *ai) { if (ai->sampler) return ai->sampler; @@ -1378,7 +1390,7 @@ static void llm_embed_generate_run (sqlite3_context *context, const char *text, if (rc != 0) { sqlite3_free(tokens); sqlite3_free(embedding); - sqlite_context_result_error(context, SQLITE_ERROR, "Model %s failed during embedding generation (%d)", is_encoder_only ? "encode" : "decode", rc); + sqlite_context_result_error(context, SQLITE_ERROR, "Model %s failed during embedding generation (%d: %s)", is_encoder_only ? "encode" : "decode", rc, llm_decode_error_string(rc)); return; } @@ -1620,8 +1632,9 @@ static void llm_text_run (sqlite3_context *context, const char *text, int32_t te int chunk = n_prompt - prompt_pos; if (chunk > n_batch) chunk = n_batch; struct llama_batch batch = llama_batch_get_one(tokens + prompt_pos, chunk); - if (llama_decode(ctx, batch)) { - sqlite_context_result_error(context, SQLITE_ERROR, "Failed to execute the decoding function during prompt processing"); + int32_t drc = llama_decode(ctx, batch); + if (drc != 0) { + sqlite_context_result_error(context, SQLITE_ERROR, "Failed to execute the decoding function during prompt processing (%d: %s)", drc, llm_decode_error_string(drc)); goto error_sampler; } prompt_pos += chunk; @@ -1652,8 +1665,9 @@ static void llm_text_run (sqlite3_context *context, const char *text, int32_t te // decode the sampled token to advance the KV cache struct llama_batch batch = llama_batch_get_one(&new_token_id, 1); - if (llama_decode(ctx, batch)) { - sqlite_context_result_error(context, SQLITE_ERROR, "Failed to execute the decoding function during generation"); + int32_t drc = llama_decode(ctx, batch); + if (drc != 0) { + sqlite_context_result_error(context, SQLITE_ERROR, "Failed to execute the decoding function during generation (%d: %s)", drc, llm_decode_error_string(drc)); goto error_sampler; } } @@ -1803,8 +1817,9 @@ static bool llm_chat_generate_response (ai_context *ai, ai_cursor *c, bool *is_e return false; } - if (llama_decode(ctx, batch)) { - sqlite_common_set_error (ai->context, ai->vtab, SQLITE_ERROR, "Failed to decode prompt batch"); + int32_t drc = llama_decode(ctx, batch); + if (drc != 0) { + sqlite_common_set_error (ai->context, ai->vtab, SQLITE_ERROR, "Failed to decode prompt batch (%d: %s)", drc, llm_decode_error_string(drc)); return false; } @@ -3209,8 +3224,9 @@ static void llm_text_run_vision (sqlite3_context *context, const char *text, int } struct llama_batch batch = llama_batch_get_one(&new_token_id, 1); - if (llama_decode(ctx, batch)) { - sqlite_context_result_error(context, SQLITE_ERROR, "Failed to decode during generation"); + int32_t drc = llama_decode(ctx, batch); + if (drc != 0) { + sqlite_context_result_error(context, SQLITE_ERROR, "Failed to decode during generation (%d: %s)", drc, llm_decode_error_string(drc)); goto error_sampler; } } @@ -3379,8 +3395,9 @@ static void llm_chat_respond_vision (sqlite3_context *context, ai_context *ai, } struct llama_batch batch = llama_batch_get_one(&token_id, 1); - if (llama_decode(ctx, batch)) { - sqlite_context_result_error(context, SQLITE_ERROR, "Failed to decode during generation"); + int32_t drc = llama_decode(ctx, batch); + if (drc != 0) { + sqlite_context_result_error(context, SQLITE_ERROR, "Failed to decode during generation (%d: %s)", drc, llm_decode_error_string(drc)); goto error; } } diff --git a/tests/c/unittest.c b/tests/c/unittest.c index 90e0689..5647895 100644 --- a/tests/c/unittest.c +++ b/tests/c/unittest.c @@ -48,7 +48,7 @@ static int open_db_and_load(const test_env *env, sqlite3 **out_db) { int rc = sqlite3_open(":memory:", &db); if (rc != SQLITE_OK) { fprintf(stderr, "sqlite3_open failed: %s\n", db ? sqlite3_errmsg(db) : "unknown error"); - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return rc; } sqlite3_enable_load_extension(db, 1); @@ -61,7 +61,7 @@ static int open_db_and_load(const test_env *env, sqlite3 **out_db) { if (rc != SQLITE_OK) { fprintf(stderr, "sqlite3_load_extension failed: %s\n", errmsg ? errmsg : sqlite3_errmsg(db)); if (errmsg) sqlite3_free(errmsg); - sqlite3_close(db); + sqlite3_close_v2(db); return rc; } if (errmsg) sqlite3_free(errmsg); @@ -181,6 +181,21 @@ static int exec_select_rows(const test_env *env, sqlite3 *db, const char *sql, i return 0; } +// SQLITE_STATUS_MEMORY_USED is a PROCESS-GLOBAL counter. Asserting that it is +// back to zero means one test that bails out without a clean teardown makes +// every later test report the same phantom leak - a single real failure turns +// into a whole-suite failure and the culprit is buried. Compare against a +// baseline captured just before each test instead, so a leak is attributed to +// the test that actually caused it. +static sqlite3_int64 memory_baseline = 0; + +static void capture_sqlite_memory_baseline(void) { + sqlite3_int64 highwater = 0; + if (sqlite3_status64(SQLITE_STATUS_MEMORY_USED, &memory_baseline, &highwater, 0) != SQLITE_OK) { + memory_baseline = 0; + } +} + static int assert_sqlite_memory_clean(const char *label, const test_env *env) { sqlite3_int64 current = 0; sqlite3_int64 highwater = 0; @@ -189,12 +204,14 @@ static int assert_sqlite_memory_clean(const char *label, const test_env *env) { return 1; } if (env->verbose) { - printf("[STATUS][%s] memory current=%lld highwater=%lld\n", - label, (long long)current, (long long)highwater); - } - if (current != 0) { - fprintf(stderr, "[%s] sqlite3 memory leak detected: current=%lld highwater=%lld\n", - label, (long long)current, (long long)highwater); + printf("[STATUS][%s] memory current=%lld baseline=%lld highwater=%lld\n", + label, (long long)current, (long long)memory_baseline, (long long)highwater); + } + if (current != memory_baseline) { + fprintf(stderr, "[%s] sqlite3 memory leak detected: %lld byte(s) not released " + "(current=%lld baseline=%lld highwater=%lld)\n", + label, (long long)(current - memory_baseline), + (long long)current, (long long)memory_baseline, (long long)highwater); return 1; } return 0; @@ -326,7 +343,7 @@ static int test_issue15_chat_without_context(const test_env *env) { } int rc = exec_expect_error(env, db, "SELECT llm_chat_create();", "Please call llm_context_create()"); - sqlite3_close(db); + sqlite3_close_v2(db); if (rc == 0) { return assert_sqlite_memory_clean("issue15", env); } @@ -343,15 +360,15 @@ static int test_llm_chat_respond_repeated(const test_env *env) { const char *model = env->model_path ? env->model_path : DEFAULT_MODEL_PATH; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) { - sqlite3_close(db); + sqlite3_close_v2(db); return 1; } if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1000');") != 0) { - sqlite3_close(db); + sqlite3_close_v2(db); return 1; } if (exec_expect_ok(env, db, "SELECT llm_chat_create();") != 0) { - sqlite3_close(db); + sqlite3_close_v2(db); return 1; } @@ -363,30 +380,30 @@ static int test_llm_chat_respond_repeated(const test_env *env) { }; for (int i = 0; i < iterations; ++i) { if (exec_expect_ok(env, db, prompts[i]) != 0) { - sqlite3_close(db); + sqlite3_close_v2(db); return 1; } if (exec_expect_ok(env, db, "SELECT llm_context_used() AS context_used, llm_context_size() AS context_size, CAST(llm_context_used() AS FLOAT)/CAST(llm_context_size() AS FLOAT) || '%' AS 'context_usage_percentage';") != 0) { - sqlite3_close(db); + sqlite3_close_v2(db); return 1; } } if (exec_expect_ok(env, db, "SELECT llm_chat_free();") != 0) { - sqlite3_close(db); + sqlite3_close_v2(db); return 1; } if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) { - sqlite3_close(db); + sqlite3_close_v2(db); return 1; } if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) { - sqlite3_close(db); + sqlite3_close_v2(db); return 1; } - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("chat_respond_repeated", env); } @@ -418,12 +435,12 @@ static int test_llm_chat_vtab(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_chat_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("chat_vtab", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -446,7 +463,7 @@ static int test_llm_embed_generate(const test_env *env) { // Intentionally skip llm_context_free/llm_model_free to mimic how Python GC drops // connections without calling the cleanup helpers (see GH issue #14). - sqlite3_close(db); + sqlite3_close_v2(db); db = NULL; // Reopening another connection will reinitialize the extension; on unfixed builds @@ -454,12 +471,12 @@ static int test_llm_embed_generate(const test_env *env) { if (open_db_and_load(env, &db) != SQLITE_OK) { return 1; } - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("llm_embed_generate", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -504,7 +521,7 @@ static int test_llm_embed_generate_basic(const test_env *env) { if (stmt) sqlite3_finalize(stmt); exec_expect_ok(env, db, "SELECT llm_context_free();"); exec_expect_ok(env, db, "SELECT llm_model_free();"); - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); if (status == 0) { if (assert_sqlite_memory_clean("llm_embed_generate_basic", env) != 0) { return 1; @@ -535,11 +552,11 @@ static int test_llm_embedding_then_chat(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("llm_embedding_then_chat", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -550,7 +567,7 @@ static int test_llm_context_size_errors(const test_env *env) { } if (exec_expect_error(env, db, "SELECT llm_context_size();", "No context found") != 0) { - sqlite3_close(db); + sqlite3_close_v2(db); return 1; } @@ -563,11 +580,11 @@ static int test_llm_context_size_errors(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("llm_context_size_errors", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -594,11 +611,11 @@ static int test_document_ingestion_flow(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("document_ingestion_flow", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -626,11 +643,11 @@ static int test_llm_sampler_roundtrip(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("llm_sampler_roundtrip", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -711,7 +728,7 @@ static int test_llm_model_load_error_recovery(const test_env *env) { } if (exec_expect_error(env, db, "SELECT llm_model_load('/path/that/does/not/exist.gguf');", "Unable to load model") != 0) { - sqlite3_close(db); + sqlite3_close_v2(db); return 1; } @@ -723,11 +740,11 @@ static int test_llm_model_load_error_recovery(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("llm_model_load_error_recovery", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -751,11 +768,11 @@ static int test_ai_logging_table(const test_env *env) { } if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("ai_logging_table", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -809,11 +826,11 @@ static int test_llm_embed_input_too_large(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("llm_embed_input_too_large", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -854,11 +871,11 @@ static int test_llm_embed_nctx_exceeds_train(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("llm_embed_nctx_exceeds_train", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -879,11 +896,11 @@ static int test_llm_embed_max_tokens_limit(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("llm_embed_max_tokens_limit", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -928,12 +945,12 @@ static int test_llm_embed_repeated_calls(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("llm_embed_repeated_calls", env); fail: if (stmt) sqlite3_finalize(stmt); - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -968,11 +985,11 @@ static int test_llm_embed_empty_input(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("llm_embed_empty_input", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1041,7 +1058,7 @@ static int test_chat_system_prompt_new_chat(const test_env *env) { if (chat_created) exec_expect_ok(env, db, "SELECT llm_chat_free();"); if (context_created) exec_expect_ok(env, db, "SELECT llm_context_free();"); if (model_loaded) exec_expect_ok(env, db, "SELECT llm_model_free();"); - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); if (status == 0) status = assert_sqlite_memory_clean("llm_context_size_errors", env); return status; } @@ -1105,7 +1122,7 @@ static int test_chat_system_prompt_replace_previous_prompt(const test_env *env) if (chat_created) exec_expect_ok(env, db, "SELECT llm_chat_free();"); if (context_created) exec_expect_ok(env, db, "SELECT llm_context_free();"); if (model_loaded) exec_expect_ok(env, db, "SELECT llm_model_free();"); - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); if (status == 0) status = assert_sqlite_memory_clean("llm_context_size_errors", env); return status; } @@ -1186,7 +1203,7 @@ static int test_chat_system_prompt_after_first_response(const test_env *env) { if (chat_created) exec_expect_ok(env, db, "SELECT llm_chat_free();"); if (context_created) exec_expect_ok(env, db, "SELECT llm_context_free();"); if (model_loaded) exec_expect_ok(env, db, "SELECT llm_model_free();"); - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); if (status == 0) status = assert_sqlite_memory_clean("llm_context_size_errors", env); return status; } @@ -1215,11 +1232,11 @@ static int test_chat_create_free_cycle(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("chat_create_free_cycle", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1256,11 +1273,11 @@ static int test_chat_recreate_after_conversation(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("chat_recreate_after_conversation", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1299,11 +1316,11 @@ static int test_chat_vtab_multi_turn(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("chat_vtab_multi_turn", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1358,11 +1375,11 @@ static int test_chat_save_restore_roundtrip(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("chat_save_restore_roundtrip", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1404,11 +1421,11 @@ static int test_chat_system_prompt_clear(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("chat_system_prompt_clear", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1437,11 +1454,11 @@ static int test_text_generate_with_eog(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("text_generate_with_eog", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1466,11 +1483,11 @@ static int test_chat_double_free(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("chat_double_free", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1495,11 +1512,11 @@ static int test_chat_respond_auto_init(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("chat_respond_auto_init", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1531,11 +1548,11 @@ static int test_chat_save_with_metadata(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("chat_save_with_metadata", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1562,11 +1579,11 @@ static int test_text_generate_default_limit(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("text_generate_default_limit", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1580,7 +1597,7 @@ static int test_audio_transcribe_no_model(const test_env *env) { if (open_db_and_load(env, &db) != SQLITE_OK) return 1; int rc = exec_expect_error(env, db, "SELECT audio_model_transcribe('/tmp/test.wav');", "No model"); - sqlite3_close(db); + sqlite3_close_v2(db); if (rc != 0) return rc; return assert_sqlite_memory_clean("audio_transcribe_no_model", env); } @@ -1591,7 +1608,7 @@ static int test_audio_model_load_invalid_path(const test_env *env) { if (open_db_and_load(env, &db) != SQLITE_OK) return 1; int rc = exec_expect_error(env, db, "SELECT audio_model_load('/nonexistent/model.bin');", "Unable to load audio model"); - sqlite3_close(db); + sqlite3_close_v2(db); if (rc != 0) return rc; return assert_sqlite_memory_clean("audio_model_load_invalid_path", env); } @@ -1608,11 +1625,11 @@ static int test_audio_model_load_free(const test_env *env) { char sql[1024]; snprintf(sql, sizeof(sql), "SELECT audio_model_load('%s');", env->whisper_model_path); - if (exec_expect_ok(env, db, sql) != 0) { sqlite3_close(db); return 1; } + if (exec_expect_ok(env, db, sql) != 0) { sqlite3_close_v2(db); return 1; } - if (exec_expect_ok(env, db, "SELECT audio_model_free();") != 0) { sqlite3_close(db); return 1; } + if (exec_expect_ok(env, db, "SELECT audio_model_free();") != 0) { sqlite3_close_v2(db); return 1; } - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("audio_model_load_free", env); } @@ -1644,10 +1661,10 @@ static int test_audio_transcribe_file(const test_env *env) { if (exec_expect_ok(env, db, "SELECT audio_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("audio_transcribe_file", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1716,10 +1733,10 @@ static int test_audio_transcribe_blob(const test_env *env) { if (exec_expect_ok(env, db, "SELECT audio_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("audio_transcribe_blob", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1750,10 +1767,10 @@ static int test_audio_transcribe_with_options(const test_env *env) { if (exec_expect_ok(env, db, "SELECT audio_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("audio_transcribe_with_options", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1781,10 +1798,10 @@ static int test_audio_transcribe_unsupported_format(const test_env *env) { if (exec_expect_ok(env, db, "SELECT audio_model_free();") != 0) goto fail; - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("audio_transcribe_unsupported_format", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1805,10 +1822,10 @@ static int test_audio_model_load_free_cycle(const test_env *env) { if (exec_expect_ok(env, db, "SELECT audio_model_free();") != 0) goto fail; } - sqlite3_close(db); + sqlite3_close_v2(db); return assert_sqlite_memory_clean("audio_model_load_free_cycle", env); fail: - if (db) sqlite3_close(db); + if (db) sqlite3_close_v2(db); return 1; } @@ -1913,7 +1930,7 @@ static int test_llm_chat_double_save(const test_env *env) { if (model_loaded) exec_expect_ok(env, db, "SELECT llm_model_free();"); if (db) - sqlite3_close(db); + sqlite3_close_v2(db); if (status == 0) status = assert_sqlite_memory_clean("llm_chat_double_save", env); return status; @@ -2018,6 +2035,7 @@ int main(int argc, char **argv) { size_t total = sizeof(TESTS) / sizeof(TESTS[0]); int failures = 0; + const char *first_failure = NULL; if (selected_test) printf("Running 1 C test\n\n"); else printf("Running %zu C test(s)\n\n", total); @@ -2026,9 +2044,13 @@ int main(int argc, char **argv) { if (selected_test && strcmp(tc->name, selected_test) != 0) { continue; } + capture_sqlite_memory_baseline(); int rc = tc->fn(&env); printf("- %s ... %s\n", tc->name, rc == 0 ? "PASS" : "FAIL"); - if (rc != 0) failures += 1; + if (rc != 0) { + if (failures == 0) first_failure = tc->name; + failures += 1; + } } if (selected_test && failures == 0) { bool found = false; @@ -2045,7 +2067,8 @@ int main(int argc, char **argv) { } if (failures) { - fprintf(stderr, "\n%d C test(s) failed.\n", failures); + fprintf(stderr, "\n%d C test(s) failed. First failure: %s\n", failures, + first_failure ? first_failure : "unknown"); return EXIT_FAILURE; } From 967bb10a01b53ec2f390800ca9a892f7ca4091a3 Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Fri, 21 Aug 2026 13:09:51 +0200 Subject: [PATCH 2/5] ci: point CMake at the Vulkan loader instead of pinning around it The pin in the previous commit was based on a wrong diagnosis. 1.4.350.1 turns out to have the same layout as 1.4.357.1 - it also keeps the loader in lib/VulkanLoader/lib - so it failed identically: Could NOT find Vulkan (missing: Vulkan_LIBRARY) (found version "1.4.350") FindVulkan locates the headers and reads the version out of them, then looks for libvulkan.so in the usual places and does not find it. No SDK version we can reach fixes that, so stop trying to dodge the layout and just tell CMake where the loader is: find libvulkan.so under $VULKAN_SDK and export its directory as CMAKE_LIBRARY_PATH, which find_library() searches. Verified locally that the CMAKE_LIBRARY_PATH environment variable alone is enough to resolve an otherwise unfindable library - no CMakeLists or Makefile change. Discovering the path rather than hard-coding it means the next layout change fails here, with a listing of what libvulkan.so* files actually exist, instead of 300 lines into a CMake trace. The version pin stays, for reproducibility rather than as a fix: an unpinned "latest" toolchain in CI is a liability on its own. Kept at 1.4.350.1 - the tarball is already known to download and unpack correctly in this job, so only one variable changes here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt --- .github/workflows/main.yml | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5293334..6c6e0e2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,7 +21,8 @@ env: AUDIO_TEST_DIR: tests/audio AUDIO_TEST_WAV: tests/audio/jfk.wav AUDIO_TEST_WAV_URL: https://github.com/ggml-org/whisper.cpp/raw/master/samples/jfk.wav - # Pinned deliberately - see the "linux-x86_64 install vulkan" step. + # Pinned so the Vulkan toolchain cannot change under CI without a visible + # diff. Bump deliberately - see the "linux-x86_64 install vulkan" step. VULKAN_SDK_VERSION: 1.4.350.1 jobs: @@ -310,14 +311,26 @@ jobs: sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-jammy.list https://packages.lunarg.com/vulkan/lunarg-vulkan-jammy.list sudo apt-get update -y sudo apt-get install -y mesa-vulkan-drivers - # Vulkan is no longer packed for Ubuntu. - # Pinned: the "latest" URL moved the loader to lib/VulkanLoader/lib in - # 1.4.357.1, which CMake's FindVulkan cannot locate ("Could NOT find - # Vulkan (missing: Vulkan_LIBRARY)"). Bump deliberately, not implicitly. + # Vulkan is no longer packed for Ubuntu wget https://sdk.lunarg.com/sdk/download/${VULKAN_SDK_VERSION}/linux/vulkan-sdk.tar.xz?Human=true -O vulkan-sdk.tar.xz tar -xf vulkan-sdk.tar.xz cd $(ls -d 1.* | head -n1) source setup-env.sh + # The SDK keeps the loader in lib/VulkanLoader/lib, which CMake's + # FindVulkan does not search: it finds the headers and the version but + # then fails with "Could NOT find Vulkan (missing: Vulkan_LIBRARY)". + # Locate libvulkan.so wherever the tarball puts it and hand the + # directory to find_library() via the CMAKE_LIBRARY_PATH env var, so a + # future layout change is a loud error here rather than a cryptic one + # 300 lines into a CMake trace. + vk_loader=$(find "$VULKAN_SDK" -name libvulkan.so -print -quit) + if [ -z "$vk_loader" ]; then + echo "::error::libvulkan.so not found under $VULKAN_SDK" + find "$VULKAN_SDK" -name 'libvulkan.so*' + exit 1 + fi + echo "Vulkan loader: $vk_loader" + echo "CMAKE_LIBRARY_PATH=$(dirname "$vk_loader")" >> $GITHUB_ENV echo "VULKAN_SDK=$VULKAN_SDK" >> $GITHUB_ENV echo "PATH=$PATH" >> $GITHUB_ENV echo "LD_LIBRARY_PATH=$LD_LIBRARY_PATH" >> $GITHUB_ENV From d224867fba623fc8cb97c012721eb8bf065aa25e Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Fri, 21 Aug 2026 13:17:17 +0200 Subject: [PATCH 3/5] ci: also put the Vulkan loader on the link path CMAKE_LIBRARY_PATH got llama.cpp configuring and building, but our own link step then failed: /usr/bin/ld: cannot find -lvulkan: No such file or directory make: *** [Makefile:228: dist/ai.so] Error 1 Makefile:199 passes -L$(VULKAN_SDK)/lib -lvulkan, which encodes the same assumption FindVulkan makes: that the loader is directly under lib/. It is not, so ld had no more luck than CMake did. Export the discovered directory as LIBRARY_PATH too, which gcc searches when resolving -l. Verified locally that LIBRARY_PATH alone links a library that is otherwise not found. Fixing Makefile:199 to locate the loader properly would help anyone building against a current SDK outside CI, but that is a shared build file with Windows and macOS paths through it; keeping the workaround in the CI step that creates the odd layout is the smaller change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt --- .github/workflows/main.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6c6e0e2..ac8a252 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -329,8 +329,13 @@ jobs: find "$VULKAN_SDK" -name 'libvulkan.so*' exit 1 fi + vk_libdir=$(dirname "$vk_loader") echo "Vulkan loader: $vk_loader" - echo "CMAKE_LIBRARY_PATH=$(dirname "$vk_loader")" >> $GITHUB_ENV + # CMAKE_LIBRARY_PATH for llama.cpp's find_library(), LIBRARY_PATH for + # our own link step: the Makefile passes -L$VULKAN_SDK/lib -lvulkan, + # and the loader is not in that directory either. + echo "CMAKE_LIBRARY_PATH=$vk_libdir" >> $GITHUB_ENV + echo "LIBRARY_PATH=$vk_libdir${LIBRARY_PATH:+:$LIBRARY_PATH}" >> $GITHUB_ENV echo "VULKAN_SDK=$VULKAN_SDK" >> $GITHUB_ENV echo "PATH=$PATH" >> $GITHUB_ENV echo "LD_LIBRARY_PATH=$LD_LIBRARY_PATH" >> $GITHUB_ENV From 34fc3eb7a0e9a5be043b0e39a22636ffca888066 Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Fri, 21 Aug 2026 13:31:32 +0200 Subject: [PATCH 4/5] test: seed llm_sampler_roundtrip so it stops being a coin flip linux-gpu-x86_64 failed on it once the Vulkan build was fixed and the suite could actually run: Failed to decode prompt batch (1: could not find a KV slot for the batch, context is full) That return code - added in this branch - is what identifies it. Code 1 is not resource exhaustion, it is the context filling up. llm_sampler_init_dist() with no argument passes LLAMA_DEFAULT_SEED, which llama_sampler_init_dist() resolves to a random seed from random_device or the system clock. So the test sampled a different generation on every CI run. Locally the same "Say hello" produced replies of 55, 238, 32, 52, 36 and 61 characters across six runs; on an unlucky draw gemma-3-270m never emits EOG and runs to the 1024-token context, and the decode fails. Seeding it produces 56 characters every time. This does not cover the chat tests, which get their sampler from llm_chat_check_context() and so inherit the same unseeded dist - that needs a decision about whether the fix belongs in the tests or in how the chat path handles a full context, so it is left alone here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt --- tests/c/unittest.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/c/unittest.c b/tests/c/unittest.c index 5647895..7ece08c 100644 --- a/tests/c/unittest.c +++ b/tests/c/unittest.c @@ -634,8 +634,12 @@ static int test_llm_sampler_roundtrip(const test_env *env) { if (exec_expect_ok(env, db, "SELECT llm_sampler_init_top_k(20);") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_sampler_init_temp(0.7);") != 0) goto fail; // dist or greedy step must be added at the end of the sampler chain - // otherwise the llm_chat_respond function will crash - if (exec_expect_ok(env, db, "SELECT llm_sampler_init_dist();") != 0) goto fail; + // otherwise the llm_chat_respond function will crash. + // Seeded on purpose: llm_sampler_init_dist() with no argument uses + // LLAMA_DEFAULT_SEED, which llama.cpp resolves to a random seed, so the + // reply length varied run to run (32-238 chars locally) and occasionally + // ran to the 1024-token context limit, failing the decode. + if (exec_expect_ok(env, db, "SELECT llm_sampler_init_dist(42);") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_create();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_respond('Say hello');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_free();") != 0) goto fail; From d41dba12d8c21825e427f6066a38d7bfea07db37 Mon Sep 17 00:00:00 2001 From: Andrea Donetti Date: Fri, 21 Aug 2026 16:49:48 +0200 Subject: [PATCH 5/5] test: seed the chat sampler everywhere, and stop relying on context_size=512 The chat tests never create a sampler, so they inherit the default chain from llm_chat_check_context(): min_p -> temp(0.8) -> dist(LLAMA_DEFAULT_SEED). llama.cpp resolves that seed to a random one, so all 14 of them sampled a different generation on every CI run, and a bad draw ran the context out and failed the decode. That is the flake that took down two arm64 jobs in 32390030022 and linux-gpu-x86_64 in 32476521456. install_seeded_chat_sampler() installs the same chain with a fixed seed. It is called after llm_model_load() on purpose: once #26 lands, loading a model releases the connection's sampler, so installing it earlier would silently leave the tests random again. Seeding alone was not enough. llm_embedding_then_chat then failed every run, because seed 42 is a draw where gemma-3-270m never emits EOG for its prompt. Seeds 1, 7, 123 and 2024 all terminate; the helper uses 7. That test was also unbounded. src/sqlite-ai.c:2736 decides "the caller did not set context_size" by comparing against llama_context_default_params().n_ctx, which is 512 - so an explicit context_size=512 is indistinguishable from unset and silently becomes the model's full n_ctx_train: context_size=64 -> n_ctx=256 context_size=512 -> n_ctx=32768 <-- the sentinel collision context_size=1000 -> n_ctx=1024 Every test asking for 512 was really running with a 32768-token context, which is why a runaway generation there costs minutes rather than failing quickly - and is the likely explanation for the ~16 minute arm64 jobs. The tests now ask for 1024 (768 for the embedding context) so they get what they intended. The sentinel collision itself is a product bug and is left for an issue. Also covers what the seeding displaces: with every chat test installing its own sampler, nothing exercised llm_chat_check_context()'s default-chain creation. chat_default_sampler_autocreate does, without generating a token, by calling llm_chat_create() - which runs check_context - on a connection that never called llm_sampler_create(). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt --- tests/c/unittest.c | 97 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/tests/c/unittest.c b/tests/c/unittest.c index 7ece08c..1b820a7 100644 --- a/tests/c/unittest.c +++ b/tests/c/unittest.c @@ -146,6 +146,19 @@ static int exec_expect_ok(const test_env *env, sqlite3 *db, const char *sql) { return 0; } +// llm_chat_check_context() installs min_p -> temp(0.8) -> dist(LLAMA_DEFAULT_SEED), +// and llama.cpp resolves that seed to a random one, so reply length varied run to +// run and occasionally ran the context out - see test_llm_sampler_roundtrip. Install +// the same chain with a fixed seed. Call this AFTER llm_model_load(): loading a model +// releases the connection's sampler. +static int install_seeded_chat_sampler(const test_env *env, sqlite3 *db) { + if (exec_expect_ok(env, db, "SELECT llm_sampler_create();") != 0) return 1; + if (exec_expect_ok(env, db, "SELECT llm_sampler_init_min_p(0.05, 1);") != 0) return 1; + if (exec_expect_ok(env, db, "SELECT llm_sampler_init_temp(0.8);") != 0) return 1; + if (exec_expect_ok(env, db, "SELECT llm_sampler_init_dist(7);") != 0) return 1; + return 0; +} + static int exec_select_rows(const test_env *env, sqlite3 *db, const char *sql, int *rows_out) { if (env->verbose) { printf("[SQL] %s\n", sql); @@ -363,6 +376,10 @@ static int test_llm_chat_respond_repeated(const test_env *env) { sqlite3_close_v2(db); return 1; } + if (install_seeded_chat_sampler(env, db) != 0) { + sqlite3_close_v2(db); + return 1; + } if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1000');") != 0) { sqlite3_close_v2(db); return 1; @@ -418,6 +435,7 @@ static int test_llm_chat_vtab(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; + if (install_seeded_chat_sampler(env, db) != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1000');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_create();") != 0) goto fail; int rows = 0; @@ -540,12 +558,13 @@ static int test_llm_embedding_then_chat(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; + if (install_seeded_chat_sampler(env, db) != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_context_create_embedding('embedding_type=UINT8');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_embed_generate('document text for embeddings');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; - if (exec_expect_ok(env, db, "SELECT llm_context_create_chat('context_size=512');") != 0) goto fail; + if (exec_expect_ok(env, db, "SELECT llm_context_create_chat('context_size=1024');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_create();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_respond('Summarize the previous document.');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_free();") != 0) goto fail; @@ -598,6 +617,7 @@ static int test_document_ingestion_flow(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; + if (install_seeded_chat_sampler(env, db) != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_context_create_embedding('context_size=768,embedding_type=UINT8');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_embed_generate('Document chunk content.');") != 0) goto fail; @@ -655,6 +675,43 @@ static int test_llm_sampler_roundtrip(const test_env *env) { return 1; } +// Every chat test installs a seeded sampler, so none of them exercise +// llm_chat_check_context()'s default-chain creation any more. Cover it here +// without generating a single token: llm_chat_create() runs check_context, so a +// successful call on a connection that never called llm_sampler_create() proves +// the default chain was built. +static int test_chat_default_sampler_autocreate(const test_env *env) { + sqlite3 *db = NULL; + if (open_db_and_load(env, &db) != SQLITE_OK) return 1; + + const char *model = env->model_path ? env->model_path : DEFAULT_MODEL_PATH; + char sqlbuf[512]; + snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); + if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; + if (exec_expect_ok(env, db, "SELECT llm_context_create_chat('context_size=1024');") != 0) goto fail; + + // deliberately no llm_sampler_create(): the default chain must be built for us + char uuid[128] = {0}; + if (exec_query_text(env, db, "SELECT llm_chat_create();", uuid, sizeof(uuid)) != 0) goto fail; + if (uuid[0] == '\0') { + fprintf(stderr, "[chat_default_sampler_autocreate] expected a chat uuid\n"); + goto fail; + } + if (env->verbose) printf("[chat_default_sampler_autocreate] uuid: %s\n", uuid); + + if (exec_expect_ok(env, db, "SELECT llm_chat_free();") != 0) goto fail; + if (exec_expect_ok(env, db, "SELECT llm_sampler_free();") != 0) goto fail; + if (exec_expect_ok(env, db, "SELECT llm_context_free();") != 0) goto fail; + if (exec_expect_ok(env, db, "SELECT llm_model_free();") != 0) goto fail; + + sqlite3_close_v2(db); + return assert_sqlite_memory_clean("chat_default_sampler_autocreate", env); + +fail: + if (db) sqlite3_close_v2(db); + return 1; +} + static int test_dual_connection_roles(const test_env *env) { sqlite3 *db_embed = NULL; sqlite3 *db_text = NULL; @@ -666,26 +723,27 @@ static int test_dual_connection_roles(const test_env *env) { snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db_embed, sqlbuf) != 0) goto fail; - if (exec_expect_ok(env, db_embed, "SELECT llm_context_create_embedding('context_size=512,embedding_type=UINT8');") != 0) goto fail; + if (exec_expect_ok(env, db_embed, "SELECT llm_context_create_embedding('context_size=768,embedding_type=UINT8');") != 0) goto fail; if (exec_expect_ok(env, db_embed, "SELECT llm_embed_generate('dual connection embedding text');") != 0) goto fail; if (exec_expect_ok(env, db_embed, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db_embed, "SELECT llm_model_free();") != 0) goto fail; if (exec_expect_ok(env, db_text, sqlbuf) != 0) goto fail; - if (exec_expect_ok(env, db_text, "SELECT llm_context_create_chat('context_size=512');") != 0) goto fail; + if (install_seeded_chat_sampler(env, db_text) != 0) goto fail; + if (exec_expect_ok(env, db_text, "SELECT llm_context_create_chat('context_size=1024');") != 0) goto fail; if (exec_expect_ok(env, db_text, "SELECT llm_chat_create();") != 0) goto fail; if (exec_expect_ok(env, db_text, "SELECT llm_chat_respond('Hello from text connection');") != 0) goto fail; if (exec_expect_ok(env, db_text, "SELECT llm_chat_free();") != 0) goto fail; if (exec_expect_ok(env, db_text, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db_text, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db_embed); - sqlite3_close(db_text); + sqlite3_close_v2(db_embed); + sqlite3_close_v2(db_text); return assert_sqlite_memory_clean("dual_connection_roles", env); fail: - if (db_embed) sqlite3_close(db_embed); - if (db_text) sqlite3_close(db_text); + if (db_embed) sqlite3_close_v2(db_embed); + if (db_text) sqlite3_close_v2(db_text); return 1; } @@ -709,19 +767,19 @@ static int test_concurrent_connections_independent(const test_env *env) { if (exec_expect_ok(env, db_one, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db_one, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db_one); + sqlite3_close_v2(db_one); db_one = NULL; if (exec_expect_ok(env, db_two, "SELECT llm_embed_generate('still active after peer closed');") != 0) goto fail; if (exec_expect_ok(env, db_two, "SELECT llm_context_free();") != 0) goto fail; if (exec_expect_ok(env, db_two, "SELECT llm_model_free();") != 0) goto fail; - sqlite3_close(db_two); + sqlite3_close_v2(db_two); return assert_sqlite_memory_clean("concurrent_connections_independent", env); fail: - if (db_one) sqlite3_close(db_one); - if (db_two) sqlite3_close(db_two); + if (db_one) sqlite3_close_v2(db_one); + if (db_two) sqlite3_close_v2(db_two); return 1; } @@ -1147,6 +1205,7 @@ static int test_chat_system_prompt_after_first_response(const test_env *env) { snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto done; model_loaded = true; + if (install_seeded_chat_sampler(env, db) != 0) goto done; if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1000');") != 0) goto done; context_created = true; @@ -1221,7 +1280,8 @@ static int test_chat_create_free_cycle(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; - if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=512');") != 0) goto fail; + if (install_seeded_chat_sampler(env, db) != 0) goto fail; + if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1024');") != 0) goto fail; // create and free chat multiple times to test for dangling pointers for (int i = 0; i < 3; i++) { @@ -1253,6 +1313,7 @@ static int test_chat_recreate_after_conversation(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; + if (install_seeded_chat_sampler(env, db) != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1000');") != 0) goto fail; // first chat session @@ -1294,6 +1355,7 @@ static int test_chat_vtab_multi_turn(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; + if (install_seeded_chat_sampler(env, db) != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1000');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_create();") != 0) goto fail; @@ -1337,6 +1399,7 @@ static int test_chat_save_restore_roundtrip(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; + if (install_seeded_chat_sampler(env, db) != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1000');") != 0) goto fail; // create chat, set system prompt, send a message, save @@ -1396,7 +1459,7 @@ static int test_chat_system_prompt_clear(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; - if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=512');") != 0) goto fail; + if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1024');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_create();") != 0) goto fail; // set a system prompt @@ -1475,7 +1538,8 @@ static int test_chat_double_free(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; - if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=512');") != 0) goto fail; + if (install_seeded_chat_sampler(env, db) != 0) goto fail; + if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1024');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_create();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_respond('Hi');") != 0) goto fail; @@ -1504,6 +1568,7 @@ static int test_chat_respond_auto_init(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; + if (install_seeded_chat_sampler(env, db) != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1000');") != 0) goto fail; // skip llm_chat_create — llm_chat_respond should auto-initialize via check_context @@ -1533,6 +1598,7 @@ static int test_chat_save_with_metadata(const test_env *env) { char sqlbuf[512]; snprintf(sqlbuf, sizeof(sqlbuf), "SELECT llm_model_load('%s');", model); if (exec_expect_ok(env, db, sqlbuf) != 0) goto fail; + if (install_seeded_chat_sampler(env, db) != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1000');") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_create();") != 0) goto fail; if (exec_expect_ok(env, db, "SELECT llm_chat_respond('Hello');") != 0) goto fail; @@ -1850,6 +1916,8 @@ static int test_llm_chat_double_save(const test_env *env) { if (exec_expect_ok(env, db, sqlbuf) != 0) goto done; model_loaded = true; + if (install_seeded_chat_sampler(env, db) != 0) + goto done; if (exec_expect_ok(env, db, "SELECT llm_context_create('context_size=1000');") != 0) @@ -1950,6 +2018,7 @@ static const test_case TESTS[] = { {"llm_context_size_errors", test_llm_context_size_errors}, {"document_ingestion_flow", test_document_ingestion_flow}, {"llm_sampler_roundtrip", test_llm_sampler_roundtrip}, + {"chat_default_sampler_autocreate", test_chat_default_sampler_autocreate}, {"dual_connection_roles", test_dual_connection_roles}, {"concurrent_connections_independent", test_concurrent_connections_independent}, {"llm_model_load_error_recovery", test_llm_model_load_error_recovery},