Files
codebase-memory-mcp/tests/test_store_bulk.c
T
Andrew Hundt 9182db9476 fix(leak): fix 3 memory leaks and 2 waitpid hangs under leaks --atExit
Previous behavior:
- make test-leak would hang indefinitely on bulk_crash_recovery and
  exec_no_shell_* tests because leaks --atExit SIGSTOPs forked children
  during heap inspection, but waitpid(pid, 0) never returns for a stopped child
- Running leaks reported 84 leaks (11,840 bytes) from three sources

What changed:
- tests/test_store_bulk.c, src/foundation/compat_fs.c: replace bare
  waitpid(pid, &status, 0) with WUNTRACED loop that sends SIGCONT on
  WIFSTOPPED, allowing leaks --atExit to inspect and release children;
  add #include <signal.h> for kill() declaration
- internal/cbm/sqlite_writer.c (pb_build_interior): free sep_cell for each
  child in the final (root) level before freeing the children array; the
  intermediate-level loop already freed these but the root-level path skipped it
- src/mcp/mcp.c (handle_trace_call_path): free the nodes array from the first
  cbm_store_find_nodes_by_name call before overwriting nodes/node_count with
  the fallback search result, preventing a 16-element leak on the fallback path
- src/mcp/mcp.c (handle_get_code_snippet): add REQUIRE_STORE_EX macro that
  accepts an extra cleanup expression; use it to free qn and snippet_mode
  before the early return when store resolution fails; REQUIRE_STORE is now
  an alias for REQUIRE_STORE_EX with (void)0

Result:
- make test-leak completes without hanging: 2917 tests pass, 0 leaks for
  0 total leaked bytes (confirmed via leaks report)

Files affected:
- tests/test_store_bulk.c: WUNTRACED+SIGCONT loop in bulk_crash_recovery
- src/foundation/compat_fs.c: WUNTRACED+SIGCONT loop in cbm_exec_no_shell
- internal/cbm/sqlite_writer.c: free sep_cell at root level in pb_build_interior
- src/mcp/mcp.c: free nodes before fallback; add REQUIRE_STORE_EX; use in get_code_snippet

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
2026-04-11 09:22:23 -04:00

192 lines
6.4 KiB
C

