From fee73ced23e4f0753523bc2157f1fbb853f51fdd Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Fri, 25 Sep 2026 14:09:27 -0500 Subject: [PATCH] test(daemon): cover host loop stop on coordination file loss Add cbm_daemon_host_wait_for_lifetime_for_test, which runs the production lifetime loop over caller-held coordination handles, and a runtime test that ages the claim marker, unlinks the cohort lifetime lock, and asserts the tick refreshes the marker, logs daemon.lifetime_end reason=coordination_file_lost file=cohort_lease, and stops the runtime. The seam runs an ephemeral generation, so a missed loss ends at the initial client window instead of hanging. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NbYKGdU7pqG6AEuMQuJxiF Signed-off-by: Shane McCarron --- src/daemon/host.c | 16 +++++ src/daemon/host_internal.h | 14 ++++ tests/test_daemon_runtime.c | 131 ++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/src/daemon/host.c b/src/daemon/host.c index 3dea464cf..fb44acdd3 100644 --- a/src/daemon/host.c +++ b/src/daemon/host.c @@ -985,6 +985,22 @@ static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, } } +bool cbm_daemon_host_wait_for_lifetime_for_test(cbm_daemon_runtime_service_t *service, + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_version_cohort_lease_t *cohort_lease, + cbm_version_cohort_daemon_claim_t *daemon_claim, + cbm_daemon_ipc_participant_guard_t *guard) { + host_state_t host = {0}; + atomic_int stop_requested = 0; + host_coordination_t coordination = { + .endpoint = endpoint, + .cohort_lease = cohort_lease, + .daemon_claim = daemon_claim, + .participant_guard = guard, + }; + return host_wait_for_lifetime(service, &stop_requested, &host, false, &coordination); +} + static bool host_application_shutdown(host_state_t *host) { if (cbm_daemon_application_shutdown(host->application, HOST_APPLICATION_SHUTDOWN_MS)) { return true; diff --git a/src/daemon/host_internal.h b/src/daemon/host_internal.h index dd8856161..8573c0913 100644 --- a/src/daemon/host_internal.h +++ b/src/daemon/host_internal.h @@ -9,6 +9,10 @@ #include struct cbm_daemon_ipc_endpoint; +struct cbm_daemon_ipc_participant_guard; +struct cbm_daemon_runtime_service; +struct cbm_version_cohort_daemon_claim; +struct cbm_version_cohort_lease; typedef bool (*cbm_daemon_host_cleanup_release_for_test_fn)(void *context); @@ -68,4 +72,14 @@ bool cbm_daemon_host_http_reconcile_free_refusal_for_test( * the failure, the adapter must cancel so the final free succeeds. */ bool cbm_daemon_host_http_thread_create_failure_lifecycle_for_test(void); +/* Run the production lifetime loop over caller-held coordination handles with + * no HTTP host state and no stop request. The generation is ephemeral, so a + * loop that misses a loss still ends at the initial client window instead of + * hanging the test. Returns the loop's own result. */ +bool cbm_daemon_host_wait_for_lifetime_for_test( + struct cbm_daemon_runtime_service *service, const struct cbm_daemon_ipc_endpoint *endpoint, + struct cbm_version_cohort_lease *cohort_lease, + struct cbm_version_cohort_daemon_claim *daemon_claim, + struct cbm_daemon_ipc_participant_guard *guard); + #endif /* CBM_DAEMON_HOST_INTERNAL_H */ diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index 827a8cf34..5ca4c72dd 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -1262,6 +1262,19 @@ static bool runtime_test_fixture_start_failed(const char *tag, const char *stage static bool runtime_test_fixture_permanent = false; +typedef struct { + cbm_version_cohort_manager_t *manager; + cbm_version_cohort_lease_t *lease; + cbm_daemon_ipc_participant_guard_t *guard; + cbm_version_cohort_daemon_claim_t *claim; + bool held; +} runtime_test_coordination_t; + +/* When set, the fixture takes the host's coordination handles in the host's + * order before starting the service: cohort admission refuses once a + * generation is already serving. */ +static runtime_test_coordination_t *runtime_test_fixture_coordination = NULL; + static bool runtime_test_fixture_start_configured( runtime_test_fixture_t *fixture, const char *tag, const cbm_daemon_build_identity_t *identity, uint32_t max_clients, uint64_t lease_timeout_ms, @@ -1317,6 +1330,19 @@ static bool runtime_test_fixture_start_configured( if (application) { config.application = *application; } + runtime_test_coordination_t *coordination = runtime_test_fixture_coordination; + if (coordination) { + cbm_daemon_conflict_t conflict; + coordination->manager = cbm_version_cohort_manager_new(fixture->endpoint); + coordination->held = + coordination->manager && + cbm_version_cohort_acquire(coordination->manager, identity, cbm_now_ms() + 5000, + &coordination->lease, &conflict) == CBM_VERSION_COHORT_OK && + cbm_daemon_ipc_participant_guard_try_join(fixture->endpoint, &coordination->guard) == + 1 && + cbm_version_cohort_daemon_claim_acquire(coordination->manager, &coordination->claim) == + CBM_VERSION_COHORT_OK; + } fixture->service = cbm_daemon_runtime_service_start(&config); if (!fixture->service) { return runtime_test_fixture_start_failed(tag, "service-start", @@ -1370,6 +1396,110 @@ TEST(daemon_runtime_convenience_service_owns_participant_guard) { PASS(); } +#ifndef _WIN32 +/* The runtime's threads log concurrently while the loop stops the service, so + * the sink only sets flags. */ +static atomic_bool runtime_test_coordination_lost_logged; +static atomic_bool runtime_test_window_expired_logged; + +static void runtime_test_lifetime_log_sink(const char *line) { + /* Substrings, not key=value spelling, so text and JSON formats both match. */ + if (line && strstr(line, "daemon.lifetime_end") && strstr(line, "coordination_file_lost") && + strstr(line, "cohort_lease")) { + atomic_store(&runtime_test_coordination_lost_logged, true); + } + if (line && strstr(line, "initial_window_expired")) { + atomic_store(&runtime_test_window_expired_logged, true); + } +} +#endif + +/* #2178: the host lifetime loop must refresh every held coordination file on + * its tick and stop the generation once one is lost. The claim marker is aged + * and the cohort lifetime lock unlinked: the tick must refresh the marker + * through its held handle, name the lease (touched after the claim) as lost, + * and stop the runtime. */ +TEST(daemon_runtime_host_loop_stops_on_coordination_file_loss) { +#ifdef _WIN32 + SKIP_PLATFORM("unlinking a held lock file is POSIX behavior"); +#else + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + identity.cache_fingerprint = RUNTIME_CACHE_A; /* cohort admission requires one */ + runtime_test_fixture_t fixture; + runtime_test_coordination_t coordination = {0}; + runtime_test_fixture_coordination = &coordination; + bool started = runtime_test_fixture_start(&fixture, "coordination-loss", &identity); + runtime_test_fixture_coordination = NULL; + bool held = coordination.held; + + char marker[RUNTIME_TEST_PATH_CAP]; + char lifetime[RUNTIME_TEST_PATH_CAP]; + int marker_written = snprintf(marker, sizeof(marker), "%s/cbm-version-cohort-daemon-v1.lock", + fixture.runtime_dir); + int lifetime_written = snprintf(lifetime, sizeof(lifetime), + "%s/cbm-version-cohort-lifetime-v1.lock", fixture.runtime_dir); + const time_t stale = 1000000000; + struct timespec stale_times[2] = {{.tv_sec = stale}, {.tv_sec = stale}}; + bool staged = held && marker_written > 0 && marker_written < (int)sizeof(marker) && + lifetime_written > 0 && lifetime_written < (int)sizeof(lifetime) && + utimensat(AT_FDCWD, marker, stale_times, 0) == 0 && unlink(lifetime) == 0; + + bool loop_ok = false; + if (staged) { + atomic_store(&runtime_test_coordination_lost_logged, false); + atomic_store(&runtime_test_window_expired_logged, false); + CBMLogLevel previous_log_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_INFO); + cbm_log_set_sink(runtime_test_lifetime_log_sink); + loop_ok = cbm_daemon_host_wait_for_lifetime_for_test( + fixture.service, fixture.endpoint, coordination.lease, coordination.claim, + coordination.guard); + cbm_log_set_sink(NULL); + cbm_log_set_level(previous_log_level); + } + cbm_daemon_runtime_service_state_t state = + started ? cbm_daemon_runtime_service_state(fixture.service) + : CBM_DAEMON_RUNTIME_SERVICE_RUNNING; + struct stat marker_status = {0}; + bool marker_refreshed = staged && stat(marker, &marker_status) == 0 && + marker_status.st_mtime > stale && marker_status.st_atime > stale; + + /* Host teardown order: service first, then guard, claim and lease. Every + * handle references the endpoint, which the fixture frees. */ + if (started && state != CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + (void)cbm_daemon_runtime_service_stop(fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + if (fixture.service && cbm_daemon_runtime_service_free(fixture.service)) { + fixture.service = NULL; + } + (void)cbm_daemon_ipc_participant_guard_release(&coordination.guard); + while (coordination.claim && cbm_version_cohort_daemon_claim_release(&coordination.claim) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + while (coordination.lease && + cbm_version_cohort_lease_release(&coordination.lease) != CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + while (coordination.manager && + cbm_version_cohort_manager_free(&coordination.manager) != CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(held); + ASSERT_TRUE(staged); + ASSERT_TRUE(loop_ok); + ASSERT_EQ(state, CBM_DAEMON_RUNTIME_SERVICE_EXITED); + ASSERT_TRUE(marker_refreshed); + ASSERT_TRUE(atomic_load(&runtime_test_coordination_lost_logged)); + ASSERT_FALSE(atomic_load(&runtime_test_window_expired_logged)); + PASS(); +#endif +} + static bool runtime_test_fixture_start_application(runtime_test_fixture_t *fixture, const char *tag, const cbm_daemon_build_identity_t *identity, runtime_application_context_t *context) { @@ -5106,6 +5236,7 @@ SUITE(daemon_runtime) { RUN_TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path); #endif RUN_TEST(daemon_runtime_convenience_service_owns_participant_guard); + RUN_TEST(daemon_runtime_host_loop_stops_on_coordination_file_loss); RUN_TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent); RUN_TEST(daemon_runtime_exact_hello_issues_connection_bound_identity); RUN_TEST(daemon_runtime_image_rejection_reaches_client_issue1383);