diff --git a/src/daemon/host.c b/src/daemon/host.c index 3e509e207..856af3ee4 100644 --- a/src/daemon/host.c +++ b/src/daemon/host.c @@ -52,6 +52,9 @@ enum { HOST_HTTP_RETRY_INITIAL_MS = 1000, HOST_HTTP_RETRY_MAX_MS = 30000, HOST_WATCH_INTERVAL_MS = 5000, + /* Age-based temp cleaners need days of inactivity (macOS tmp_cleaner: 3); + * a minute bounds the blind window after a long sleep (#2178). */ + HOST_COORDINATION_TOUCH_MS = 60000, HOST_CONFLICT_LOG_CAP = 1024 * 1024, HOST_OPERATION_LOG_CAP = 5 * 1024 * 1024, HOST_PATH_CAP = 4096, @@ -868,10 +871,42 @@ static bool host_background_start(host_state_t *host) { return true; } +/* Coordination handles the host retains for the generation's whole lifetime. */ +typedef struct { + 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 *participant_guard; +} host_coordination_t; + +/* Refresh every long-held runtime file and name the first one found lost, or + * return NULL. A held lock whose path now names another inode coordinates + * nothing: forked index workers read the daemon as uncoordinated and a peer + * can start a second generation (#2178). */ +static const char *host_coordination_touch(const host_coordination_t *coordination, + cbm_daemon_runtime_service_t *service) { + if (!cbm_version_cohort_daemon_claim_touch(coordination->daemon_claim)) { + return "cohort_daemon_claim"; + } + if (!cbm_version_cohort_lease_touch(coordination->cohort_lease)) { + return "cohort_lease"; + } + if (!cbm_daemon_ipc_participant_guard_touch(coordination->endpoint, + coordination->participant_guard)) { + return "participant_guard"; + } + if (!cbm_daemon_runtime_service_touch_listener(service)) { + return "listener"; + } + return NULL; +} + static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, - atomic_int *stop_requested, host_state_t *host, bool permanent) { + atomic_int *stop_requested, host_state_t *host, bool permanent, + const host_coordination_t *coordination) { uint64_t initial_deadline = cbm_now_ms() + HOST_INITIAL_CLIENT_TIMEOUT_MS; uint64_t stopping_deadline = 0; + uint64_t next_touch = 0; for (;;) { cbm_daemon_runtime_service_state_t state = cbm_daemon_runtime_service_state(service); if (state == CBM_DAEMON_RUNTIME_SERVICE_EXITED) { @@ -903,6 +938,17 @@ static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, stop ? "stop_requested" : "initial_window_expired"); return cbm_daemon_runtime_service_stop(service, HOST_RUNTIME_SHUTDOWN_MS); } + /* Permanent generations too: exiting lets the next client start a + * coordinated generation, while re-claiming would race that client. */ + if (cbm_now_ms() >= next_touch) { + next_touch = cbm_now_ms() + HOST_COORDINATION_TOUCH_MS; + const char *lost = host_coordination_touch(coordination, service); + if (lost) { + cbm_log_warn("daemon.lifetime_end", "reason", "coordination_file_lost", "file", + lost); + return cbm_daemon_runtime_service_stop(service, HOST_RUNTIME_SHUTDOWN_MS); + } + } host_http_reconcile_at(host, cbm_now_ms(), false); /* Retire an ephemeral generation that lingered for cold-storm cohort * participants once they drain, or once its bounded linger elapses. @@ -1123,7 +1169,14 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { : "unavailable", "memory_budget_bytes", memory_budget, "physical_job_limit", physical_job_limit, "worker_memory_budget_bytes", worker_memory_budget); - if (!host_wait_for_lifetime(service, config->stop_requested, &host, config->permanent)) { + host_coordination_t coordination = { + .endpoint = config->endpoint, + .cohort_lease = cohort_lease, + .daemon_claim = daemon_claim, + .participant_guard = participant_guard, + }; + if (!host_wait_for_lifetime(service, config->stop_requested, &host, config->permanent, + &coordination)) { host_force_terminate("runtime"); } diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index a92befb08..53da43542 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -3220,6 +3220,46 @@ void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener) { free(listener); } +/* Touch through the held descriptor only after proving the path still names + * it: refreshing by path would keep a replacement inode fresh and hide the + * loss (#2178). */ +static bool posix_held_file_touch(int directory_fd, const char *base_name, int fd) { + return private_regular_file_snapshot(directory_fd, base_name, fd, 1, NULL) && + futimens(fd, NULL) == 0; +} + +static bool posix_held_lock_touch(int directory_fd, const process_lock_entry_t *entry, int fd) { + return entry && posix_held_file_touch(directory_fd, entry->lock_name, fd); +} + +bool cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener) { + if (!listener || listener->dir_fd < 0 || listener->owner_pid != getpid() || + !listener->lifetime_reservation) { + return false; + } + bool lifetime = + posix_held_lock_touch(listener->dir_fd, listener->lifetime_reservation->process_entry, + listener->lifetime_reservation->fd); + const cbm_daemon_ipc_participant_guard_t *guard = listener->participant_guard; + bool participant = + !guard || + posix_held_lock_touch(listener->dir_fd, guard->legacy_process_entry, guard->legacy_fd); + /* The marker is not held open; reopen it and require the published inode. */ + int marker_fd = openat(listener->dir_fd, listener->socket_identity_name, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + struct stat marker_status; + bool marker = marker_fd >= 0 && + private_regular_file_snapshot(listener->dir_fd, listener->socket_identity_name, + marker_fd, 1, &marker_status) && + marker_status.st_dev == listener->identity_device && + marker_status.st_ino == listener->identity_inode && + futimens(marker_fd, NULL) == 0; + if (marker_fd >= 0) { + (void)close(marker_fd); + } + return lifetime && participant && marker; +} + int cbm_daemon_ipc_accept(cbm_daemon_ipc_listener_t *listener, uint32_t timeout_ms, cbm_daemon_ipc_connection_t **connection_out) { if (connection_out) { @@ -3603,6 +3643,12 @@ bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t return true; } +bool cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t *guard) { + return endpoint && guard && guard->owner_pid == getpid() && + posix_held_lock_touch(endpoint->dir_fd, guard->legacy_process_entry, guard->legacy_fd); +} + int cbm_daemon_ipc_local_transition_try_acquire( const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_local_transition_t **transition_out) { if (transition_out) { @@ -6746,6 +6792,17 @@ bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t return true; } +/* Windows has no age-based cleaner of the private runtime directory, and its + * held handles deny deletion; the #2178 heartbeat has nothing to refresh. */ +bool cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener) { + return listener != NULL; +} + +bool cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t *guard) { + return endpoint && guard; +} + int cbm_daemon_ipc_local_transition_try_acquire( const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_local_transition_t **transition_out) { if (transition_out) { diff --git a/src/daemon/ipc.h b/src/daemon/ipc.h index 545ecb357..ed214ea79 100644 --- a/src/daemon/ipc.h +++ b/src/daemon/ipc.h @@ -76,6 +76,15 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( cbm_daemon_ipc_lifetime_reservation_t **reservation_io); void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener); +/* Heartbeat for the daemon's long-lived runtime artifacts: refresh the held + * lifetime reservation, the listener's participant guard (if it owns one) and + * the published identity marker so an age-based temp cleaner never deletes + * them (#2178). Returns false once any of them no longer names the file this + * listener owns: a lost lifetime file lets a peer start a second generation, + * and a lost identity marker makes listener close leave the socket pair + * behind. Only the owning thread may call it while the listener is open. */ +bool cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener); + /* Create or validate one private current-user directory at an already * canonical local path. Ancestors are handle-validated without mutation: * POSIX permits only root/current-user owners and no group/other write (apart @@ -270,6 +279,10 @@ bool cbm_daemon_ipc_startup_lock_release(cbm_daemon_ipc_startup_lock_t **lock_io int cbm_daemon_ipc_participant_guard_try_join(const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_participant_guard_t **guard_out); bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t **guard_io); +/* Same heartbeat contract as cbm_daemon_ipc_listener_touch, for a guard the + * caller retains itself. */ +bool cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t *guard); /* Standalone CLI work joins the legacy-compatible current participant group * without becoming a daemon client. Acquisition retains startup-v2 only for diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index efa41a020..7fb7d32a9 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -2578,6 +2578,10 @@ size_t cbm_daemon_runtime_service_active_connections(cbm_daemon_runtime_service_ return count; } +bool cbm_daemon_runtime_service_touch_listener(cbm_daemon_runtime_service_t *service) { + return service && service->listener && cbm_daemon_ipc_listener_touch(service->listener); +} + void cbm_daemon_runtime_service_reconcile_lifetime(cbm_daemon_runtime_service_t *service) { if (!service) { return; diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index 7a31dab61..304d7c898 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -336,6 +336,10 @@ size_t cbm_daemon_runtime_service_active_connections(cbm_daemon_runtime_service_ * an unbounded idle hang. The host lifetime loop calls this every tick; it is a * no-op unless a last-committed-client linger is armed. */ void cbm_daemon_runtime_service_reconcile_lifetime(cbm_daemon_runtime_service_t *service); +/* #2178 heartbeat over the listener's runtime artifacts; see + * cbm_daemon_ipc_listener_touch. Call only from the thread that stops and + * frees the service, which is the only thread that closes the listener. */ +bool cbm_daemon_runtime_service_touch_listener(cbm_daemon_runtime_service_t *service); size_t cbm_daemon_runtime_service_job_subscribers(cbm_daemon_runtime_service_t *service, const char *project_key); uint64_t cbm_daemon_runtime_service_client_process_id(cbm_daemon_runtime_service_t *service, diff --git a/src/daemon/version_cohort.c b/src/daemon/version_cohort.c index 3742a1330..82cc63b32 100644 --- a/src/daemon/version_cohort.c +++ b/src/daemon/version_cohort.c @@ -363,6 +363,25 @@ cbm_private_file_lock_status_t cbm_version_cohort_lease_release( return result; } +static bool version_cohort_lock_touch(cbm_private_file_lock_t *lock) { + return !lock || cbm_private_file_lock_touch(lock) == CBM_PRIVATE_FILE_LOCK_OK; +} + +bool cbm_version_cohort_lease_touch(cbm_version_cohort_lease_t *lease) { + if (!lease) { + return false; + } + /* Evaluate all three so one lost file does not leave the others to age. */ + bool lifetime = version_cohort_lock_touch(lease->lifetime); + bool admission = version_cohort_lock_touch(lease->admission); + bool maintenance = version_cohort_lock_touch(lease->maintenance); + return lifetime && admission && maintenance; +} + +bool cbm_version_cohort_daemon_claim_touch(cbm_version_cohort_daemon_claim_t *claim) { + return claim && claim->marker && version_cohort_lock_touch(claim->marker); +} + static cbm_version_cohort_status_t version_cohort_failed(cbm_version_cohort_lease_t *lease, cbm_version_cohort_status_t status, cbm_version_cohort_lease_t **lease_out) { diff --git a/src/daemon/version_cohort.h b/src/daemon/version_cohort.h index 51304cc95..ac79c8b86 100644 --- a/src/daemon/version_cohort.h +++ b/src/daemon/version_cohort.h @@ -145,6 +145,15 @@ cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence_under_tr cbm_private_file_lock_status_t cbm_version_cohort_lease_release( cbm_version_cohort_lease_t **lease_io); +/* Heartbeat for a long-lived holder: refresh every lock file the lease or + * claim holds and report false once any held file was unlinked or replaced. + * An age-based temp cleaner deleting a held file silently ends coordination, + * because the next participant creates and locks a fresh inode (#2178). The + * only safe response to false is an orderly exit; re-claiming would race + * that participant. */ +bool cbm_version_cohort_lease_touch(cbm_version_cohort_lease_t *lease); +bool cbm_version_cohort_daemon_claim_touch(cbm_version_cohort_daemon_claim_t *claim); + /* Refuses teardown while a lease or retryable cleanup handle remains. */ cbm_private_file_lock_status_t cbm_version_cohort_manager_free( cbm_version_cohort_manager_t **manager_io); diff --git a/src/foundation/private_file_lock.c b/src/foundation/private_file_lock.c index 80df0d17c..a7d83172e 100644 --- a/src/foundation/private_file_lock.c +++ b/src/foundation/private_file_lock.c @@ -482,6 +482,24 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f return valid ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; } +cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock) { + if (!lock) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + /* An unlinked file keeps its inode alive for this handle with st_nlink 0, + * which the payload validity check already rejects. */ + bool linked = private_payload_fd_valid(lock, NULL); + bool touched = linked && futimens(lock->fd, NULL) == 0; + cbm_private_file_lock_fork_guard_leave(); + if (!linked) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + return touched ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; +} + cbm_private_file_lock_status_t cbm_private_file_lock_release(cbm_private_file_lock_t **lock_io) { if (!lock_io || !*lock_io) { return CBM_PRIVATE_FILE_LOCK_IO; @@ -1450,6 +1468,20 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f return valid ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; } +cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock) { + /* Windows has no age-based cleaner of the private lock directory; only + * confirm the held handle still names a linked file. */ + if (!lock) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + bool linked = private_win_payload_handle_valid(lock); + cbm_private_file_lock_fork_guard_leave(); + return linked ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_UNSAFE; +} + static bool private_win_release_unlock(cbm_private_file_lock_t *lock) { lock->test_unlock_attempts++; if (lock->test_fail_unlock_once) { diff --git a/src/foundation/private_file_lock_internal.h b/src/foundation/private_file_lock_internal.h index 8801d9b1a..b8ec34ac7 100644 --- a/src/foundation/private_file_lock_internal.h +++ b/src/foundation/private_file_lock_internal.h @@ -61,6 +61,13 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f const void *buffer, size_t length); +/* Long-lived holders call this periodically. It refreshes the held file's + * timestamps through the validated handle so age-based temp cleaners (macOS + * tmp_cleaner, systemd-tmpfiles) never select it, and returns UNSAFE once the + * handle no longer names a linked file: a peer opening the path would then + * lock a different inode and the lock coordinates nothing (#2178). */ +cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock); + /* Forces the next successfully acquired native lock down the post-lock * validation cleanup path and injects pre-call release failures there. */ bool cbm_private_lock_directory_fail_post_acquire_cleanup_for_test( diff --git a/tests/test_daemon_ipc.c b/tests/test_daemon_ipc.c index b451f9bb3..d29a1e6c0 100644 --- a/tests/test_daemon_ipc.c +++ b/tests/test_daemon_ipc.c @@ -1004,8 +1004,8 @@ TEST(daemon_ipc_windows_sid_trust_accepts_local_admin_rejects_foreign_500) { (void)CreateWellKnownSid(WinBuiltinAdministratorsSid, NULL, NULL, &builtin_needed); if (builtin_needed > 0) { builtin_admins = malloc(builtin_needed); - if (builtin_admins && - !CreateWellKnownSid(WinBuiltinAdministratorsSid, NULL, builtin_admins, &builtin_needed)) { + if (builtin_admins && !CreateWellKnownSid(WinBuiltinAdministratorsSid, NULL, builtin_admins, + &builtin_needed)) { free(builtin_admins); builtin_admins = NULL; } @@ -2063,6 +2063,54 @@ TEST(daemon_ipc_lifetime_reservation_survives_saturated_second_listen) { PASS(); } +/* #2178: an age-based temp cleaner unlinked the live daemon's lifetime lock + * and identity marker. The heartbeat must report either loss, so the daemon + * exits instead of serving under a lifetime file that coordinates nothing or + * leaving an unremovable socket pair behind at close. */ +TEST(daemon_ipc_listener_touch_detects_lost_runtime_files) { +#ifdef _WIN32 + SKIP_PLATFORM("unlink-while-held of runtime files is POSIX behavior"); +#else + static const char key[] = "2178a2178a2178a0"; + static const char *const lost_files[] = {"lifetime.lock", "sock.identity"}; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + bool started[2] = {false, false}; + bool touched_intact[2] = {false, false}; + bool unlinked[2] = {false, false}; + bool touched_after_loss[2] = {true, true}; + + if (ipc_test_parent_new(parent, "listener-touch")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + for (size_t i = 0; endpoint && i < 2; i++) { + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint); + started[i] = listener != NULL; + touched_intact[i] = started[i] && cbm_daemon_ipc_listener_touch(listener); + char path[TEST_PATH_CAP]; + int written = snprintf(path, sizeof(path), "%s/cbm-%s.%s", runtime_dir, key, lost_files[i]); + unlinked[i] = + touched_intact[i] && written > 0 && written < (int)sizeof(path) && unlink(path) == 0; + touched_after_loss[i] = unlinked[i] && cbm_daemon_ipc_listener_touch(listener); + cbm_daemon_ipc_listener_close(listener); + } + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + for (size_t i = 0; i < 2; i++) { + ASSERT_TRUE(started[i]); + ASSERT_TRUE(touched_intact[i]); + ASSERT_TRUE(unlinked[i]); + ASSERT_FALSE(touched_after_loss[i]); + } + PASS(); +#endif +} + TEST(daemon_ipc_lifetime_reservation_transfers_without_unlock_window) { static const char key[] = "1029384756abcdef"; char parent[TEST_PATH_CAP] = {0}; @@ -5260,8 +5308,7 @@ TEST(daemon_ipc_posix_single_uid_userns_real_smoke_issue1830) { if (WEXITSTATUS(status) != 0 && WEXITSTATUS(status) != 1) { char unexpected[128]; (void)snprintf(unexpected, sizeof(unexpected), - "userns probe exited %d -- not a security verdict", - WEXITSTATUS(status)); + "userns probe exited %d -- not a security verdict", WEXITSTATUS(status)); FAIL(unexpected); } ASSERT_EQ(0, WEXITSTATUS(status)); @@ -5376,6 +5423,7 @@ SUITE(daemon_ipc) { RUN_TEST(daemon_ipc_rejects_uppercase_instance_key); RUN_TEST(daemon_ipc_no_spawn_probe_distinguishes_absent_active_and_busy); RUN_TEST(daemon_ipc_lifetime_reservation_survives_saturated_second_listen); + RUN_TEST(daemon_ipc_listener_touch_detects_lost_runtime_files); RUN_TEST(daemon_ipc_lifetime_reservation_transfers_without_unlock_window); RUN_TEST(daemon_ipc_local_frame_roundtrip); RUN_TEST(daemon_ipc_bounded_receive_rejects_oversize_before_payload); diff --git a/tests/test_private_file_lock.c b/tests/test_private_file_lock.c index 2760bf55b..b795f9176 100644 --- a/tests/test_private_file_lock.c +++ b/tests/test_private_file_lock.c @@ -166,7 +166,8 @@ static void private_lock_fixture_finish(private_lock_fixture_t *fixture) { "lock-attempt-cleanup.lock", "acl-directory.lock", "acl-file.lock", - "fork.lock"}; + "fork.lock", + "touch.lock"}; if (fixture->root[0]) { for (size_t index = 0; index < sizeof(files) / sizeof(files[0]); index++) { if (!private_lock_path(path, fixture, files[index])) { @@ -480,6 +481,65 @@ TEST(private_file_lock_consumed_close_error_never_retries_recycled_fd) { #endif } +/* #2178: macOS tmp_cleaner unlinks /tmp files whose atime, mtime and ctime are + * all older than three days, including empty lock files a daemon holds for its + * whole life. The next participant then creates and locks a fresh inode, so + * the old lock coordinates nothing. Touch must keep a held file young and + * report the loss once the path stops naming it. */ +TEST(private_file_lock_touch_refreshes_held_file_and_detects_unlink) { +#ifdef _WIN32 + SKIP_PLATFORM("age-based temp cleaning and unlink-while-held are POSIX behaviors"); +#else + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + char path[PRIVATE_LOCK_TEST_PATH_CAP]; + bool path_ok = started && private_lock_path(path, &fixture, "touch.lock"); + cbm_private_file_lock_t *held = NULL; + cbm_private_file_lock_t *successor = NULL; + cbm_private_file_lock_status_t acquired = + path_ok ? cbm_private_file_lock_try_acquire(fixture.directory, "touch.lock", + CBM_PRIVATE_FILE_LOCK_EX, &held) + : CBM_PRIVATE_FILE_LOCK_IO; + const time_t stale = 1000000000; + struct timespec stale_times[2] = {{.tv_sec = stale}, {.tv_sec = stale}}; + bool aged = acquired == CBM_PRIVATE_FILE_LOCK_OK && + futimens(cbm_private_file_lock_native_fd_for_test(held), stale_times) == 0; + cbm_private_file_lock_status_t linked_touch = + aged ? cbm_private_file_lock_touch(held) : CBM_PRIVATE_FILE_LOCK_IO; + struct stat refreshed = {0}; + bool refreshed_ok = linked_touch == CBM_PRIVATE_FILE_LOCK_OK && stat(path, &refreshed) == 0; + + bool unlinked = refreshed_ok && unlink(path) == 0; + cbm_private_file_lock_status_t unlinked_touch = + unlinked ? cbm_private_file_lock_touch(held) : CBM_PRIVATE_FILE_LOCK_IO; + /* The hazard itself: with the path gone, a peer's EX succeeds while the + * original holder still believes it is exclusive. */ + cbm_private_file_lock_status_t successor_status = + unlinked ? cbm_private_file_lock_try_acquire(fixture.directory, "touch.lock", + CBM_PRIVATE_FILE_LOCK_EX, &successor) + : CBM_PRIVATE_FILE_LOCK_IO; + if (successor) { + (void)cbm_private_file_lock_release(&successor); + } + if (held) { + (void)cbm_private_file_lock_release(&held); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(aged); + ASSERT_EQ(linked_touch, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(refreshed_ok); + ASSERT_TRUE(refreshed.st_atime > stale); + ASSERT_TRUE(refreshed.st_mtime > stale); + ASSERT_TRUE(unlinked); + ASSERT_EQ(unlinked_touch, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_EQ(successor_status, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + TEST(private_file_lock_post_acquire_failure_returns_cleanup_owner) { private_lock_fixture_t fixture; bool started = private_lock_fixture_start(&fixture); @@ -863,6 +923,7 @@ SUITE(private_file_lock) { RUN_TEST(private_file_lock_unlock_failure_retains_retryable_lock); RUN_TEST(private_file_lock_close_failure_retries_without_duplicate_unlock); RUN_TEST(private_file_lock_consumed_close_error_never_retries_recycled_fd); + RUN_TEST(private_file_lock_touch_refreshes_held_file_and_detects_unlink); RUN_TEST(private_file_lock_post_acquire_failure_returns_cleanup_owner); RUN_TEST(private_file_lock_windows_lock_attempt_failure_returns_cleanup_owner); RUN_TEST(private_file_lock_rejects_unsafe_entries_and_replaced_root);