/*
* test_store_bulk.c — Crash-safety tests for bulk write mode.
*
* Verifies that cbm_store_begin_bulk / cbm_store_end_bulk never switch away
* from WAL journal mode. Switching to MEMORY journal mode during bulk writes
* makes the database unrecoverable on a crash because the in-memory rollback
* journal is lost. WAL mode is inherently crash-safe: uncommitted WAL entries
* are simply discarded on the next open. Performance is preserved via
* synchronous=OFF and a larger cache, which are safe with WAL.
*
* Tests:
* bulk_pragma_wal_invariant — journal_mode stays "wal" after begin_bulk
* bulk_pragma_end_wal_invariant — journal_mode stays "wal" after end_bulk
* bulk_crash_recovery — DB is readable after simulated crash mid-bulk
*/
#include "test_framework.h"
#include <store/store.h>
#include <foundation/compat.h>
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#endif
/* ── Helpers ──────────────────────────────────────────────────── */
/* Query journal_mode via a separate read-only connection so the result is
* independent of any state held inside the cbm_store_t under test. */
static char *get_journal_mode(const char *db_path) {
sqlite3 *db;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK)
return NULL;
sqlite3_stmt *stmt;
char *mode = NULL;
if (sqlite3_prepare_v2(db, "PRAGMA journal_mode;", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW)
mode = strdup((const char *)sqlite3_column_text(stmt, 0));
sqlite3_finalize(stmt);
}
sqlite3_close(db);
return mode;
}
static void make_temp_path(char *buf, size_t n) {
snprintf(buf, n, "%s/cmm_bulk_test_%d.db", cbm_tmpdir(), (int)getpid());
}
static void cleanup_db(const char *path) {
remove(path);
char aux[512];
snprintf(aux, sizeof(aux), "%s-wal", path);
remove(aux);
snprintf(aux, sizeof(aux), "%s-shm", path);
remove(aux);
}
/* ── Tests ──────────────────────────────────────────────────────── */
/* begin_bulk must NOT switch journal_mode away from WAL. */
TEST(bulk_pragma_wal_invariant) {
char db_path[256];
make_temp_path(db_path, sizeof(db_path));
cleanup_db(db_path);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
char *before = get_journal_mode(db_path);
ASSERT_NOT_NULL(before);
ASSERT_STR_EQ(before, "wal");
free(before);
int rc = cbm_store_begin_bulk(s);
ASSERT_EQ(rc, CBM_STORE_OK);
char *after = get_journal_mode(db_path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, "wal"); /* FAILS if bulk mode switches away from WAL */
free(after);
cbm_store_end_bulk(s);
cbm_store_close(s);
cleanup_db(db_path);
PASS();
}
/* end_bulk must also leave journal_mode as WAL. */
TEST(bulk_pragma_end_wal_invariant) {
char db_path[256];
make_temp_path(db_path, sizeof(db_path));
cleanup_db(db_path);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
cbm_store_begin_bulk(s);
cbm_store_end_bulk(s);
char *mode = get_journal_mode(db_path);
ASSERT_NOT_NULL(mode);
ASSERT_STR_EQ(mode, "wal");
free(mode);
cbm_store_close(s);
cleanup_db(db_path);
PASS();
}
/* Simulate a crash mid-bulk-write: fork a child that calls begin_bulk, opens
* an explicit transaction, and then calls _exit() without committing or calling
* end_bulk. The parent verifies the database is still openable and that
* committed baseline data is intact and uncommitted data is absent.
*
* This test uses fork()/waitpid() and is therefore POSIX-only. */
#ifndef _WIN32
TEST(bulk_crash_recovery) {
char db_path[256];
make_temp_path(db_path, sizeof(db_path));
cleanup_db(db_path);
/* Write committed baseline data. */
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
int rc = cbm_store_upsert_project(s, "baseline", "/tmp/baseline");
ASSERT_EQ(rc, CBM_STORE_OK);
cbm_store_close(s);
/* Child: enter bulk mode, start a transaction, write, then crash. */
pid_t pid = fork();
if (pid == 0) {
cbm_store_t *cs = cbm_store_open_path(db_path);
if (!cs)
_exit(1);
cbm_store_begin_bulk(cs);
cbm_store_begin(cs); /* explicit open transaction */
cbm_store_upsert_project(cs, "crashed", "/tmp/crashed");
/* Crash: no COMMIT, no end_bulk, no close. */
_exit(0);
}
ASSERT_GT(pid, 0);
int status;
/* Robust wait: leaks --atExit on macOS temporarily SIGSTOPs forked children
* during heap inspection. WUNTRACED lets us detect the stop and send SIGCONT
* so the child can proceed to _exit(). */
for (;;) {
pid_t r = waitpid(pid, &status, WUNTRACED);
ASSERT_GT((int)r, 0);
if (WIFSTOPPED(status)) {
kill(pid, SIGCONT);
continue;
}
break;
}
/* Confirm child exited normally so the write actually occurred. */
ASSERT(WIFEXITED(status) && WEXITSTATUS(status) == 0);
/* Recovery: database must open cleanly. */
cbm_store_t *recovered = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(recovered); /* NULL would indicate corruption */
/* Baseline commit must survive. */
cbm_project_t p = {0};
rc = cbm_store_get_project(recovered, "baseline", &p);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(p.name, "baseline");
cbm_project_free_fields(&p);
/* Uncommitted "crashed" write must NOT appear after recovery. */
cbm_project_t p2 = {0};
int rc2 = cbm_store_get_project(recovered, "crashed", &p2);
ASSERT_NEQ(rc2, CBM_STORE_OK); /* row must be absent */
cbm_store_close(recovered);
cleanup_db(db_path);
PASS();
}
#endif /* _WIN32 */
/* ── Suite ──────────────────────────────────────────────────────── */
SUITE(store_bulk) {
RUN_TEST(bulk_pragma_wal_invariant);
RUN_TEST(bulk_pragma_end_wal_invariant);
#ifndef _WIN32
RUN_TEST(bulk_crash_recovery);
#endif
}