From 2e7cca7aff2bc9a93603ee13ebacdb0233285077 Mon Sep 17 00:00:00 2001 From: edwardmhughes Date: Sat, 2 May 2026 13:05:11 -0600 Subject: [PATCH] feat(store): expose mmap_size via CBM_SQLITE_MMAP_SIZE env Hard-coded `PRAGMA mmap_size = 67108864` in configure_pragmas() left no path for users running multiple cbm-mcp instances against the same cache to opt out of memory-mapped I/O. On macOS, when one instance's checkpoint or reindex truncates the DB file under another instance's live mmap, accessing the now-missing pages raises SIGBUS, taking the process down. Setting CBM_SQLITE_MMAP_SIZE=0 reverts to read()/pread() I/O, which returns recoverable SQLITE_IOERR instead of crashing the process. - Default unchanged (67108864 / 64 MB). No behavior change for single-instance users. - Malformed values (non-numeric, partial-numeric) fall back to the default rather than failing the store open. - Negative values clamp to 0. - New tests: tests/test_store_pragmas.c covers all five resolver paths plus an integration smoke that opens a file-backed store with mmap disabled. Empirical evidence: 9 SIGBUS crash reports collected on macOS arm64 v0.6.0 in a 14-hour window, all signature 'cluster_pagein past EOF' with stacks bottoming in SQLite btree code under the watcher thread's incremental-index pipeline. --- Makefile.cbm | 3 +- src/store/store.c | 23 ++++++++- src/store/store.h | 9 ++++ tests/test_main.c | 2 + tests/test_store_pragmas.c | 97 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 tests/test_store_pragmas.c diff --git a/Makefile.cbm b/Makefile.cbm index 6bc1eb12..8ef9501f 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -279,7 +279,8 @@ TEST_STORE_SRCS = \ tests/test_store_edges.c \ tests/test_store_search.c \ tests/test_store_arch.c \ - tests/test_store_bulk.c + tests/test_store_bulk.c \ + tests/test_store_pragmas.c TEST_CYPHER_SRCS = \ tests/test_cypher.c diff --git a/src/store/store.c b/src/store/store.c index 5d73dcce..04de5572 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -297,6 +297,24 @@ static int create_user_indexes(cbm_store_t *s) { return exec_sql(s, sql); } +int64_t cbm_store_resolve_mmap_size(void) { + enum { MMAP_DEFAULT = 67108864, BASE_10 = 10 }; /* default 64 MB; decimal radix */ + char buf[ST_BUF_64]; + if (cbm_safe_getenv("CBM_SQLITE_MMAP_SIZE", buf, sizeof(buf), NULL) == NULL) { + return (int64_t)MMAP_DEFAULT; + } + char *end = NULL; + long long parsed = strtoll(buf, &end, BASE_10); + if (end == buf || *end != '\0') { + /* Malformed — fall back to default rather than fail the store open. */ + return (int64_t)MMAP_DEFAULT; + } + if (parsed < 0) { + return 0; + } + return (int64_t)parsed; +} + static int configure_pragmas(cbm_store_t *s, bool in_memory) { int rc; rc = exec_sql(s, "PRAGMA foreign_keys = ON;"); @@ -323,7 +341,10 @@ static int configure_pragmas(cbm_store_t *s, bool in_memory) { if (rc != CBM_STORE_OK) { return rc; } - rc = exec_sql(s, "PRAGMA mmap_size = 67108864;"); /* CBM_SZ_64 MB */ + char mmap_sql[ST_BUF_64]; + snprintf(mmap_sql, sizeof(mmap_sql), "PRAGMA mmap_size = %lld;", + (long long)cbm_store_resolve_mmap_size()); + rc = exec_sql(s, mmap_sql); } return rc; } diff --git a/src/store/store.h b/src/store/store.h index 6fa855c3..1241ee3a 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -250,6 +250,15 @@ int cbm_store_create_indexes(cbm_store_t *s); /* Force WAL checkpoint + PRAGMA optimize. */ int cbm_store_checkpoint(cbm_store_t *s); +/* Resolve the mmap_size pragma value applied to on-disk stores from the + * CBM_SQLITE_MMAP_SIZE environment variable. Defaults to 67108864 (64 MB) + * when the variable is unset, malformed, or partially numeric. Negative + * values clamp to 0 (which disables mmap and reverts to read()/pread() + * I/O — recoverable SQLITE_IOERR instead of SIGBUS when concurrent + * processes truncate the DB file under live mappings). Exposed for + * testability. */ +int64_t cbm_store_resolve_mmap_size(void); + /* ── Dump / Restore ─────────────────────────────────────────────── */ /* Dump in-memory database to a file. */ diff --git a/tests/test_main.c b/tests/test_main.c index 6796f8c3..ae3942e5 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -43,6 +43,7 @@ extern void suite_go_lsp(void); extern void suite_c_lsp(void); extern void suite_store_arch(void); extern void suite_store_bulk(void); +extern void suite_store_pragmas(void); extern void suite_traces(void); extern void suite_configlink(void); extern void suite_infrascan(void); @@ -79,6 +80,7 @@ int main(void) { RUN_SUITE(store_edges); RUN_SUITE(store_search); RUN_SUITE(store_bulk); + RUN_SUITE(store_pragmas); /* Cypher (M6) */ RUN_SUITE(cypher); diff --git a/tests/test_store_pragmas.c b/tests/test_store_pragmas.c new file mode 100644 index 00000000..ee573b74 --- /dev/null +++ b/tests/test_store_pragmas.c @@ -0,0 +1,97 @@ +/* + * test_store_pragmas.c — Tests for SQLite pragma resolution. + * + * Validates that the CBM_SQLITE_MMAP_SIZE env var controls the mmap_size + * pragma applied to on-disk stores. Default behavior (env unset) must + * remain 64 MB. Setting the env to 0 disables memory-mapped I/O so + * concurrent processes that truncate the DB file under a sibling's live + * mapping return SQLITE_IOERR instead of crashing the process with SIGBUS. + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include + +static void clear_mmap_env(void) { + unsetenv("CBM_SQLITE_MMAP_SIZE"); +} + +TEST(mmap_size_default_when_unset) { + clear_mmap_env(); + ASSERT_EQ(cbm_store_resolve_mmap_size(), 67108864LL); + PASS(); +} + +TEST(mmap_size_zero_disables_mmap) { + setenv("CBM_SQLITE_MMAP_SIZE", "0", 1); + ASSERT_EQ(cbm_store_resolve_mmap_size(), 0LL); + clear_mmap_env(); + PASS(); +} + +TEST(mmap_size_explicit_value) { + setenv("CBM_SQLITE_MMAP_SIZE", "1048576", 1); + ASSERT_EQ(cbm_store_resolve_mmap_size(), 1048576LL); + clear_mmap_env(); + PASS(); +} + +TEST(mmap_size_negative_clamped_to_zero) { + setenv("CBM_SQLITE_MMAP_SIZE", "-1", 1); + ASSERT_EQ(cbm_store_resolve_mmap_size(), 0LL); + clear_mmap_env(); + PASS(); +} + +TEST(mmap_size_garbage_falls_back_to_default) { + setenv("CBM_SQLITE_MMAP_SIZE", "not-a-number", 1); + ASSERT_EQ(cbm_store_resolve_mmap_size(), 67108864LL); + clear_mmap_env(); + PASS(); +} + +TEST(mmap_size_partial_garbage_falls_back_to_default) { + setenv("CBM_SQLITE_MMAP_SIZE", "123abc", 1); + ASSERT_EQ(cbm_store_resolve_mmap_size(), 67108864LL); + clear_mmap_env(); + PASS(); +} + +/* Integration smoke: opening a file-backed store with mmap_size=0 must + * succeed. Proves the resolver is wired through configure_pragmas(). */ +TEST(store_open_with_mmap_disabled) { + setenv("CBM_SQLITE_MMAP_SIZE", "0", 1); + char tmp_path[256]; + snprintf(tmp_path, sizeof(tmp_path), "/tmp/cbm_test_pragmas_%d.db", (int)getpid()); + unlink(tmp_path); + + cbm_store_t *s = cbm_store_open_path(tmp_path); + ASSERT(s != NULL); + cbm_store_close(s); + + unlink(tmp_path); + /* WAL/SHM siblings created by the open */ + char tmp_wal[300]; + char tmp_shm[300]; + snprintf(tmp_wal, sizeof(tmp_wal), "%s-wal", tmp_path); + snprintf(tmp_shm, sizeof(tmp_shm), "%s-shm", tmp_path); + unlink(tmp_wal); + unlink(tmp_shm); + + clear_mmap_env(); + PASS(); +} + +SUITE(store_pragmas) { + RUN_TEST(mmap_size_default_when_unset); + RUN_TEST(mmap_size_zero_disables_mmap); + RUN_TEST(mmap_size_explicit_value); + RUN_TEST(mmap_size_negative_clamped_to_zero); + RUN_TEST(mmap_size_garbage_falls_back_to_default); + RUN_TEST(mmap_size_partial_garbage_falls_back_to_default); + RUN_TEST(store_open_with_mmap_disabled); +}