diff --git a/Makefile.cbm b/Makefile.cbm index 6bc1eb12..cd91ed22 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_checkpoint.c TEST_CYPHER_SRCS = \ tests/test_cypher.c diff --git a/src/store/store.c b/src/store/store.c index 5d73dcce..63a2fc7b 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -790,7 +790,13 @@ int cbm_store_create_indexes(cbm_store_t *s) { /* ── Checkpoint ─────────────────────────────────────────────────── */ int cbm_store_checkpoint(cbm_store_t *s) { - int rc = sqlite3_wal_checkpoint_v2(s->db, NULL, SQLITE_CHECKPOINT_TRUNCATE, NULL, NULL); + /* PASSIVE never blocks readers and never ftruncate()s either file. + * SQLite recommends PASSIVE for shared databases — TRUNCATE shrinks + * the WAL via ftruncate(fd, 0) on success, which on macOS can raise + * SIGBUS in a sibling process that has the DB mmap'd through SQLite + * when it next faults a page in the now-shorter region. + * See https://www.sqlite.org/c3ref/c_checkpoint_full.html */ + int rc = sqlite3_wal_checkpoint_v2(s->db, NULL, SQLITE_CHECKPOINT_PASSIVE, NULL, NULL); if (rc != SQLITE_OK) { store_set_error_sqlite(s, "checkpoint"); return CBM_STORE_ERR; diff --git a/tests/test_main.c b/tests/test_main.c index 6796f8c3..ac2248ad 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_checkpoint(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_checkpoint); /* Cypher (M6) */ RUN_SUITE(cypher); diff --git a/tests/test_store_checkpoint.c b/tests/test_store_checkpoint.c new file mode 100644 index 00000000..d3ed8d1c --- /dev/null +++ b/tests/test_store_checkpoint.c @@ -0,0 +1,79 @@ +/* + * test_store_checkpoint.c — Tests for WAL checkpoint behavior. + * + * Verifies that cbm_store_checkpoint() does not truncate the on-disk + * WAL file. SQLITE_CHECKPOINT_TRUNCATE shrinks the WAL via ftruncate(fd, 0) + * on success; on macOS this can raise SIGBUS in a sibling process that + * has the DB mmap'd through SQLite when it next faults a page in the + * now-shorter region. SQLITE_CHECKPOINT_PASSIVE marks frames as + * checkpointed in the WAL header without changing the file size — disk + * space is reclaimed on the next write cycle, not on every checkpoint. + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include +#include +#include +#include +#include +#include + +TEST(checkpoint_does_not_truncate_wal) { + enum { N_ROWS = 100, PATH_BUF = 256, PATH_BUF_EXT = 300 }; + char db_path[PATH_BUF]; + snprintf(db_path, sizeof(db_path), "/tmp/cbm_test_ckpt_%d.db", (int)getpid()); + char wal_path[PATH_BUF_EXT]; + snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); + char shm_path[PATH_BUF_EXT]; + snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path); + unlink(db_path); + unlink(wal_path); + unlink(shm_path); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT(s != NULL); + + /* Grow WAL beyond zero bytes via direct SQL. */ + int rc_sql = cbm_store_exec( + s, + "INSERT OR IGNORE INTO projects(name, indexed_at, root_path) " + "VALUES('p', '2026-01-01', '/tmp/p');"); + ASSERT_EQ(rc_sql, 0); + for (int i = 0; i < N_ROWS; i++) { + char sql[256]; + snprintf(sql, sizeof(sql), + "INSERT INTO nodes(project, label, name, qualified_name, file_path) " + "VALUES('p', 'Function', 'fn', 'p.module.fn_%d', 'f.c');", + i); + rc_sql = cbm_store_exec(s, sql); + ASSERT_EQ(rc_sql, 0); + } + + /* WAL must exist and be non-empty before the checkpoint call. */ + struct stat st_before; + int rc_stat = stat(wal_path, &st_before); + ASSERT_EQ(rc_stat, 0); + ASSERT(st_before.st_size > 0); + + /* Under SQLITE_CHECKPOINT_TRUNCATE the WAL would be ftruncate()d to 0 + * bytes on success. Under SQLITE_CHECKPOINT_PASSIVE the file size is + * preserved (frames marked, not removed). */ + int rc_ckpt = cbm_store_checkpoint(s); + ASSERT_EQ(rc_ckpt, 0); /* CBM_STORE_OK */ + + struct stat st_after; + rc_stat = stat(wal_path, &st_after); + ASSERT_EQ(rc_stat, 0); + ASSERT(st_after.st_size > 0); + + cbm_store_close(s); + unlink(db_path); + unlink(wal_path); + unlink(shm_path); + PASS(); +} + +SUITE(store_checkpoint) { + RUN_TEST(checkpoint_does_not_truncate_wal); +}