From 036562df35486924c62cdb11a8c9518e479959d1 Mon Sep 17 00:00:00 2001 From: Brian McNaboe Date: Thu, 20 Aug 2026 12:22:24 -0400 Subject: [PATCH] fix(hook-augment): cache the image fingerprint per process and announce a missed deadline on stderr The build-identity fingerprint is a SHA-256 over the entire ~295 MB executable: ~1.1 s per hash on an M5 Pro, ~2.3 s on the machine where this was first measured. A daemon pays it twice at start (the supervisor's startup capture and the runtime service's active-image check), and a peer whose image is not inode-identical to the daemon's own is re-hashed in full on every rendezvous. - Cache the fingerprint per process at runtime_process_image_reference_acquire, keyed by (device, inode, size, mtime, ctime): the tuple the acquire path already verifies as stable across the hash, so a rebuilt or replaced binary rolls the key. Every hit is still bracketed by the same before/after stat and process-maps checks, so a cached digest stays bound to the verified image. Measured with a 296 MB image: daemon cold start 6.5 s -> 5.3 s. A cold hook-augment still hashes exactly once (1.15 s here) and is unchanged by the cache. - A missed hook deadline is announced on stderr as well as the timeouts log, so a fired deadline is never a silent 0-byte exit 0. The breadcrumb is formatted before the log path is resolved, so stderr gets it even when the log cannot be opened. CBM_HOOK_DEADLINE_MS is unchanged. - `daemon ` is listed in --help. SHA-256 itself is unchanged: one portable scalar implementation on every platform. Refs #1335, #2058 Signed-off-by: Brian McNaboe --- src/cli/hook_augment.c | 31 +++-- src/daemon/runtime.c | 255 ++++++++++++++++++++++++++++++++++-- src/daemon/runtime.h | 13 ++ src/main.c | 3 + tests/test_cli.c | 34 ++++- tests/test_daemon_runtime.c | 49 +++++++ 6 files changed, 361 insertions(+), 24 deletions(-) diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index f8909c365..b5541fcb9 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -96,14 +96,31 @@ static size_t g_ha_crumb_len = 0; static void ha_deadline_exit(int sig) { (void)sig; - if (g_ha_crumb_fd >= 0 && g_ha_crumb_len > 0) { - ssize_t w = write(g_ha_crumb_fd, g_ha_crumb_msg, g_ha_crumb_len); - (void)w; + /* async-signal-safe only: write() the prepared breadcrumb to the timeouts + * log (when it opened) and ALWAYS to stderr, so a fired deadline is a + * visible event, never a silent 0-byte exit(0). */ + if (g_ha_crumb_len > 0) { + if (g_ha_crumb_fd >= 0) { + ssize_t w = write(g_ha_crumb_fd, g_ha_crumb_msg, g_ha_crumb_len); + (void)w; + } + ssize_t e = write(STDERR_FILENO, g_ha_crumb_msg, g_ha_crumb_len); + (void)e; } _exit(0); } static void ha_open_crumb_log(int deadline_ms) { + /* Pre-format the breadcrumb unconditionally: the handler may only write() a + * prepared buffer, and formatting it before the log path is resolved means + * stderr still gets the line even when the file cannot be opened (no HOME, + * unwritable cache dir) — the "never silent" guarantee holds regardless. */ + int n = snprintf(g_ha_crumb_msg, sizeof(g_ha_crumb_msg), + "hook-augment: deadline_exceeded ms=%d pid=%ld (raise via " + "CBM_HOOK_DEADLINE_MS)\n", + deadline_ms, (long)getpid()); + g_ha_crumb_len = (n > 0 && n < (int)sizeof(g_ha_crumb_msg)) ? (size_t)n : 0; + const char *override = getenv("CBM_HOOK_TIMEOUT_LOG"); /* tests + power users */ char path[CBM_SZ_1K]; if (override && override[0]) { @@ -119,14 +136,6 @@ static void ha_open_crumb_log(int deadline_ms) { snprintf(path, sizeof(path), "%s/hook-augment-timeouts.log", dir); } g_ha_crumb_fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0644); - if (g_ha_crumb_fd < 0) { - return; - } - int n = snprintf(g_ha_crumb_msg, sizeof(g_ha_crumb_msg), - "hook-augment: deadline_exceeded ms=%d pid=%ld (raise via " - "CBM_HOOK_DEADLINE_MS)\n", - deadline_ms, (long)getpid()); - g_ha_crumb_len = (n > 0 && n < (int)sizeof(g_ha_crumb_msg)) ? (size_t)n : 0; } int cbm_hook_augment_deadline_ms_for_testing(void) { diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index efa41a020..c1bbe90b6 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -752,6 +752,239 @@ static bool runtime_posix_stat_same_image(const struct stat *first, const struct #endif +/* ── Executable-image fingerprint cache ────────────────────────────────────── + * The build-identity fingerprint is a SHA-256 over the entire ~295 MB + * executable (cbm_daemon_build_fingerprint_native_file) — ~2.3 s of pure CPU on + * every process start and every peer rendezvous, and the direct cause of the + * hook-augment startup deadline being missed on large graphs. The mapped image + * is immutable for a build's lifetime, so the digest is cached keyed by the file + * identity the acquire path already verifies as stable across the hash (device, + * inode, size, mtime, ctime — the same tuple runtime_*_stat_same compares). A + * rebuilt or replaced binary lands on a new inode/mtime and misses, so the cache + * can never return a stale digest and build-cohort admission keeps its exact + * meaning. A cache hit does not weaken the identity proof: the acquire chain + * still brackets the held image with its before/after stat and process-maps + * checks, so the returned digest is bound to that same verified image — only the + * re-read of bytes whose identity is unchanged is skipped. */ +#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) + +typedef struct { + bool valid; + uint64_t device; + uint64_t inode; + uint64_t size; + int64_t mtime_seconds; + int64_t mtime_nanoseconds; + int64_t ctime_seconds; + int64_t ctime_nanoseconds; +} runtime_fingerprint_key_t; + +#define RUNTIME_FINGERPRINT_CACHE_CAP 8 + +static atomic_flag runtime_fingerprint_cache_lock = ATOMIC_FLAG_INIT; +static struct { + runtime_fingerprint_key_t key; + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; +} runtime_fingerprint_cache[RUNTIME_FINGERPRINT_CACHE_CAP]; +static size_t runtime_fingerprint_cache_count; +static size_t runtime_fingerprint_cache_victim; + +#ifdef CBM_ENABLE_TEST_SEAMS +static atomic_int runtime_fingerprint_hash_call_count; +static atomic_bool runtime_fingerprint_hash_stub_active; +static char runtime_fingerprint_hash_stub_digest[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; +#endif + +static bool runtime_fingerprint_key_equal(const runtime_fingerprint_key_t *a, + const runtime_fingerprint_key_t *b) { + return a->valid && b->valid && a->device == b->device && a->inode == b->inode && + a->size == b->size && a->mtime_seconds == b->mtime_seconds && + a->mtime_nanoseconds == b->mtime_nanoseconds && a->ctime_seconds == b->ctime_seconds && + a->ctime_nanoseconds == b->ctime_nanoseconds; +} + +static void runtime_fingerprint_cache_enter(void) { + while ( + atomic_flag_test_and_set_explicit(&runtime_fingerprint_cache_lock, memory_order_acquire)) {} +} + +static void runtime_fingerprint_cache_leave(void) { + atomic_flag_clear_explicit(&runtime_fingerprint_cache_lock, memory_order_release); +} + +static bool runtime_fingerprint_cache_lookup(const runtime_fingerprint_key_t *key, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!key->valid) { + return false; + } + bool hit = false; + runtime_fingerprint_cache_enter(); + for (size_t index = 0; index < runtime_fingerprint_cache_count; index++) { + if (runtime_fingerprint_key_equal(&runtime_fingerprint_cache[index].key, key)) { + memcpy(out, runtime_fingerprint_cache[index].fingerprint, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + hit = true; + break; + } + } + runtime_fingerprint_cache_leave(); + return hit; +} + +static void runtime_fingerprint_cache_store( + const runtime_fingerprint_key_t *key, + const char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!key->valid || fingerprint[0] == '\0') { + return; + } + runtime_fingerprint_cache_enter(); + size_t slot = RUNTIME_FINGERPRINT_CACHE_CAP; + for (size_t index = 0; index < runtime_fingerprint_cache_count; index++) { + if (runtime_fingerprint_key_equal(&runtime_fingerprint_cache[index].key, key)) { + slot = index; + break; + } + } + if (slot == RUNTIME_FINGERPRINT_CACHE_CAP) { + if (runtime_fingerprint_cache_count < RUNTIME_FINGERPRINT_CACHE_CAP) { + slot = runtime_fingerprint_cache_count++; + } else { + slot = runtime_fingerprint_cache_victim; + runtime_fingerprint_cache_victim = + (runtime_fingerprint_cache_victim + 1) % RUNTIME_FINGERPRINT_CACHE_CAP; + } + } + runtime_fingerprint_cache[slot].key = *key; + memcpy(runtime_fingerprint_cache[slot].fingerprint, fingerprint, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + runtime_fingerprint_cache_leave(); +} + +/* The single point where the expensive image hash is paid; a test seam counts + * calls and can substitute a stub digest so the cache's hit/miss behaviour is + * observable without a real 295 MB read. */ +static bool runtime_fingerprint_hash_native_file(uintptr_t native_file, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { +#ifdef CBM_ENABLE_TEST_SEAMS + atomic_fetch_add_explicit(&runtime_fingerprint_hash_call_count, 1, memory_order_relaxed); + if (atomic_load_explicit(&runtime_fingerprint_hash_stub_active, memory_order_acquire)) { + memcpy(out, runtime_fingerprint_hash_stub_digest, CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + return true; + } +#endif + return cbm_daemon_build_fingerprint_native_file(native_file, out); +} + +/* Resolve the fingerprint of the held native image, consulting the cache before + * paying for the full-image hash and populating it on a miss. */ +static bool runtime_build_fingerprint_cached(uintptr_t native_file, + const runtime_fingerprint_key_t *key, + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (runtime_fingerprint_cache_lookup(key, fingerprint)) { + return true; + } + if (!runtime_fingerprint_hash_native_file(native_file, fingerprint)) { + return false; + } + runtime_fingerprint_cache_store(key, fingerprint); + return true; +} + +#ifdef _WIN32 +static int64_t runtime_fingerprint_filetime(FILETIME value) { + return (int64_t)(((uint64_t)value.dwHighDateTime << 32) | value.dwLowDateTime); +} + +static bool runtime_build_fingerprint_cached_windows( + uintptr_t native_file, const BY_HANDLE_FILE_INFORMATION *information, const LARGE_INTEGER *size, + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + runtime_fingerprint_key_t key; + memset(&key, 0, sizeof(key)); + key.valid = information != NULL && size != NULL && size->QuadPart >= 0; + if (key.valid) { + key.device = information->dwVolumeSerialNumber; + key.inode = ((uint64_t)information->nFileIndexHigh << 32) | information->nFileIndexLow; + key.size = (uint64_t)size->QuadPart; + key.mtime_seconds = runtime_fingerprint_filetime(information->ftLastWriteTime); + key.ctime_seconds = runtime_fingerprint_filetime(information->ftCreationTime); + } + return runtime_build_fingerprint_cached(native_file, &key, fingerprint); +} +#else +static bool runtime_build_fingerprint_cached_posix( + uintptr_t native_file, const struct stat *status, + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + runtime_fingerprint_key_t key; + memset(&key, 0, sizeof(key)); + key.valid = status != NULL && S_ISREG(status->st_mode); + if (key.valid) { + key.device = (uint64_t)status->st_dev; + key.inode = (uint64_t)status->st_ino; + key.size = (uint64_t)status->st_size; +#ifdef __APPLE__ + key.mtime_seconds = status->st_mtimespec.tv_sec; + key.mtime_nanoseconds = status->st_mtimespec.tv_nsec; + key.ctime_seconds = status->st_ctimespec.tv_sec; + key.ctime_nanoseconds = status->st_ctimespec.tv_nsec; +#else + key.mtime_seconds = status->st_mtim.tv_sec; + key.mtime_nanoseconds = status->st_mtim.tv_nsec; + key.ctime_seconds = status->st_ctim.tv_sec; + key.ctime_nanoseconds = status->st_ctim.tv_nsec; +#endif + } + return runtime_build_fingerprint_cached(native_file, &key, fingerprint); +} +#endif + +#ifdef CBM_ENABLE_TEST_SEAMS +void cbm_daemon_runtime_fingerprint_cache_reset_for_testing(void) { + runtime_fingerprint_cache_enter(); + memset(runtime_fingerprint_cache, 0, sizeof(runtime_fingerprint_cache)); + runtime_fingerprint_cache_count = 0; + runtime_fingerprint_cache_victim = 0; + runtime_fingerprint_cache_leave(); + atomic_store_explicit(&runtime_fingerprint_hash_call_count, 0, memory_order_relaxed); + atomic_store_explicit(&runtime_fingerprint_hash_stub_active, false, memory_order_release); + memset(runtime_fingerprint_hash_stub_digest, 0, sizeof(runtime_fingerprint_hash_stub_digest)); +} + +void cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(const char *digest) { + if (!digest) { + atomic_store_explicit(&runtime_fingerprint_hash_stub_active, false, memory_order_release); + return; + } + memset(runtime_fingerprint_hash_stub_digest, 0, sizeof(runtime_fingerprint_hash_stub_digest)); + size_t length = strlen(digest); + if (length >= CBM_DAEMON_BUILD_FINGERPRINT_SIZE) { + length = CBM_DAEMON_BUILD_FINGERPRINT_SIZE - 1; + } + memcpy(runtime_fingerprint_hash_stub_digest, digest, length); + atomic_store_explicit(&runtime_fingerprint_hash_stub_active, true, memory_order_release); +} + +int cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(void) { + return atomic_load_explicit(&runtime_fingerprint_hash_call_count, memory_order_relaxed); +} + +bool cbm_daemon_runtime_fingerprint_cache_resolve_for_testing( + uint64_t device, uint64_t inode, uint64_t size, int64_t mtime_seconds, + int64_t mtime_nanoseconds, int64_t ctime_seconds, int64_t ctime_nanoseconds, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + runtime_fingerprint_key_t key = {.valid = true, + .device = device, + .inode = inode, + .size = size, + .mtime_seconds = mtime_seconds, + .mtime_nanoseconds = mtime_nanoseconds, + .ctime_seconds = ctime_seconds, + .ctime_nanoseconds = ctime_nanoseconds}; + return runtime_build_fingerprint_cached((uintptr_t)0, &key, out); +} +#endif + +#endif /* supported platform: executable-image fingerprint cache */ + /* Acquire one process instance's mapped image as a native object. Supplying a * fingerprint hashes that same held object; NULL performs metadata-only * acquisition for the HELLO fast path. Every platform brackets the process @@ -793,7 +1026,8 @@ static bool runtime_process_image_reference_acquire( : INVALID_HANDLE_VALUE; free(open_path); ok = ok && runtime_windows_file_snapshot(file, &file_before, &size_before) && - (!fingerprint || cbm_daemon_build_fingerprint_native_file((uintptr_t)file, fingerprint)) && + (!fingerprint || runtime_build_fingerprint_cached_windows((uintptr_t)file, &file_before, + &size_before, fingerprint)) && runtime_windows_file_snapshot(file, &file_after, &size_after) && runtime_windows_file_snapshot_same(&file_before, &size_before, &file_after, &size_after) && runtime_windows_process_image_snapshot(process, &process_after) && @@ -829,7 +1063,8 @@ static bool runtime_process_image_reference_acquire( struct stat file_after; ok = ok && fd >= 0 && fstat(fd, &file_before) == 0 && S_ISREG(file_before.st_mode) && runtime_mac_process_maps_file_executable(pid, &file_before) && - (!fingerprint || cbm_daemon_build_fingerprint_native_file((uintptr_t)fd, fingerprint)) && + (!fingerprint || + runtime_build_fingerprint_cached_posix((uintptr_t)fd, &file_before, fingerprint)) && fstat(fd, &file_after) == 0 && runtime_mac_stat_same(&file_before, &file_after) && runtime_mac_process_maps_file_executable(pid, &file_after) && runtime_mac_process_instance(pid, &process_after) && @@ -852,12 +1087,12 @@ static bool runtime_process_image_reference_acquire( int image_fd = process_fd >= 0 ? openat(process_fd, "exe", O_RDONLY | O_CLOEXEC) : -1; struct stat image_before; struct stat image_after; - bool ok = image_fd >= 0 && fstat(image_fd, &image_before) == 0 && - S_ISREG(image_before.st_mode) && - (!fingerprint || - cbm_daemon_build_fingerprint_native_file((uintptr_t)image_fd, fingerprint)) && - fstat(image_fd, &image_after) == 0 && - runtime_posix_stat_same_image(&image_before, &image_after); + bool ok = + image_fd >= 0 && fstat(image_fd, &image_before) == 0 && S_ISREG(image_before.st_mode) && + (!fingerprint || + runtime_build_fingerprint_cached_posix((uintptr_t)image_fd, &image_before, fingerprint)) && + fstat(image_fd, &image_after) == 0 && + runtime_posix_stat_same_image(&image_before, &image_after); int verify_fd = ok ? openat(process_fd, "exe", O_RDONLY | O_CLOEXEC) : -1; struct stat verify_status; ok = ok && verify_fd >= 0 && fstat(verify_fd, &verify_status) == 0 && @@ -895,8 +1130,8 @@ static bool runtime_process_image_reference_acquire( struct stat image_before; struct stat image_after; ok = image_fd >= 0 && fstat(image_fd, &image_before) == 0 && S_ISREG(image_before.st_mode) && - (!fingerprint || - cbm_daemon_build_fingerprint_native_file((uintptr_t)image_fd, fingerprint)) && + (!fingerprint || runtime_build_fingerprint_cached_posix((uintptr_t)image_fd, &image_before, + fingerprint)) && fstat(image_fd, &image_after) == 0 && runtime_posix_stat_same_image(&image_before, &image_after); if (ok) { diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index 7a31dab61..8b9a2cd2c 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -454,6 +454,19 @@ void cbm_daemon_runtime_set_containment_hook_for_testing( * in test time. UINT32_MAX restores the production constant; any other value * (0 = expire immediately) overrides. Process-global; reset it after use. */ void cbm_daemon_runtime_service_set_ephemeral_linger_timeout_for_testing(uint32_t timeout_ms); + +/* Executable-image fingerprint-cache seams: reset the cache and hash counter + * between cases, substitute a stub for the expensive image hash so cache + * hit/miss/key-roll are observable without a real 295 MB read, and resolve a + * synthetic file identity through the exact cached path the acquire fingerprint + * sites use. */ +void cbm_daemon_runtime_fingerprint_cache_reset_for_testing(void); +void cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(const char *digest); +int cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(void); +bool cbm_daemon_runtime_fingerprint_cache_resolve_for_testing( + uint64_t device, uint64_t inode, uint64_t size, int64_t mtime_seconds, + int64_t mtime_nanoseconds, int64_t ctime_seconds, int64_t ctime_nanoseconds, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); #endif #endif /* CBM_DAEMON_RUNTIME_H */ diff --git a/src/main.c b/src/main.c index 284a899cb..80baee318 100644 --- a/src/main.c +++ b/src/main.c @@ -1079,6 +1079,9 @@ static void print_help(void) { printf(" codebase-memory-mcp uninstall [-y|-n] [--dry-run]\n"); printf(" codebase-memory-mcp update [-y|-n]\n"); printf(" codebase-memory-mcp config \n"); + printf(" codebase-memory-mcp daemon \n"); + printf(" start keeps a warm daemon so per-call startup\n"); + printf(" cost (and hook-augment latency) is removed\n"); printf(" codebase-memory-mcp --version Print version\n"); printf(" codebase-memory-mcp --help Print this help\n"); printf("\nCLI output options:\n"); diff --git a/tests/test_cli.c b/tests/test_cli.c index 06ebb3841..2b0a2e241 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -14389,19 +14389,31 @@ TEST(cli_hook_augment_deadline_breadcrumb_issue858) { snprintf(logpath, sizeof(logpath), "%s/timeouts.log", tmpdir); int fds[2]; + int errfds[2]; if (pipe(fds) != 0) { test_rmdir_r(tmpdir); FAIL("pipe failed"); } + if (pipe(errfds) != 0) { + close(fds[0]); + close(fds[1]); + test_rmdir_r(tmpdir); + FAIL("stderr pipe failed"); + } fflush(NULL); pid_t pid = fork(); if (pid == 0) { /* Child: hook-augment with a 60ms deadline and stdin that blocks - * forever (parent keeps the write end open, sends nothing). */ + * forever (parent keeps the write end open, sends nothing). Its stderr + * is redirected so the parent can prove the deadline is also announced + * there, not only in the timeouts log. */ close(fds[1]); dup2(fds[0], 0); close(fds[0]); + close(errfds[0]); + dup2(errfds[1], 2); + close(errfds[1]); setenv("CBM_HOOK_DEADLINE_MS", "60", 1); setenv("CBM_HOOK_TIMEOUT_LOG", logpath, 1); alarm(10); /* backstop: never hang the suite */ @@ -14409,6 +14421,19 @@ TEST(cli_hook_augment_deadline_breadcrumb_issue858) { } ASSERT_GT(pid, 0); close(fds[0]); + close(errfds[1]); + + /* Drain the child's stderr before reaping, so a full pipe can never wedge + * it. EOF arrives once the child's deadline handler has run and _exit()ed. */ + char errbuf[4096] = ""; + size_t errlen = 0; + ssize_t rd; + while (errlen + 1 < sizeof(errbuf) && + (rd = read(errfds[0], errbuf + errlen, sizeof(errbuf) - 1 - errlen)) > 0) { + errlen += (size_t)rd; + } + errbuf[errlen] = '\0'; + close(errfds[0]); int status = 0; waitpid(pid, &status, 0); @@ -14419,8 +14444,8 @@ TEST(cli_hook_augment_deadline_breadcrumb_issue858) { ASSERT_EQ(WEXITSTATUS(status), 0); /* RED before the fix: no breadcrumb existed — a fired deadline was - * indistinguishable from a no-match run. GREEN: the log names the - * deadline and the knob. */ + * indistinguishable from a no-match run. GREEN: it is announced on BOTH + * the timeouts log and stderr, each naming the deadline and the knob. */ FILE *f = fopen(logpath, "r"); if (!f) { fprintf(stderr, " [858] FAIL no timeout breadcrumb written to %s\n", logpath); @@ -14433,6 +14458,9 @@ TEST(cli_hook_augment_deadline_breadcrumb_issue858) { ASSERT(strstr(line, "deadline_exceeded") != NULL); ASSERT(strstr(line, "CBM_HOOK_DEADLINE_MS") != NULL); + ASSERT(strstr(errbuf, "deadline_exceeded") != NULL); + ASSERT(strstr(errbuf, "CBM_HOOK_DEADLINE_MS") != NULL); + cbm_unsetenv("CBM_HOOK_DEADLINE_MS"); cbm_unsetenv("CBM_HOOK_TIMEOUT_LOG"); test_rmdir_r(tmpdir); diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index 827a8cf34..9af6ee9db 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -4911,6 +4911,54 @@ TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { } #endif +/* The executable-image fingerprint cache short-circuits the ~2.3 s image hash + * for a repeated file identity, and rolls its key when the binary changes so a + * rebuild or atomic replacement never returns a stale digest. */ +TEST(daemon_runtime_fingerprint_cache_hit_miss_key_roll) { + /* 64-hex placeholders, distinct enough to tell "cached" from "recomputed". */ + static const char digest_a[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + static const char digest_b[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + + /* Miss: first sight of this identity pays the hash and stores digest_a. */ + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(digest_a); + ASSERT_TRUE( + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(1, 2, 3, 100, 200, 300, 400, out)); + ASSERT_STR_EQ(out, digest_a); + ASSERT_EQ(cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(), 1); + + /* Hit: the same identity returns the cached digest and never calls the hash, + * even though the stub would now yield a different value. */ + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(digest_b); + memset(out, 0, sizeof(out)); + ASSERT_TRUE( + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(1, 2, 3, 100, 200, 300, 400, out)); + ASSERT_STR_EQ(out, digest_a); + ASSERT_EQ(cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(), 1); + + /* Key roll: a changed mtime (as a rebuilt binary produces) misses and + * recomputes, yielding the current digest. */ + memset(out, 0, sizeof(out)); + ASSERT_TRUE( + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(1, 2, 3, 101, 200, 300, 400, out)); + ASSERT_STR_EQ(out, digest_b); + ASSERT_EQ(cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(), 2); + + /* A changed inode (atomic replacement) likewise rolls the key. */ + memset(out, 0, sizeof(out)); + ASSERT_TRUE( + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(1, 9, 3, 100, 200, 300, 400, out)); + ASSERT_STR_EQ(out, digest_b); + ASSERT_EQ(cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(), 3); + + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(NULL); + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + PASS(); +} + TEST(daemon_runtime_close_begin_releases_admission_with_inflight_request) { static const uint8_t request[] = {'b', 'l', 'o', 'c', 'k'}; cbm_daemon_build_identity_t identity = @@ -5105,6 +5153,7 @@ SUITE(daemon_runtime) { #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) RUN_TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path); #endif + RUN_TEST(daemon_runtime_fingerprint_cache_hit_miss_key_roll); RUN_TEST(daemon_runtime_convenience_service_owns_participant_guard); RUN_TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent); RUN_TEST(daemon_runtime_exact_hello_issues_connection_bound_identity);