From 9545ea4febde8474a4bab03cb35fcb7ab8f92783 Mon Sep 17 00:00:00 2001 From: Brian McNaboe Date: Thu, 20 Aug 2026 12:22:24 -0400 Subject: [PATCH] perf(daemon): hash the executable image once per process; announce missed hook deadlines 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. - Cache the fingerprint per process at runtime_process_image_reference_acquire, keyed by (device, inode, size, mtime, change time): the identity the acquire path already verifies as stable across the hash, so a rebuilt or replaced binary rolls the key. On Windows the change time is FILE_BASIC_INFO.ChangeTime, which the kernel maintains and SetFileTime cannot reset. 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. A freshly hashed digest is committed only after the acquire chain, including the post-hash stat check, has passed, and only under the identity observed after the hash, so an image that changed under the hash is never cached. The cache is process-local: it removes the daemon's second start-up hash (cold start 6.5 s -> 5.3 s with a 296 MB image); a fresh hook process still hashes its own image once. - 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. 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 | 355 +++++++++++++++++++++++++++++++++++- src/daemon/runtime.h | 25 +++ tests/test_cli.c | 34 +++- tests/test_daemon_runtime.c | 134 ++++++++++++++ 5 files changed, 556 insertions(+), 23 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 8d1d8af94..de8690494 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -752,6 +752,313 @@ 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 executable + * (cbm_daemon_build_fingerprint_native_file): about a second of CPU per hash + * for a ~300 MB image. A daemon process otherwise pays it twice at start (the + * supervisor's startup capture, then the runtime service's active-image check), + * so the digest is cached per process, keyed by the file identity the acquire + * path verifies as stable across the hash: device, inode, size, mtime and + * change time (on Windows FILE_BASIC_INFO.ChangeTime, which the kernel + * maintains and SetFileTime cannot reset, unlike the creation time). A rebuilt + * or replaced binary rolls that key and misses, so the cache never returns a + * stale digest and build-cohort admission keeps its exact meaning. The cache is + * process-local: a fresh process (a hook, a CLI call) still hashes its own image + * once. A 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. A freshly hashed digest is + * committed only after that post-hash check has passed, under the identity + * observed after the hash, so an image that changed under the hash is never + * cached. */ +#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) || defined(__FreeBSD__) || \ + defined(__NetBSD__) + +/* POSIX carries timespec fields; Windows stores FILETIME ticks in the seconds + * fields and leaves the nanoseconds fields zero. */ +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 full-image 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 digest for the image identity `key`: from the cache when it + * already holds that identity, otherwise by hashing the held native image. + * `cached` reports which. A freshly hashed digest is deliberately not stored + * here: the acquire site commits it (runtime_fingerprint_cache_commit) only + * once its post-hash identity check has proven the hashed bytes belong to the + * identity they were keyed on. */ +static bool runtime_build_fingerprint_resolve(uintptr_t native_file, + const runtime_fingerprint_key_t *key, + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], + bool *cached) { + *cached = runtime_fingerprint_cache_lookup(key, fingerprint); + return *cached || runtime_fingerprint_hash_native_file(native_file, fingerprint); +} + +/* Commit a freshly hashed digest after the acquire chain has passed. The + * identity observed after the hash must equal the one the digest was resolved + * under; otherwise the bytes read belong to no single identity and are not + * cached. A cache hit has nothing to commit. */ +static void runtime_fingerprint_cache_commit( + const runtime_fingerprint_key_t *before, const runtime_fingerprint_key_t *after, bool cached, + const char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!cached && runtime_fingerprint_key_equal(before, after)) { + runtime_fingerprint_cache_store(after, fingerprint); + } +} + +#ifdef _WIN32 +static int64_t runtime_fingerprint_filetime(FILETIME value) { + return (int64_t)(((uint64_t)value.dwHighDateTime << 32) | value.dwLowDateTime); +} + +/* Key the held image by the snapshot identity the acquire path compares plus + * the kernel-maintained change time. An unreadable change time yields an + * invalid key, which never hits and is never stored: the image is simply + * hashed. */ +static void runtime_fingerprint_key_windows(HANDLE file, + const BY_HANDLE_FILE_INFORMATION *information, + const LARGE_INTEGER *size, + runtime_fingerprint_key_t *key) { + memset(key, 0, sizeof(*key)); + FILE_BASIC_INFO basic; + memset(&basic, 0, sizeof(basic)); + key->valid = information != NULL && size != NULL && size->QuadPart >= 0 && + file != INVALID_HANDLE_VALUE && + GetFileInformationByHandleEx(file, FileBasicInfo, &basic, sizeof(basic)) != 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 = basic.ChangeTime.QuadPart; + } +} + +static bool runtime_build_fingerprint_resolve_windows( + HANDLE file, const BY_HANDLE_FILE_INFORMATION *information, const LARGE_INTEGER *size, + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], runtime_fingerprint_key_t *key, + bool *cached) { + runtime_fingerprint_key_windows(file, information, size, key); + return runtime_build_fingerprint_resolve((uintptr_t)file, key, fingerprint, cached); +} + +static void runtime_fingerprint_cache_commit_windows( + HANDLE file, const BY_HANDLE_FILE_INFORMATION *information, const LARGE_INTEGER *size, + const runtime_fingerprint_key_t *key, bool cached, + const char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + runtime_fingerprint_key_t key_after; + runtime_fingerprint_key_windows(file, information, size, &key_after); + runtime_fingerprint_cache_commit(key, &key_after, cached, fingerprint); +} +#else +static void runtime_fingerprint_key_posix(const struct stat *status, + 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 + } +} + +static bool runtime_build_fingerprint_resolve_posix( + uintptr_t native_file, const struct stat *status, + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], runtime_fingerprint_key_t *key, + bool *cached) { + runtime_fingerprint_key_posix(status, key); + return runtime_build_fingerprint_resolve(native_file, key, fingerprint, cached); +} + +static void runtime_fingerprint_cache_commit_posix( + const struct stat *status, const runtime_fingerprint_key_t *key, bool cached, + const char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + runtime_fingerprint_key_t key_after; + runtime_fingerprint_key_posix(status, &key_after); + runtime_fingerprint_cache_commit(key, &key_after, cached, 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); +} + +static runtime_fingerprint_key_t runtime_fingerprint_key_for_testing( + const cbm_daemon_runtime_fingerprint_identity_for_testing_t *identity) { + runtime_fingerprint_key_t key = {.valid = true, + .device = identity->device, + .inode = identity->inode, + .size = identity->size, + .mtime_seconds = identity->mtime_seconds, + .mtime_nanoseconds = identity->mtime_nanoseconds, + .ctime_seconds = identity->ctime_seconds, + .ctime_nanoseconds = identity->ctime_nanoseconds}; + return key; +} + +bool cbm_daemon_runtime_fingerprint_cache_resolve_for_testing( + const cbm_daemon_runtime_fingerprint_identity_for_testing_t *before, + const cbm_daemon_runtime_fingerprint_identity_for_testing_t *after, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + /* There is no native image behind a synthetic identity: only the stubbed + * hash may ever be reached from here. */ + if (!before || !after || !out || + !atomic_load_explicit(&runtime_fingerprint_hash_stub_active, memory_order_acquire)) { + return false; + } + runtime_fingerprint_key_t key_before = runtime_fingerprint_key_for_testing(before); + runtime_fingerprint_key_t key_after = runtime_fingerprint_key_for_testing(after); + bool cached = false; + if (!runtime_build_fingerprint_resolve((uintptr_t)0, &key_before, out, &cached)) { + return false; + } + runtime_fingerprint_cache_commit(&key_before, &key_after, cached, out); + return true; +} +#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 @@ -780,6 +1087,8 @@ static bool runtime_process_image_reference_acquire( BY_HANDLE_FILE_INFORMATION file_after; LARGE_INTEGER size_before; LARGE_INTEGER size_after; + runtime_fingerprint_key_t fingerprint_key = {0}; + bool fingerprint_cached = false; bool ok = runtime_windows_process_image_snapshot(process, &process_before); /* QueryFullProcessImageNameW returns a stable identity spelling for the * before/after comparison, but its Win32/DOS form may exceed MAX_PATH. @@ -793,7 +1102,9 @@ 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_resolve_windows(file, &file_before, &size_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && 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) && @@ -802,6 +1113,10 @@ static bool runtime_process_image_reference_acquire( ok = false; } if (ok) { + if (fingerprint) { + runtime_fingerprint_cache_commit_windows( + file, &file_after, &size_after, &fingerprint_key, fingerprint_cached, fingerprint); + } reference->held = true; reference->file = file; reference->information = file_after; @@ -827,14 +1142,22 @@ static bool runtime_process_image_reference_acquire( int fd = ok ? open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) : -1; struct stat file_before; struct stat file_after; + runtime_fingerprint_key_t fingerprint_key = {0}; + bool fingerprint_cached = false; 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_resolve_posix((uintptr_t)fd, &file_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && 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) && runtime_mac_process_instance_same(&process_before, &process_after); if (ok) { + if (fingerprint) { + runtime_fingerprint_cache_commit_posix(&file_after, &fingerprint_key, + fingerprint_cached, fingerprint); + } reference->held = true; reference->fd = fd; reference->status = file_after; @@ -852,12 +1175,15 @@ 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); + runtime_fingerprint_key_t fingerprint_key = {0}; + bool fingerprint_cached = false; + bool ok = + image_fd >= 0 && fstat(image_fd, &image_before) == 0 && S_ISREG(image_before.st_mode) && + (!fingerprint || + runtime_build_fingerprint_resolve_posix((uintptr_t)image_fd, &image_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && + 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 && @@ -869,6 +1195,10 @@ static bool runtime_process_image_reference_acquire( ok = false; } if (ok) { + if (fingerprint) { + runtime_fingerprint_cache_commit_posix(&image_after, &fingerprint_key, + fingerprint_cached, fingerprint); + } reference->held = true; reference->fd = image_fd; reference->status = image_after; @@ -894,12 +1224,19 @@ static bool runtime_process_image_reference_acquire( int image_fd = ok ? open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) : -1; struct stat image_before; struct stat image_after; + runtime_fingerprint_key_t fingerprint_key = {0}; + bool fingerprint_cached = false; 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)) && + runtime_build_fingerprint_resolve_posix((uintptr_t)image_fd, &image_before, fingerprint, + &fingerprint_key, &fingerprint_cached)) && fstat(image_fd, &image_after) == 0 && runtime_posix_stat_same_image(&image_before, &image_after); if (ok) { + if (fingerprint) { + runtime_fingerprint_cache_commit_posix(&image_after, &fingerprint_key, + fingerprint_cached, fingerprint); + } reference->held = true; reference->fd = image_fd; reference->status = image_after; diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index d9c3db8e9..deb1fed10 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -465,6 +465,31 @@ 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 empties the cache, zeroes + * the hash counter and disarms the stub; a test that arms the stub must reset + * before its first assertion, because an armed stub replaces every later + * fingerprint in the process. resolve drives a synthetic file identity through + * the exact resolve-then-commit path the acquire sites use: `before` is the + * identity the digest is keyed on, `after` the identity observed after the + * hash, and the digest is committed only when the two agree. It refuses unless + * the stub is armed, since no native image backs a synthetic identity. */ +typedef struct { + 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; +} cbm_daemon_runtime_fingerprint_identity_for_testing_t; +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( + const cbm_daemon_runtime_fingerprint_identity_for_testing_t *before, + const cbm_daemon_runtime_fingerprint_identity_for_testing_t *after, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); #endif #endif /* CBM_DAEMON_RUNTIME_H */ diff --git a/tests/test_cli.c b/tests/test_cli.c index 7cd44244e..e20a881c0 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -14656,19 +14656,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 */ @@ -14676,6 +14688,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); @@ -14686,8 +14711,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); @@ -14700,6 +14725,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 8bfb611bc..49ca1f894 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -4920,6 +4920,137 @@ TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { } #endif +/* The executable-image fingerprint cache short-circuits the full-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. Results are + * gathered first and asserted only after the stub is disarmed: an assertion + * returns early, and an armed stub would replace every later fingerprint in + * this process. */ +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"; + const cbm_daemon_runtime_fingerprint_identity_for_testing_t image = {1, 2, 3, 100, + 200, 300, 400}; + cbm_daemon_runtime_fingerprint_identity_for_testing_t rolled = image; + char miss[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char hit[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char mtime_rolled[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char ctime_rolled[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char inode_rolled[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + /* 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); + bool miss_ok = cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&image, &image, miss); + int hashes_after_miss = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + /* Hit: the same identity returns the cached digest without calling the + * hash, even though the stub would now yield a different value. */ + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(digest_b); + bool hit_ok = cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&image, &image, hit); + int hashes_after_hit = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + /* Key roll: a changed mtime (a rebuilt binary) misses and recomputes. */ + rolled = image; + rolled.mtime_seconds = 101; + bool mtime_ok = + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&rolled, &rolled, mtime_rolled); + int hashes_after_mtime_roll = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + /* A changed change time (an in-place write, a metadata edit) rolls it too. */ + rolled = image; + rolled.ctime_seconds = 301; + bool ctime_ok = + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&rolled, &rolled, ctime_rolled); + int hashes_after_ctime_roll = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + /* A changed inode (atomic replacement) likewise rolls the key. */ + rolled = image; + rolled.inode = 9; + bool inode_ok = + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&rolled, &rolled, inode_rolled); + int hashes_after_inode_roll = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + + ASSERT_TRUE(miss_ok); + ASSERT_STR_EQ(miss, digest_a); + ASSERT_EQ(hashes_after_miss, 1); + ASSERT_TRUE(hit_ok); + ASSERT_STR_EQ(hit, digest_a); + ASSERT_EQ(hashes_after_hit, 1); + ASSERT_TRUE(mtime_ok); + ASSERT_STR_EQ(mtime_rolled, digest_b); + ASSERT_EQ(hashes_after_mtime_roll, 2); + ASSERT_TRUE(ctime_ok); + ASSERT_STR_EQ(ctime_rolled, digest_b); + ASSERT_EQ(hashes_after_ctime_roll, 3); + ASSERT_TRUE(inode_ok); + ASSERT_STR_EQ(inode_rolled, digest_b); + ASSERT_EQ(hashes_after_inode_roll, 4); + PASS(); +} + +/* A digest is committed only when the identity observed after the hash is the + * one it was keyed on. An image that changed under the hash still yields the + * bytes read for that acquisition (the acquire chain's own post-hash stat check + * is what rejects it) but is never cached, so the next sight of the original + * identity hashes again. */ +TEST(daemon_runtime_fingerprint_cache_skips_image_changed_under_hash) { + static const char digest[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + const cbm_daemon_runtime_fingerprint_identity_for_testing_t image = {1, 2, 3, 100, + 200, 300, 400}; + cbm_daemon_runtime_fingerprint_identity_for_testing_t moved = image; + moved.ctime_seconds = 301; + char first[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char second[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(digest); + bool first_ok = cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&image, &moved, first); + int hashes_after_first = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + bool second_ok = + cbm_daemon_runtime_fingerprint_cache_resolve_for_testing(&image, &image, second); + int hashes_after_second = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + + ASSERT_TRUE(first_ok); + ASSERT_STR_EQ(first, digest); + ASSERT_EQ(hashes_after_first, 1); + ASSERT_TRUE(second_ok); + ASSERT_STR_EQ(second, digest); + ASSERT_EQ(hashes_after_second, 2); + PASS(); +} + +/* The real acquire path: fingerprinting this process twice pays the image hash + * once, and the second call is served from the cache inside the same bracketed + * identity checks. This drives cbm_daemon_runtime_process_build_fingerprint, + * not the seam, so a cache wired into the seam but not into the platform's + * acquire site fails here. */ +TEST(daemon_runtime_process_fingerprint_hashes_own_image_once_per_process) { + static const char digest[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + char first[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char second[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + cbm_daemon_runtime_fingerprint_cache_set_hash_stub_for_testing(digest); + bool first_ok = cbm_daemon_runtime_process_build_fingerprint(runtime_test_process_id(), first); + int hashes_after_first = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + bool second_ok = + cbm_daemon_runtime_process_build_fingerprint(runtime_test_process_id(), second); + int hashes_after_second = cbm_daemon_runtime_fingerprint_hash_call_count_for_testing(); + cbm_daemon_runtime_fingerprint_cache_reset_for_testing(); + + ASSERT_TRUE(first_ok); + ASSERT_TRUE(second_ok); + ASSERT_STR_EQ(first, digest); + ASSERT_STR_EQ(second, digest); + ASSERT_EQ(hashes_after_first, 1); + ASSERT_EQ(hashes_after_second, 1); + 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 = @@ -5114,6 +5245,9 @@ 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_fingerprint_cache_skips_image_changed_under_hash); + RUN_TEST(daemon_runtime_process_fingerprint_hashes_own_image_once_per_process); 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);