@@ -265,6 +265,137 @@ TEST(version_cohort_rejects_exact_build_with_different_cache_root) {
265265 PASS ();
266266}
267267
268+ typedef struct {
269+ cbm_version_cohort_manager_t * manager ;
270+ cbm_daemon_build_identity_t identity ;
271+ uint64_t deadline_ms ;
272+ cbm_version_cohort_status_t status ;
273+ cbm_version_cohort_lease_t * lease ;
274+ cbm_daemon_conflict_t conflict ;
275+ atomic_bool finished ;
276+ } version_cohort_acquire_wait_t ;
277+
278+ static void * version_cohort_acquire_wait_thread (void * context ) {
279+ version_cohort_acquire_wait_t * wait = context ;
280+ wait -> status = cbm_version_cohort_acquire (wait -> manager , & wait -> identity , wait -> deadline_ms ,
281+ & wait -> lease , & wait -> conflict );
282+ atomic_store_explicit (& wait -> finished , true, memory_order_release );
283+ return NULL ;
284+ }
285+
286+ static bool version_cohort_wait_for_retries_above (uint64_t baseline , uint64_t deadline_ms ) {
287+ while (cbm_version_cohort_conflict_retries_for_testing () <= baseline &&
288+ cbm_now_ms () < deadline_ms ) {
289+ cbm_usleep (1000 );
290+ }
291+ return cbm_version_cohort_conflict_retries_for_testing () > baseline ;
292+ }
293+
294+ /* #2046: a mismatched holder that never leaves is still a conflict — but the
295+ * caller's deadline is the budget for waiting it out, so the refusal may only
296+ * arrive once that budget is spent, and must carry the same detail as before.
297+ * The lower bound is the contract under test, not a timing guess: the holder
298+ * is released only after the call returns. */
299+ TEST (version_cohort_conflict_is_retried_until_the_deadline ) {
300+ version_cohort_fixture_t fixture ;
301+ ASSERT_TRUE (version_cohort_fixture_start (& fixture , "conflict-deadline" ));
302+ cbm_version_cohort_manager_t * first = cbm_version_cohort_manager_new (fixture .endpoint );
303+ cbm_version_cohort_manager_t * second = cbm_version_cohort_manager_new (fixture .endpoint );
304+ ASSERT_NOT_NULL (first );
305+ ASSERT_NOT_NULL (second );
306+
307+ cbm_daemon_build_identity_t active = version_cohort_identity ("2.4.0" , VERSION_COHORT_BUILD_A );
308+ cbm_daemon_build_identity_t requested = active ;
309+ active .cache_fingerprint = VERSION_COHORT_CACHE_A ;
310+ requested .cache_fingerprint = VERSION_COHORT_CACHE_B ;
311+ cbm_version_cohort_lease_t * active_lease = NULL ;
312+ cbm_version_cohort_lease_t * requested_lease = NULL ;
313+ cbm_daemon_conflict_t conflict ;
314+ ASSERT_EQ (cbm_version_cohort_acquire (first , & active , UINT64_MAX , & active_lease , & conflict ),
315+ CBM_VERSION_COHORT_OK );
316+
317+ uint64_t retries_before = cbm_version_cohort_conflict_retries_for_testing ();
318+ uint64_t deadline = cbm_now_ms () + 300U ;
319+ cbm_version_cohort_status_t status =
320+ cbm_version_cohort_acquire (second , & requested , deadline , & requested_lease , & conflict );
321+ uint64_t returned_at = cbm_now_ms ();
322+
323+ ASSERT_EQ (status , CBM_VERSION_COHORT_CONFLICT );
324+ ASSERT_NULL (requested_lease );
325+ ASSERT_TRUE (returned_at >= deadline );
326+ ASSERT_TRUE (cbm_version_cohort_conflict_retries_for_testing () > retries_before );
327+ ASSERT_EQ (conflict .status , CBM_DAEMON_HELLO_CACHE_CONFLICT );
328+ ASSERT_STR_EQ (conflict .active_cache_fingerprint , VERSION_COHORT_CACHE_A );
329+ ASSERT_STR_EQ (conflict .requested_cache_fingerprint , VERSION_COHORT_CACHE_B );
330+
331+ /* An indefinite deadline never waits on a conflicting holder. */
332+ retries_before = cbm_version_cohort_conflict_retries_for_testing ();
333+ ASSERT_EQ (cbm_version_cohort_acquire (second , & requested , UINT64_MAX , & requested_lease ,
334+ & conflict ),
335+ CBM_VERSION_COHORT_CONFLICT );
336+ ASSERT_NULL (requested_lease );
337+ ASSERT_EQ (cbm_version_cohort_conflict_retries_for_testing (), retries_before );
338+
339+ version_cohort_release (& active_lease );
340+ version_cohort_manager_close (& second );
341+ version_cohort_manager_close (& first );
342+ version_cohort_fixture_finish (& fixture );
343+ PASS ();
344+ }
345+
346+ /* #2046: the draining-daemon handoff. The waiter is admitted once the
347+ * mismatched holder leaves — and the test releases that holder only after the
348+ * seam proves the waiter already met it, so "admitted" cannot mean "arrived
349+ * late". The waiter's deadline is a backstop against a hang, never the
350+ * decider: the asserted state is OK-with-lease after the release. */
351+ TEST (version_cohort_conflict_waiter_is_admitted_when_the_holder_leaves ) {
352+ version_cohort_fixture_t fixture ;
353+ ASSERT_TRUE (version_cohort_fixture_start (& fixture , "conflict-handoff" ));
354+ cbm_version_cohort_manager_t * first = cbm_version_cohort_manager_new (fixture .endpoint );
355+ cbm_version_cohort_manager_t * second = cbm_version_cohort_manager_new (fixture .endpoint );
356+ ASSERT_NOT_NULL (first );
357+ ASSERT_NOT_NULL (second );
358+
359+ cbm_daemon_build_identity_t active = version_cohort_identity ("2.4.0" , VERSION_COHORT_BUILD_A );
360+ active .cache_fingerprint = VERSION_COHORT_CACHE_A ;
361+ cbm_version_cohort_lease_t * active_lease = NULL ;
362+ cbm_daemon_conflict_t conflict ;
363+ ASSERT_EQ (cbm_version_cohort_acquire (first , & active , UINT64_MAX , & active_lease , & conflict ),
364+ CBM_VERSION_COHORT_OK );
365+
366+ version_cohort_acquire_wait_t wait ;
367+ memset (& wait , 0 , sizeof (wait ));
368+ wait .manager = second ;
369+ wait .identity = active ;
370+ wait .identity .cache_fingerprint = VERSION_COHORT_CACHE_B ;
371+ wait .deadline_ms = cbm_now_ms () + 10000U ;
372+ wait .status = CBM_VERSION_COHORT_IO ;
373+ atomic_init (& wait .finished , false);
374+
375+ uint64_t retries_before = cbm_version_cohort_conflict_retries_for_testing ();
376+ cbm_thread_t thread ;
377+ bool started = cbm_thread_create (& thread , 0 , version_cohort_acquire_wait_thread , & wait ) == 0 ;
378+ bool met_holder =
379+ started && version_cohort_wait_for_retries_above (retries_before , cbm_now_ms () + 5000U );
380+ bool still_waiting = started && !atomic_load_explicit (& wait .finished , memory_order_acquire );
381+ version_cohort_release (& active_lease );
382+ bool finished = started && version_cohort_wait_for_atomic (& wait .finished , cbm_now_ms () + 5000U );
383+ bool joined = started && cbm_thread_join (& thread ) == 0 ;
384+
385+ version_cohort_release (& wait .lease );
386+ version_cohort_manager_close (& second );
387+ version_cohort_manager_close (& first );
388+ version_cohort_fixture_finish (& fixture );
389+
390+ ASSERT_TRUE (started );
391+ ASSERT_TRUE (met_holder );
392+ ASSERT_TRUE (still_waiting );
393+ ASSERT_TRUE (finished );
394+ ASSERT_TRUE (joined );
395+ ASSERT_EQ (wait .status , CBM_VERSION_COHORT_OK );
396+ PASS ();
397+ }
398+
268399TEST (version_cohort_exclusive_activation_blocks_and_is_blocked_by_participants ) {
269400 version_cohort_fixture_t fixture ;
270401 ASSERT_TRUE (version_cohort_fixture_start (& fixture , "activation" ));
@@ -896,6 +1027,8 @@ SUITE(version_cohort) {
8961027 RUN_TEST (version_cohort_rejects_same_hash_with_different_abi );
8971028 RUN_TEST (version_cohort_rejects_missing_cache_fingerprint );
8981029 RUN_TEST (version_cohort_rejects_exact_build_with_different_cache_root );
1030+ RUN_TEST (version_cohort_conflict_is_retried_until_the_deadline );
1031+ RUN_TEST (version_cohort_conflict_waiter_is_admitted_when_the_holder_leaves );
8991032 RUN_TEST (version_cohort_exclusive_activation_blocks_and_is_blocked_by_participants );
9001033 RUN_TEST (version_cohort_mutation_intent_fails_new_admission_and_spans_lease );
9011034 RUN_TEST (version_cohort_mutation_waits_for_every_lifetime_participant );
0 commit comments