Files
codebase-memory-mcp/tests/test_runtime_settings.c
T
Martin Vogel eb294298e0 fix(daemon): settings startup survives busy and interrupted writes
Nondeterministic Windows reds came from races on _config.db:
test-windows-guards section_start_status_port / daemon_lifecycle ("the
daemon did not accept the UI configuration"), section_churn_stability
("could not load saved runtime settings"), the windows-2025 release
smoke whose `--ui=true --port` server exited about 5 s after start
(Phase 15, read as "no embedded UI assets"), and section_crash_recovery
("the stale daemon state never cleared").

- Every _config.db connection now waits out a committing writer
  (cbm_runtime_settings_wait_for_writers: one busy handler, logs
  config_db.busy_wait once per busy wait, a 3 s bound for a wedged
  holder). Startup's 50 ms window let a daemon's routine commit end
  unrelated CLI starts; cbm_config_open and the UI load had no wait at
  all, so a concurrent commit failed the daemon's UI save and made the
  load keep defaults.
- A process killed mid-write (crash, taskkill /F, power loss) left a hot
  rollback journal that the read-only settings reads cannot roll back
  (SQLITE_READONLY_ROLLBACK). Every later command then failed with
  "could not load saved runtime settings", permanently. Settings startup
  and the UI load now recover once through a read-write open that rolls
  the interrupted write back (config_db.hot_journal_recovered); a failed
  recovery is reported with the journal path and stays fatal.
- write_value no longer rewrites an unchanged value. The upsert fired the
  revision trigger, so every `daemon start` on a UI build committed and
  locked readers out although nothing changed.
- `daemon start` and the stdio server wait for the set_context and
  set_ui_config replies within their startup budget instead of the 1 s
  probe budget (MAIN_CONNECT_TIMEOUT_MS) that decided the command
  whenever the daemon's save ran long.

Tests, red on 8df0639c and green here:
- runtime_settings suite: three single-threaded lock tests (a log sink
  commits the held lock when the waiter logs config_db.busy_wait, so no
  test depends on a duration) and two hot-journal tests (a portable
  fixture snapshots the database and its journal mid-transaction and
  checks the journal is hot before the code under test runs).
- tests/windows/test_daemon_stability.py: new section
  start_port_waits_for_settings_writer holds the lock against a real
  `daemon start --port` and releases it on events; the crash section
  now prints the status output it saw.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-15 18:07:25 +02:00

645 lines
28 KiB
C

#include "test_framework.h"
#include "foundation/mem_core.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/platform.h"
#include "foundation/limits.h"
#include "foundation/log.h"
#include "cli/cli.h"
#include "cli/runtime_settings.h"
#include "ui/config.h"
#include <sqlite3.h>
#include <yyjson/yyjson.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char path[256];
char *cache;
char *workers;
char *cross;
} settings_fixture_t;
static bool fixture_open(settings_fixture_t *fixture) {
memset(fixture, 0, sizeof(*fixture));
snprintf(fixture->path, sizeof(fixture->path), "%s/cbm-settings-XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(fixture->path))
return false;
fixture->cache = getenv("CBM_CACHE_DIR") ? strdup(getenv("CBM_CACHE_DIR")) : NULL;
fixture->workers = getenv("CBM_WORKERS") ? strdup(getenv("CBM_WORKERS")) : NULL;
fixture->cross =
getenv("CBM_DISABLE_LSP_CROSS") ? strdup(getenv("CBM_DISABLE_LSP_CROSS")) : NULL;
return cbm_setenv("CBM_CACHE_DIR", fixture->path, 1) == 0;
}
static void restore_env(const char *key, char *value) {
if (value)
cbm_setenv(key, value, 1);
else
cbm_unsetenv(key);
free(value);
}
static void fixture_close(settings_fixture_t *fixture) {
cbm_runtime_settings_reset_for_tests();
restore_env("CBM_CACHE_DIR", fixture->cache);
restore_env("CBM_WORKERS", fixture->workers);
restore_env("CBM_DISABLE_LSP_CROSS", fixture->cross);
char path[512];
snprintf(path, sizeof(path), "%s/_config.db", fixture->path);
remove(path);
snprintf(path, sizeof(path), "%s/config.json", fixture->path);
remove(path);
cbm_rmdir(fixture->path);
}
static yyjson_val *item(yyjson_doc *document, const char *key) {
yyjson_val *items = yyjson_obj_get(yyjson_doc_get_root(document), "settings");
size_t index, max;
yyjson_val *value;
yyjson_arr_foreach(items, index, max,
value) if (strcmp(yyjson_get_str(yyjson_obj_get(value, "key")), key) ==
0) return value;
return NULL;
}
static yyjson_doc *read_snapshot(const char *path) {
char *json = cbm_runtime_settings_get_json(path);
yyjson_doc *document = json ? yyjson_read(json, strlen(json), 0) : NULL;
cbm_free(CBM_MEM_CLASS_OTHER, json);
return document;
}
static char *apply(const char *path, const char *changes, int *status) {
yyjson_doc *before = read_snapshot(path);
if (!before)
return NULL;
const char *revision = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(before), "revision"));
char request[4096];
snprintf(request, sizeof(request), "{\"revision\":\"%s\",\"changes\":%s}", revision, changes);
yyjson_doc_free(before);
return cbm_runtime_settings_apply_json(path, request, strlen(request), status);
}
TEST(runtime_settings_atomic_validation_and_conflict) {
settings_fixture_t fixture;
ASSERT_TRUE(fixture_open(&fixture));
yyjson_doc *before = read_snapshot(fixture.path);
ASSERT_NOT_NULL(before);
char revision[32];
snprintf(revision, sizeof(revision), "%s",
yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(before), "revision")));
yyjson_doc_free(before);
int status = 0;
char *result =
apply(fixture.path, "{\"auto_index\":\"true\",\"CBM_WORKERS\":\"999\"}", &status);
ASSERT_EQ(status, 400);
cbm_free(CBM_MEM_CLASS_OTHER, result);
yyjson_doc *after = read_snapshot(fixture.path);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item(after, "auto_index"), "value")), "false");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(after), "revision")), revision);
yyjson_doc_free(after);
result = apply(fixture.path,
"{\"auto_index\":\"true\",\"CBM_WORKERS\":\"4\",\"CBM_SQLITE_MMAP_SIZE\":\"0\"}",
&status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
char request[256];
snprintf(request, sizeof(request),
"{\"revision\":\"%s\",\"changes\":{\"auto_index\":\"false\"}}", revision);
result = cbm_runtime_settings_apply_json(fixture.path, request, strlen(request), &status);
ASSERT_EQ(status, 409);
cbm_free(CBM_MEM_CLASS_OTHER, result);
cbm_config_t *config = cbm_config_open(fixture.path);
ASSERT_TRUE(cbm_config_get_bool(config, "auto_index", false));
cbm_config_close(config);
fixture_close(&fixture);
PASS();
}
TEST(runtime_settings_strict_allowlist_and_types) {
settings_fixture_t fixture;
ASSERT_TRUE(fixture_open(&fixture));
const char *invalid[] = {"{\"CBM_TEST_CRASH_ON\":\"1\"}",
"{\"CBM_WALK_DEFS_MAX\":\"1024\"}",
"{\"CBM_CACHE_DIR\":\"/tmp/no\"}",
"{\"CBM_WORKERS\":4}",
"{\"CBM_WORKERS\":\"4\",\"CBM_WORKERS\":\"8\"}",
"{\"CBM_SEMANTIC_THRESHOLD\":\"NaN\"}",
"{\"CBM_SEMANTIC_THRESHOLD\":\"0\"}",
"{\"CBM_LOG_LEVEL\":\"secret\"}",
"{\"auto_index\":\"1\"}",
"{\"ui_port\":\"65536\"}",
"{\"CBM_MEM_STATS_OUT\":\"/tmp/out\"}"};
for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) {
int status = 0;
char *result = apply(fixture.path, invalid[i], &status);
ASSERT_EQ(status, 400);
cbm_free(CBM_MEM_CLASS_OTHER, result);
}
yyjson_doc *snapshot = read_snapshot(fixture.path);
ASSERT_NOT_NULL(snapshot);
ASSERT_NULL(item(snapshot, "CBM_TEST_CRASH_ON"));
ASSERT_NULL(item(snapshot, "CBM_WALK_DEFS_MAX"));
ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(item(snapshot, "CBM_CACHE_DIR"), "editable")));
yyjson_doc_free(snapshot);
fixture_close(&fixture);
PASS();
}
TEST(runtime_settings_snapshot_false_mask_reset_and_real_consumer) {
settings_fixture_t fixture;
ASSERT_TRUE(fixture_open(&fixture));
ASSERT_EQ(cbm_setenv("CBM_WORKERS", "2", 1), 0);
ASSERT_EQ(cbm_setenv("CBM_DISABLE_LSP_CROSS", "0", 1), 0);
int status = 0;
char *result = apply(fixture.path,
"{\"CBM_WORKERS\":\"4\",\"CBM_DISABLE_LSP_CROSS\":\"false\",\"CBM_MAX_"
"FILE_BYTES\":\"4096\"}",
&status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
ASSERT_TRUE(cbm_runtime_settings_init(fixture.path));
ASSERT_STR_EQ(cbm_runtime_getenv("CBM_WORKERS"), "4");
ASSERT_NULL(cbm_runtime_getenv("CBM_DISABLE_LSP_CROSS"));
char safe[64];
ASSERT_NULL(cbm_safe_getenv("CBM_DISABLE_LSP_CROSS", safe, sizeof(safe), NULL));
ASSERT_EQ(cbm_max_file_bytes(), 4096);
ASSERT_STR_EQ(getenv("CBM_WORKERS"), "2");
ASSERT_STR_EQ(getenv("CBM_DISABLE_LSP_CROSS"), "0");
result = apply(fixture.path, "{\"CBM_WORKERS\":\"8\",\"CBM_DISABLE_LSP_CROSS\":null}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
ASSERT_STR_EQ(cbm_runtime_getenv("CBM_WORKERS"), "4");
yyjson_doc *snapshot = read_snapshot(fixture.path);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item(snapshot, "CBM_WORKERS"), "effective")), "4");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item(snapshot, "CBM_WORKERS"), "value")), "8");
ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(item(snapshot, "CBM_WORKERS"), "pendingRestart")));
ASSERT_TRUE(
yyjson_get_bool(yyjson_obj_get(item(snapshot, "CBM_DISABLE_LSP_CROSS"), "pendingRestart")));
yyjson_doc_free(snapshot);
result = apply(fixture.path, "{\"CBM_WORKERS\":null}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
cbm_runtime_settings_reset_for_tests();
ASSERT_TRUE(cbm_runtime_settings_init(fixture.path));
ASSERT_STR_EQ(cbm_runtime_getenv("CBM_WORKERS"), "2");
ASSERT_STR_EQ(cbm_runtime_getenv("CBM_DISABLE_LSP_CROSS"), "0");
fixture_close(&fixture);
PASS();
}
TEST(runtime_settings_cli_revision_and_ui_last_writer) {
settings_fixture_t fixture;
ASSERT_TRUE(fixture_open(&fixture));
cbm_ui_config_t legacy = {.ui_enabled = false, .ui_port = 8111};
ASSERT_TRUE(cbm_ui_config_save(&legacy));
ASSERT_TRUE(cbm_runtime_settings_init(fixture.path));
int status = 0;
char *result =
apply(fixture.path,
"{\"ui_enabled\":\"true\",\"ui_port\":\"8222\",\"auto_index\":\"true\"}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
cbm_ui_config_t actual;
cbm_ui_config_load(&actual);
ASSERT_TRUE(actual.ui_enabled);
ASSERT_EQ(actual.ui_port, 8222);
yyjson_doc *before = read_snapshot(fixture.path);
char old_revision[32];
snprintf(old_revision, sizeof(old_revision), "%s",
yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(before), "revision")));
yyjson_doc_free(before);
cbm_config_t *config = cbm_config_open(fixture.path);
ASSERT_EQ(cbm_config_set(config, "auto_index", "false"), 0);
cbm_config_close(config);
yyjson_doc *after = read_snapshot(fixture.path);
ASSERT_TRUE(strcmp(old_revision, yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(after),
"revision"))) != 0);
yyjson_doc_free(after);
actual.ui_enabled = false;
actual.ui_port = 8333;
ASSERT_TRUE(cbm_ui_config_save(&actual));
cbm_ui_config_load(&actual);
ASSERT_FALSE(actual.ui_enabled);
ASSERT_EQ(actual.ui_port, 8333);
result = apply(fixture.path, "{\"ui_enabled\":null,\"ui_port\":null}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
cbm_ui_config_load(&actual);
ASSERT_FALSE(actual.ui_enabled);
ASSERT_EQ(actual.ui_port, 8111);
fixture_close(&fixture);
PASS();
}
TEST(runtime_settings_sql_failure_rolls_back_whole_batch) {
settings_fixture_t fixture;
ASSERT_TRUE(fixture_open(&fixture));
yyjson_doc *initial = read_snapshot(fixture.path);
ASSERT_NOT_NULL(initial);
yyjson_doc_free(initial);
char path[512];
snprintf(path, sizeof(path), "%s/_config.db", fixture.path);
sqlite3 *db = NULL;
ASSERT_EQ(sqlite3_open(path, &db), SQLITE_OK);
ASSERT_EQ(
sqlite3_exec(db,
"CREATE TRIGGER reject_workers BEFORE INSERT ON runtime_settings WHEN "
"NEW.key='CBM_WORKERS' BEGIN SELECT RAISE(ABORT,'test write failure'); END",
NULL, NULL, NULL),
SQLITE_OK);
sqlite3_close(db);
int status = 0;
char *result = apply(fixture.path, "{\"auto_index\":\"true\",\"CBM_WORKERS\":\"3\"}", &status);
ASSERT_EQ(status, 500);
cbm_free(CBM_MEM_CLASS_OTHER, result);
yyjson_doc *snapshot = read_snapshot(fixture.path);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item(snapshot, "auto_index"), "value")), "false");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(snapshot), "revision")), "0");
yyjson_doc_free(snapshot);
fixture_close(&fixture);
PASS();
}
TEST(runtime_settings_canonical_alias_and_actual_listener) {
settings_fixture_t fixture;
ASSERT_TRUE(fixture_open(&fixture));
char alias[512];
snprintf(alias, sizeof(alias), "%s/.", fixture.path);
int status = 0;
char *result = apply(alias, "{\"CBM_WORKERS\":\"4\"}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
ASSERT_TRUE(cbm_runtime_settings_init(alias));
ASSERT_TRUE(cbm_runtime_settings_init(fixture.path));
result = apply(fixture.path, "{\"CBM_WORKERS\":\"8\",\"ui_port\":\"9768\"}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
cbm_runtime_settings_note_http_port(9768);
yyjson_doc *snapshot = read_snapshot(fixture.path);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item(snapshot, "CBM_WORKERS"), "effective")), "4");
ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(item(snapshot, "CBM_WORKERS"), "pendingRestart")));
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item(snapshot, "ui_port"), "effective")), "9768");
ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(item(snapshot, "ui_port"), "pendingRestart")));
yyjson_doc_free(snapshot);
fixture_close(&fixture);
PASS();
}
TEST(runtime_settings_inherited_clamps_and_unbuilt_features) {
settings_fixture_t fixture;
ASSERT_TRUE(fixture_open(&fixture));
const char *keys[] = {"CBM_STARTUP_TIMEOUT_MS", "CBM_SQLITE_MMAP_SIZE", "CBM_HOOK_DEADLINE_MS"};
const char *values[] = {"2", "-1", "1"};
const char *expected[] = {"1000", "0", "50"};
char *previous[3];
for (size_t i = 0; i < 3; i++) {
previous[i] = getenv(keys[i]) ? strdup(getenv(keys[i])) : NULL;
cbm_setenv(keys[i], values[i], 1);
}
yyjson_doc *snapshot = read_snapshot(fixture.path);
ASSERT_NOT_NULL(snapshot);
for (size_t i = 0; i < 3; i++)
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item(snapshot, keys[i]), "value")),
expected[i]);
ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(item(snapshot, "CBM_MEM_PROFILE"), "editable")));
ASSERT_FALSE(
yyjson_get_bool(yyjson_obj_get(item(snapshot, "CBM_MEM_PROFILE_MIN"), "editable")));
yyjson_doc_free(snapshot);
for (size_t i = 0; i < 3; i++)
restore_env(keys[i], previous[i]);
fixture_close(&fixture);
PASS();
}
TEST(runtime_settings_preserves_unknown_inherited_values_and_reset) {
settings_fixture_t fixture;
ASSERT_TRUE(fixture_open(&fixture));
char *previous_budget =
getenv("CBM_TS_TYPE_BUDGET") ? strdup(getenv("CBM_TS_TYPE_BUDGET")) : NULL;
ASSERT_EQ(cbm_setenv("CBM_WORKERS", "3junk", 1), 0);
ASSERT_EQ(cbm_setenv("CBM_TS_TYPE_BUDGET", "-1", 1), 0);
ASSERT_TRUE(cbm_runtime_settings_init(fixture.path));
yyjson_doc *snapshot = read_snapshot(fixture.path);
ASSERT_NOT_NULL(snapshot);
yyjson_val *workers = item(snapshot, "CBM_WORKERS");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(workers, "value")), "3junk");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(workers, "effective")), "3junk");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(workers, "source")), "environment");
ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(workers, "effectiveKnown")));
ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(workers, "pendingRestart")));
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item(snapshot, "CBM_TS_TYPE_BUDGET"), "value")),
"-1");
ASSERT_FALSE(
yyjson_get_bool(yyjson_obj_get(item(snapshot, "CBM_TS_TYPE_BUDGET"), "effectiveKnown")));
yyjson_doc_free(snapshot);
ASSERT_EQ(cbm_default_worker_count(true), 3);
int status = 0;
char *result = apply(fixture.path, "{\"CBM_WORKERS\":\"5\"}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
snapshot = read_snapshot(fixture.path);
workers = item(snapshot, "CBM_WORKERS");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(workers, "value")), "5");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(workers, "effective")), "3junk");
ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(workers, "effectiveKnown")));
ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(workers, "pendingRestart")));
yyjson_doc_free(snapshot);
result = apply(fixture.path, "{\"CBM_WORKERS\":null}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
snapshot = read_snapshot(fixture.path);
workers = item(snapshot, "CBM_WORKERS");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(workers, "value")), "3junk");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(workers, "source")), "environment");
ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(workers, "effectiveKnown")));
ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(workers, "pendingRestart")));
yyjson_doc_free(snapshot);
result = apply(fixture.path, "{\"CBM_WORKERS\":\"5\"}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
cbm_runtime_settings_reset_for_tests();
ASSERT_TRUE(cbm_runtime_settings_init(fixture.path));
snapshot = read_snapshot(fixture.path);
workers = item(snapshot, "CBM_WORKERS");
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(workers, "effective")), "5");
ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(workers, "effectiveKnown")));
ASSERT_FALSE(yyjson_get_bool(yyjson_obj_get(workers, "pendingRestart")));
yyjson_doc_free(snapshot);
ASSERT_EQ(cbm_default_worker_count(true), 5);
restore_env("CBM_TS_TYPE_BUDGET", previous_budget);
fixture_close(&fixture);
PASS();
}
TEST(runtime_settings_response_memory_returns_to_caller_baseline) {
settings_fixture_t fixture;
ASSERT_TRUE(fixture_open(&fixture));
size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER);
size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER);
for (int request = 0; request < 3; request++) {
int status = 0;
char *json;
if (request == 0)
json = cbm_runtime_settings_get_json(fixture.path);
else if (request == 1) {
json = cbm_runtime_settings_apply_json(fixture.path, "{}", 2, &status);
ASSERT_EQ(status, 400);
} else {
json = apply(fixture.path, "{\"CBM_WORKERS\":\"4\"}", &status);
ASSERT_EQ(status, 200);
}
ASSERT_NOT_NULL(json);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks + 1);
ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes + cbm_mem_usable_size(json));
cbm_free(CBM_MEM_CLASS_OTHER, json);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks);
ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes);
}
fixture_close(&fixture);
PASS();
}
/* A second connection holds _config.db's lock, as another process mid-commit
* would. The sink commits the holder the moment the waiter logs
* config_db.busy_wait, so the wait is observed as an event, single-threaded and
* without a duration. A call that fails on the lock instead never logs, so the
* holder stays held through it and the call reports the failure. Results are
* captured before any ASSERT so a failure never leaks the lock or the sink. */
static sqlite3 *g_lock_holder;
static int g_lock_waits;
static CBMLogLevel g_lock_saved_level;
static void release_holder_on_wait(const char *line) {
if (!g_lock_holder || !strstr(line, "config_db.busy_wait"))
return;
g_lock_waits++;
(void)sqlite3_exec(g_lock_holder, "COMMIT", NULL, NULL, NULL);
}
static bool hold_config_lock(const char *cache, const char *lock_sql) {
char path[512];
snprintf(path, sizeof(path), "%s/_config.db", cache);
g_lock_waits = 0;
if (sqlite3_open(path, &g_lock_holder) != SQLITE_OK ||
sqlite3_exec(g_lock_holder, lock_sql, NULL, NULL, NULL) != SQLITE_OK) {
sqlite3_close(g_lock_holder);
g_lock_holder = NULL;
return false;
}
g_lock_saved_level = cbm_log_get_level();
if (g_lock_saved_level > CBM_LOG_WARN)
cbm_log_set_level(CBM_LOG_WARN);
cbm_log_set_sink(release_holder_on_wait);
return true;
}
static void release_config_lock(void) {
cbm_log_set_sink(NULL);
cbm_log_set_level(g_lock_saved_level);
if (g_lock_holder) {
if (!sqlite3_get_autocommit(g_lock_holder))
(void)sqlite3_exec(g_lock_holder, "COMMIT", NULL, NULL, NULL);
sqlite3_close(g_lock_holder);
g_lock_holder = NULL;
}
}
static long long settings_revision(const char *cache) {
yyjson_doc *snapshot = read_snapshot(cache);
const char *text =
snapshot ? yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(snapshot), "revision")) : NULL;
long long revision = text ? strtoll(text, NULL, 10) : -1;
yyjson_doc_free(snapshot);
return revision;
}
/* A test that fails between init and fixture_close leaves the settings bound
* to its own cache; the lock tests reset first so each one fails on its own
* defect, never on a predecessor's leftover state. */
static bool fresh_fixture_open(settings_fixture_t *fixture) {
cbm_runtime_settings_reset_for_tests();
return fixture_open(fixture);
}
/* CI churn: a daemon committing _config.db made an unrelated CLI start exit
* with "could not load saved runtime settings" (50 ms window). */
TEST(runtime_settings_startup_waits_out_a_committing_writer) {
settings_fixture_t fixture;
ASSERT_TRUE(fresh_fixture_open(&fixture));
int status = 0;
char *result = apply(fixture.path, "{\"CBM_WORKERS\":\"4\"}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
ASSERT_TRUE(hold_config_lock(fixture.path, "BEGIN EXCLUSIVE"));
bool initialized = cbm_runtime_settings_init(fixture.path);
int waits = g_lock_waits;
release_config_lock();
ASSERT_TRUE(initialized);
ASSERT_EQ(waits, 1);
ASSERT_STR_EQ(cbm_runtime_getenv("CBM_WORKERS"), "4");
fixture_close(&fixture);
PASS();
}
/* The daemon's set_ui_config handler: a concurrent commit failed the save at
* once (cbm_config_open had no wait) and made the load silently return the
* legacy defaults instead of the saved port. */
TEST(runtime_settings_ui_save_and_load_wait_out_a_committing_writer) {
settings_fixture_t fixture;
ASSERT_TRUE(fresh_fixture_open(&fixture));
ASSERT_TRUE(cbm_runtime_settings_init(fixture.path));
cbm_ui_config_t config = {.ui_enabled = true, .ui_port = 8222};
ASSERT_TRUE(cbm_ui_config_save(&config));
ASSERT_TRUE(hold_config_lock(fixture.path, "BEGIN EXCLUSIVE"));
config.ui_port = 8333;
bool saved = cbm_ui_config_save(&config);
int save_waits = g_lock_waits;
release_config_lock();
ASSERT_TRUE(saved);
ASSERT_TRUE(save_waits >= 1);
ASSERT_TRUE(hold_config_lock(fixture.path, "BEGIN EXCLUSIVE"));
cbm_ui_config_t loaded = {0};
cbm_ui_config_load(&loaded);
int load_waits = g_lock_waits;
release_config_lock();
ASSERT_EQ(load_waits, 1);
ASSERT_TRUE(loaded.ui_enabled);
ASSERT_EQ(loaded.ui_port, 8333);
fixture_close(&fixture);
PASS();
}
/* Every `daemon start` on a UI build saved the same two values; the upsert
* still updated the rows, fired the revision trigger and committed. */
TEST(runtime_settings_unchanged_ui_save_does_not_rewrite) {
settings_fixture_t fixture;
ASSERT_TRUE(fresh_fixture_open(&fixture));
ASSERT_TRUE(cbm_runtime_settings_init(fixture.path));
cbm_ui_config_t config = {.ui_enabled = true, .ui_port = 8222};
ASSERT_TRUE(cbm_ui_config_save(&config));
long long first = settings_revision(fixture.path);
ASSERT_TRUE(first > 0);
ASSERT_TRUE(cbm_ui_config_save(&config));
ASSERT_TRUE(cbm_ui_config_save(&config));
ASSERT_EQ(settings_revision(fixture.path), first);
config.ui_port = 8223;
ASSERT_TRUE(cbm_ui_config_save(&config));
ASSERT_EQ(settings_revision(fixture.path), first + 1);
fixture_close(&fixture);
PASS();
}
/* A process killed mid-write (crash, taskkill /F, power loss) leaves _config.db
* with a hot rollback journal. Made portably and deterministically: snapshot the
* database and its journal while a spilled write transaction is open, roll the
* transaction back, then put the snapshot back. The journal is checked to be
* hot (SQLite's magic and a nonzero record count) before the code under test
* runs, so these tests cannot pass on a journal that was never hot. */
static bool copy_file(const char *from, const char *to) {
FILE *in = cbm_fopen(from, "rb");
FILE *out = in ? cbm_fopen(to, "wb") : NULL;
bool ok = in && out;
char buffer[8192];
size_t got = 0;
while (ok && (got = fread(buffer, 1, sizeof(buffer), in)) > 0)
ok = fwrite(buffer, 1, got, out) == got;
if (in)
fclose(in);
if (out && fclose(out) != 0)
ok = false;
return ok;
}
static bool journal_is_hot(const char *path) {
static const unsigned char magic[8] = {0xd9, 0xd5, 0x05, 0xf9, 0x20, 0xa1, 0x63, 0xd7};
unsigned char header[12] = {0};
FILE *file = cbm_fopen(path, "rb");
bool read = file && fread(header, 1, sizeof(header), file) == sizeof(header);
if (file)
fclose(file);
unsigned records = ((unsigned)header[8] << 24) | ((unsigned)header[9] << 16) |
((unsigned)header[10] << 8) | (unsigned)header[11];
return read && memcmp(header, magic, sizeof(magic)) == 0 && records > 0;
}
static bool leave_hot_journal(const char *cache, const char *update_sql) {
char db_path[512], journal_path[512], db_copy[512], journal_copy[512];
snprintf(db_path, sizeof(db_path), "%s/_config.db", cache);
snprintf(journal_path, sizeof(journal_path), "%s/_config.db-journal", cache);
snprintf(db_copy, sizeof(db_copy), "%s/snapshot.db", cache);
snprintf(journal_copy, sizeof(journal_copy), "%s/snapshot.journal", cache);
sqlite3 *writer = NULL;
bool ok = sqlite3_open(db_path, &writer) == SQLITE_OK &&
sqlite3_exec(writer, "PRAGMA cache_size=1; BEGIN IMMEDIATE; CREATE TABLE spill(x BLOB)",
NULL, NULL, NULL) == SQLITE_OK &&
sqlite3_exec(writer, update_sql, NULL, NULL, NULL) == SQLITE_OK;
for (int i = 0; ok && i < 64; i++)
ok = sqlite3_exec(writer, "INSERT INTO spill VALUES(randomblob(8192))", NULL, NULL, NULL) ==
SQLITE_OK;
ok = ok && copy_file(db_path, db_copy) && copy_file(journal_path, journal_copy);
if (writer) {
(void)sqlite3_exec(writer, "ROLLBACK", NULL, NULL, NULL);
sqlite3_close(writer);
}
ok = ok && journal_is_hot(journal_copy) && copy_file(db_copy, db_path) &&
copy_file(journal_copy, journal_path);
remove(db_copy);
remove(journal_copy);
return ok;
}
/* The hot journal made every later start fail with "could not load saved
* runtime settings", permanently: a read-only connection cannot roll it back. */
TEST(runtime_settings_startup_recovers_a_hot_journal) {
settings_fixture_t fixture;
ASSERT_TRUE(fresh_fixture_open(&fixture));
int status = 0;
char *result = apply(fixture.path, "{\"CBM_WORKERS\":\"4\"}", &status);
ASSERT_EQ(status, 200);
cbm_free(CBM_MEM_CLASS_OTHER, result);
ASSERT_TRUE(leave_hot_journal(fixture.path,
"UPDATE runtime_settings SET value='9' WHERE key='CBM_WORKERS'"));
ASSERT_TRUE(cbm_runtime_settings_init(fixture.path));
ASSERT_STR_EQ(cbm_runtime_getenv("CBM_WORKERS"), "4");
char journal[512];
snprintf(journal, sizeof(journal), "%s/_config.db-journal", fixture.path);
ASSERT_FALSE(cbm_file_exists(journal));
fixture_close(&fixture);
PASS();
}
/* The same journal made the UI load silently fall back to the legacy defaults. */
TEST(runtime_settings_ui_load_recovers_a_hot_journal) {
settings_fixture_t fixture;
ASSERT_TRUE(fresh_fixture_open(&fixture));
ASSERT_TRUE(cbm_runtime_settings_init(fixture.path));
cbm_ui_config_t config = {.ui_enabled = true, .ui_port = 8222};
ASSERT_TRUE(cbm_ui_config_save(&config));
ASSERT_TRUE(leave_hot_journal(fixture.path,
"UPDATE runtime_settings SET value='9999' WHERE key='ui_port'"));
cbm_ui_config_t loaded = {0};
cbm_ui_config_load(&loaded);
ASSERT_TRUE(loaded.ui_enabled);
ASSERT_EQ(loaded.ui_port, 8222);
fixture_close(&fixture);
PASS();
}
SUITE(runtime_settings) {
RUN_TEST(runtime_settings_startup_recovers_a_hot_journal);
RUN_TEST(runtime_settings_ui_load_recovers_a_hot_journal);
RUN_TEST(runtime_settings_startup_waits_out_a_committing_writer);
RUN_TEST(runtime_settings_ui_save_and_load_wait_out_a_committing_writer);
RUN_TEST(runtime_settings_unchanged_ui_save_does_not_rewrite);
RUN_TEST(runtime_settings_response_memory_returns_to_caller_baseline);
RUN_TEST(runtime_settings_preserves_unknown_inherited_values_and_reset);
RUN_TEST(runtime_settings_canonical_alias_and_actual_listener);
RUN_TEST(runtime_settings_inherited_clamps_and_unbuilt_features);
RUN_TEST(runtime_settings_atomic_validation_and_conflict);
RUN_TEST(runtime_settings_strict_allowlist_and_types);
RUN_TEST(runtime_settings_snapshot_false_mask_reset_and_real_consumer);
RUN_TEST(runtime_settings_cli_revision_and_ui_last_writer);
RUN_TEST(runtime_settings_sql_failure_rolls_back_whole_batch);
}