Don't copy/paste code into sync/time test cases (#3151)

* add blocked_waiter_wakeup() to time/sync primitives. rework test-cases to use vs copy/paste
fixel bazel compile flags. add PICO_PLATFORM_TEST_HEADER for test injections
* pass timed_out variable to blocked_waiter_wakeup
* add missing bazel srcs
diff --git a/src/common/pico_sync/mutex.c b/src/common/pico_sync/mutex.c
index 5e0f4c2..b2c5a84 100644
--- a/src/common/pico_sync/mutex.c
+++ b/src/common/pico_sync/mutex.c
@@ -66,7 +66,6 @@
         return;
     }
 #endif
-    // note: if you change the implementation here, please update the similar code in pico_sync_test.c
     lock_owner_id_t caller = lock_get_caller_owner_id();
     do {
         uint32_t save = spin_lock_blocking(mtx->core.spin_lock);
@@ -76,6 +75,7 @@
             break;
         }
         lock_internal_spin_unlock_with_wait(&mtx->core, save);
+        blocked_waiter_wakeup(false);
     } while (true);
 }
 
@@ -92,6 +92,7 @@
         } else {
             lock_internal_spin_unlock_with_wait(&mtx->core, save);
         }
+        blocked_waiter_wakeup(false);
     } while (true);
 }
 
@@ -164,7 +165,6 @@
         return recursive_mutex_enter_block_until(mtx, until);
     }
 #endif
-    // note: if you change the implementation here, please update the similar code in pico_sync_test.c
     assert(mtx->core.spin_lock);
     lock_owner_id_t caller = lock_get_caller_owner_id();
     do {
@@ -175,17 +175,17 @@
             return true;
         } else {
             if (lock_internal_spin_unlock_with_best_effort_wait_or_timeout(&mtx->core, save, until)) {
-                // timed out
+                blocked_waiter_wakeup(true);
                 return false;
             }
             // not timed out; spin lock already unlocked, so loop again
         }
+        blocked_waiter_wakeup(false);
     } while (true);
 }
 
 bool __time_critical_func(recursive_mutex_enter_block_until)(recursive_mutex_t *mtx, absolute_time_t until) {
     assert(mtx->core.spin_lock);
-    // note: if you change the implementation here, please update the similar code in pico_sync_test.c
     lock_owner_id_t caller = lock_get_caller_owner_id();
     do {
         uint32_t save = spin_lock_blocking(mtx->core.spin_lock);
@@ -197,11 +197,12 @@
             return true;
         } else {
             if (lock_internal_spin_unlock_with_best_effort_wait_or_timeout(&mtx->core, save, until)) {
-                // timed out
+                blocked_waiter_wakeup(true);
                 return false;
             }
             // not timed out; spin lock already unlocked, so loop again
         }
+        blocked_waiter_wakeup(false);
     } while (true);
 }
 
diff --git a/src/common/pico_sync/sem.c b/src/common/pico_sync/sem.c
index ee180f8..33dd58b 100644
--- a/src/common/pico_sync/sem.c
+++ b/src/common/pico_sync/sem.c
@@ -24,7 +24,6 @@
 }
 
 void __time_critical_func(sem_acquire_blocking)(semaphore_t *sem) {
-    // note: if you change the implementation here, please update the similar code in pico_sync_test.c
     // only a caller which waited can have consumed a notification, so only it can owe one to
     // the waiters it did not exclude - taking one of several permits leaves the others able to
     // proceed. Taking a permit on the first pass consumes no notification, so owes nothing.
@@ -38,6 +37,7 @@
         }
         lock_internal_spin_unlock_with_wait(&sem->core, save);
         waited = true;
+        blocked_waiter_wakeup(false);
     } while (true);
 }
 
@@ -50,7 +50,6 @@
 }
 
 bool __time_critical_func(sem_acquire_block_until)(semaphore_t *sem, absolute_time_t until) {
-    // note: if you change the implementation here, please update the similar code in pico_sync_test.c
     bool __unused waited = false;
     do {
         uint32_t save = spin_lock_blocking(sem->core.spin_lock);
@@ -60,9 +59,11 @@
             return true;
         }
         if (lock_internal_spin_unlock_with_best_effort_wait_or_timeout(&sem->core, save, until)) {
+            blocked_waiter_wakeup(true);
             return false;
         }
         waited = true;
+        blocked_waiter_wakeup(false);
     } while (true);
 }
 
diff --git a/src/common/pico_time/time.c b/src/common/pico_time/time.c
index 72f0bde..692a863 100644
--- a/src/common/pico_time/time.c
+++ b/src/common/pico_time/time.c
@@ -484,6 +484,7 @@
                 // sleep_until_callback() which also does a __sev() - the irq itself will wake us up if on the same core
                 __wfe();
 #endif
+                blocked_waiter_wakeup(false);
             }
         }
     }
diff --git a/src/common/pico_util/queue.c b/src/common/pico_util/queue.c
index 3307c7e..ba74d17 100644
--- a/src/common/pico_util/queue.c
+++ b/src/common/pico_util/queue.c
@@ -53,6 +53,7 @@
         }
         if (block) {
             lock_internal_spin_unlock_with_wait(&q->core, save);
+            blocked_waiter_wakeup(false);
         } else {
             spin_unlock(q->core.spin_lock, save);
             return false;
@@ -73,6 +74,7 @@
         }
         if (block) {
             lock_internal_spin_unlock_with_wait(&q->core, save);
+            blocked_waiter_wakeup(false);
         } else {
             spin_unlock(q->core.spin_lock, save);
             return false;
@@ -92,6 +94,7 @@
         }
         if (block) {
             lock_internal_spin_unlock_with_wait(&q->core, save);
+            blocked_waiter_wakeup(false);
         } else {
             spin_unlock(q->core.spin_lock, save);
             return false;
diff --git a/src/host/pico_platform/include/pico/platform.h b/src/host/pico_platform/include/pico/platform.h
index a4ed96c..1f54ddd 100644
--- a/src/host/pico_platform/include/pico/platform.h
+++ b/src/host/pico_platform/include/pico/platform.h
@@ -163,6 +163,8 @@
 
 void busy_wait_at_least_cycles(uint32_t minimum_cycles);
 
+static inline void blocked_waiter_wakeup(__unused bool timed_out) {}
+
 // PICO_CONFIG: PICO_NUM_VTABLE_IRQS, Number of IRQ handlers in the vector table - can be lowered to save space if you aren't using some higher IRQs, type=int, default=NUM_IRQS, group=hardware_irq
 #ifndef PICO_NUM_VTABLE_IRQS
 #define PICO_NUM_VTABLE_IRQS NUM_IRQS
diff --git a/src/rp2_common/pico_platform_common/include/pico/platform/common.h b/src/rp2_common/pico_platform_common/include/pico/platform/common.h
index c4645d6..45ba560 100644
--- a/src/rp2_common/pico_platform_common/include/pico/platform/common.h
+++ b/src/rp2_common/pico_platform_common/include/pico/platform/common.h
@@ -18,6 +18,11 @@
  * but making an explicit library dependency does not make sense.
  */
 
+// PICO_CONFIG: PICO_PLATFORM_TEST_HEADER, Unquoted path to a header to include here so a test can inject code/defines into every translation unit, group=pico_platform
+#ifdef PICO_PLATFORM_TEST_HEADER
+#include __PICO_XSTRING(PICO_PLATFORM_TEST_HEADER)
+#endif
+
 // PICO_CONFIG: PICO_MINIMAL_STORED_VECTOR_TABLE, Only store a very minimal vector table in the binary on Arm, type=bool, default=0, advanced=true, group=pico_crt0
 #ifndef PICO_MINIMAL_STORED_VECTOR_TABLE
 #define PICO_MINIMAL_STORED_VECTOR_TABLE 0
@@ -68,10 +73,23 @@
  *  \ingroup pico_platform
  *
  * No-op function intended to be called by any tight hardware polling loop. Using this ubiquitously
- * makes it much easier to find tight loops, but also in the future \#ifdef-ed support for lockup
- * debugging might be added
+ * makes it much easier to find tight loops, and may be #ifdef-ed for debugging
  */
+#ifndef tight_loop_contents
 static __force_inline void tight_loop_contents(void) {}
+#endif
+
+/*! \brief No-op function to record that a blocked waiter has woken up
+ *  \ingroup pico_platform
+ *
+ * No-op function intended to be called by any primitive which has just completed a wait for a condition to be
+ * satisfied - including a wait that ended in a timeout rather than the condition. This is distinct from
+ * \ref tight_loop_contents in that this operation follows some sort of actual wait (e.g. `__wfe()`) vs
+ * polling. This is mostly intended to be overridden for debugging or test cases
+ */
+#ifndef blocked_waiter_wakeup
+static __force_inline void blocked_waiter_wakeup(__unused bool timed_out) {}
+#endif
 
 #define host_safe_hw_ptr(x) ((uintptr_t)(x))
 #define native_safe_hw_ptr(x) host_safe_hw_ptr(x)
diff --git a/test/pico_sync_test/BUILD.bazel b/test/pico_sync_test/BUILD.bazel
index 9bc6734..cdab1df 100644
--- a/test/pico_sync_test/BUILD.bazel
+++ b/test/pico_sync_test/BUILD.bazel
@@ -1,15 +1,17 @@
 load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
 load("//bazel:defs.bzl", "compatible_with_rp2")
+load("//bazel/util:transition.bzl", "extra_copts_for_all_deps")
 
 package(default_visibility = ["//visibility:public"])
 
 cc_binary(
-    name = "pico_sync_test",
+    name = "pico_sync_test_actual",
     testonly = True,
-    srcs = ["pico_sync_test.c"],
-    defines = [
-        "PICO_USE_SW_SPIN_LOCKS=0",
+    srcs = [
+        "pico_sync_test.c",
+        "pico_sync_test_hook.h",
     ],
+    tags = ["manual"],  # Built via pico_sync_test / pico_sync_test_sw.
     target_compatible_with = compatible_with_rp2(),
     deps = [
         "//src/common/pico_sync",
@@ -21,20 +23,18 @@
     }),
 )
 
-cc_binary(
+extra_copts_for_all_deps(
+    name = "pico_sync_test",
+    testonly = True,
+    src = ":pico_sync_test_actual",
+    extra_copts = ["-DPICO_USE_SW_SPIN_LOCKS=0"],
+    target_compatible_with = compatible_with_rp2(),
+)
+
+extra_copts_for_all_deps(
     name = "pico_sync_test_sw",
     testonly = True,
-    srcs = ["pico_sync_test.c"],
-    defines = [
-        "PICO_USE_SW_SPIN_LOCKS=1",
-    ],
+    src = ":pico_sync_test_actual",
+    extra_copts = ["-DPICO_USE_SW_SPIN_LOCKS=1"],
     target_compatible_with = ["//bazel/constraint:rp2350"],
-    deps = [
-        "//src/common/pico_sync",
-        "//src/rp2_common/pico_multicore",
-        "//test/pico_test",
-    ] + select({
-        "//bazel/constraint:host": ["//src/host/pico_stdlib"],
-        "//conditions:default": ["//src/rp2_common/pico_stdlib"],
-    }),
 )
diff --git a/test/pico_sync_test/CMakeLists.txt b/test/pico_sync_test/CMakeLists.txt
index ea2056d..5ae5fcb 100644
--- a/test/pico_sync_test/CMakeLists.txt
+++ b/test/pico_sync_test/CMakeLists.txt
@@ -1,11 +1,16 @@
 add_executable(pico_sync_test pico_sync_test.c)
 target_compile_definitions(pico_sync_test PRIVATE PICO_USE_SW_SPIN_LOCKS=0)
+target_include_directories(pico_sync_test PRIVATE ${CMAKE_CURRENT_LIST_DIR})
+target_compile_definitions(pico_sync_test PRIVATE
+        PICO_PLATFORM_TEST_HEADER=pico_sync_test_hook.h)
 target_link_libraries(pico_sync_test PRIVATE pico_test pico_sync pico_multicore)
 pico_add_extra_outputs(pico_sync_test)
 
 if (NOT PICO_RP2040)
     add_executable(pico_sync_test_sw pico_sync_test.c)
     target_compile_definitions(pico_sync_test_sw PRIVATE PICO_USE_SW_SPIN_LOCKS=1)
+    target_include_directories(pico_sync_test_sw PRIVATE ${CMAKE_CURRENT_LIST_DIR})
+    target_compile_definitions(pico_sync_test_sw PRIVATE PICO_PLATFORM_TEST_HEADER=pico_sync_test_hook.h)
     target_link_libraries(pico_sync_test_sw PRIVATE pico_test pico_sync pico_multicore)
     pico_add_extra_outputs(pico_sync_test_sw)
 endif()
diff --git a/test/pico_sync_test/pico_sync_test.c b/test/pico_sync_test/pico_sync_test.c
index cc50dac..f53223e 100644
--- a/test/pico_sync_test/pico_sync_test.c
+++ b/test/pico_sync_test/pico_sync_test.c
@@ -18,22 +18,17 @@
 
 PICOTEST_MODULE_NAME("SYNC", "sync test");
 
-typedef struct {
-    lock_core_t lock;
-    bool flag;
-} lock_with_flag_t;;
+// Counts wakeups from the wait loops inside pico_sync and pico_time, via the blocked_waiter_wakeup hook
+volatile int wakeups[NUM_CORES];
 
-int64_t notify_lock_with_flag(__unused alarm_id_t id, void *user_data) {
-    lock_with_flag_t *lock_with_flag = (lock_with_flag_t *)user_data;
-    uint32_t save = spin_lock_blocking(lock_with_flag->lock.spin_lock);
-    lock_with_flag->flag = true;
-    lock_internal_spin_unlock_with_notify(&lock_with_flag->lock, save);
-    return 0;
+void pico_sync_test_wakeup(__unused bool timed_out) {
+    wakeups[get_core_num()]++;
 }
 
-static int64_t sleep_until_callback(__unused alarm_id_t id, __unused void *user_data) {
-    // note: this implementation is a copy of the code from pico_time/time.c and should be kept in sync
-    __sev();
+static semaphore_t release_sem;
+
+static int64_t release_sem_callback(__unused alarm_id_t id, __unused void *user_data) {
+    sem_release(&release_sem);
     return 0;
 }
 
@@ -43,64 +38,43 @@
     printf("=== Test on core %d ===\n", get_core_num());
 
     PICOTEST_START_SECTION("check low power lock_core wait loop with timeout");
-    // note: this implementation is implemented in a similar functions to mutex_enter_block_until(),
-    //       sem_acquire_block_until() etc. and should be updated if they are
-    lock_core_t lock;
-    lock_init(&lock, 0);
+    // A semaphore with no permits, so the acquire waits out the whole timeout
+    semaphore_t empty_sem;
+    sem_init(&empty_sem, 0, 1);
     absolute_time_t until = make_timeout_time_ms(50);
-    int wait_count = 0;
-    do {
-        uint32_t save = spin_lock_blocking(lock.spin_lock);
-        wait_count++;
-        if (lock_internal_spin_unlock_with_best_effort_wait_or_timeout(&lock, save, until)) break;
-    } while (true);
+    wakeups[get_core_num()] = 0;
+    bool acquired = sem_acquire_block_until(&empty_sem, until);
+    int wait_count = wakeups[get_core_num()];
     printf("Waited %d times\n", wait_count);
-
+    PICOTEST_CHECK(!acquired, "Expected the acquire to time out");
+    PICOTEST_CHECK(time_reached(until), "Expected to have reached the timeout");
+    // a count of zero means the wait never happened, or blocked_waiter_wakeup is not reaching us
+    PICOTEST_CHECK(wait_count > 0, "Expected at least one wait");
     PICOTEST_CHECK(wait_count <= MAX_WAITS, "Expected <= %d waits", MAX_WAITS);
     PICOTEST_END_SECTION();
 
     PICOTEST_START_SECTION("check low power lock_core wait loop without timeout");
-    // note: this implementation is implemented in a similar functions to mutex_enter_blocking(),
-    //       sem_acquire_blocking() etc. and should be updated if they are
-    lock_with_flag_t lock_with_flag;
-    lock_init(&lock_with_flag.lock, 0);
-    lock_with_flag.flag = false;
-    add_alarm_in_ms(50, notify_lock_with_flag, &lock_with_flag, false);
-    __wfe(); // consume outstanding one from adding alarm
-    int wait_count = 0;
-    do {
-        uint32_t save = spin_lock_blocking(lock_with_flag.lock.spin_lock);
-        if (lock_with_flag.flag) {
-            spin_unlock(lock_with_flag.lock.spin_lock, save);
-            break;
-        }
-        wait_count++;
-        lock_internal_spin_unlock_with_wait(&lock_with_flag.lock, save);
-    } while (true);
+    // Nothing to acquire until the alarm releases a permit, so the acquire has to wait
+    sem_init(&release_sem, 0, 1);
+    add_alarm_in_ms(50, release_sem_callback, NULL, false);
+    wakeups[get_core_num()] = 0;
+    sem_acquire_blocking(&release_sem);
+    int wait_count = wakeups[get_core_num()];
     printf("Waited %d times\n", wait_count);
+    // a count of zero means the wait never happened, or blocked_waiter_wakeup is not reaching us
+    PICOTEST_CHECK(wait_count > 0, "Expected at least one wait");
     PICOTEST_CHECK(wait_count <= MAX_WAITS, "Expected <= %d waits", MAX_WAITS);
     PICOTEST_END_SECTION();
 
     PICOTEST_START_SECTION("check low power sleep loop");
-    // note: this sleep implementation is a copy of the code from pico_time/time.c and should be kept in sync
-    int wait_count = 0;
-    absolute_time_t t = make_timeout_time_ms(500);
-    uint64_t t_us = to_us_since_boot(t);
-    uint64_t t_before_us = t_us - PICO_TIME_SLEEP_OVERHEAD_ADJUST_US;
-    // needs to work in the first PICO_TIME_SLEEP_OVERHEAD_ADJUST_US of boot
-    if (t_before_us > t_us) t_before_us = 0;
-    absolute_time_t t_before;
-    update_us_since_boot(&t_before, t_before_us);
-    if (absolute_time_diff_us(get_absolute_time(), t_before) > 0) {
-        if (add_alarm_at(t_before, sleep_until_callback, NULL, false) >= 0) {
-            // able to add alarm for just before the time
-            while (!time_reached(t_before)) {
-                __wfe();
-                wait_count++;
-            }
-        }
-    }
+    absolute_time_t sleep_target = make_timeout_time_ms(500);
+    wakeups[get_core_num()] = 0;
+    sleep_until(sleep_target);
+    int wait_count = wakeups[get_core_num()];
     printf("Waited %d times\n", wait_count);
+    PICOTEST_CHECK(time_reached(sleep_target), "Expected to have slept until the target");
+    // a count of zero means the wait never happened, or blocked_waiter_wakeup is not reaching us
+    PICOTEST_CHECK(wait_count > 0, "Expected at least one wait");
     PICOTEST_CHECK(wait_count <= MAX_WAITS, "Expected <= %d waits", MAX_WAITS);
     PICOTEST_END_SECTION();
 
diff --git a/test/pico_sync_test/pico_sync_test_hook.h b/test/pico_sync_test/pico_sync_test_hook.h
new file mode 100644
index 0000000..659a731
--- /dev/null
+++ b/test/pico_sync_test/pico_sync_test_hook.h
@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2026 Raspberry Pi (Trading) Ltd.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+
+// Injected into every translation unit of this test via PICO_PLATFORM_TEST_HEADER, so that the
+// wait loops inside pico_sync and pico_time report each wakeup to the test.
+
+#ifndef _PICO_SYNC_TEST_HOOK_H
+#define _PICO_SYNC_TEST_HOOK_H
+
+#ifndef __ASSEMBLER__
+extern void pico_sync_test_wakeup(bool);
+#endif
+
+#define blocked_waiter_wakeup pico_sync_test_wakeup
+
+#endif
diff --git a/test/sync_interop_test/BUILD.bazel b/test/sync_interop_test/BUILD.bazel
index af557d7..58b1fdc 100644
--- a/test/sync_interop_test/BUILD.bazel
+++ b/test/sync_interop_test/BUILD.bazel
@@ -1,26 +1,23 @@
 load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
 load("//bazel:defs.bzl", "compatible_with_rp2")
+load("//bazel/util:transition.bzl", "extra_copts_for_all_deps")
 
 package(default_visibility = ["//visibility:public"])
 
 # Only the baseline (no RTOS) variants are built here. The FreeRTOS variants need a kernel
 # that the Bazel build does not provide, and are CMake-only - see CMakeLists.txt.
 cc_binary(
-    name = "sync_interop_test",
+    name = "sync_interop_test_actual",
     testonly = True,
     srcs = [
         "interop_harness.c",
         "interop_harness.h",
         "interop_platform.c",
         "interop_platform.h",
+        "interop_test_hook.h",
         "sync_interop_test.c",
     ],
-    defines = [
-        "PICO_USE_SW_SPIN_LOCKS=0",
-        # printf is instrumentation here, not the subject: the two disciplines are modelled
-        # with the suite's own mutexes so no case depends on PICO_STDOUT_MUTEX.
-        "PICO_STDOUT_MUTEX=0",
-    ],
+    tags = ["manual"],  # Built via sync_interop_test / sync_interop_test_sw.
     target_compatible_with = compatible_with_rp2(),
     deps = [
         "//src/common/pico_sync",
@@ -31,29 +28,28 @@
     ],
 )
 
-cc_binary(
+extra_copts_for_all_deps(
+    name = "sync_interop_test",
+    testonly = True,
+    src = ":sync_interop_test_actual",
+    extra_copts = [
+        "-DPICO_USE_SW_SPIN_LOCKS=0",
+        # printf is instrumentation here, not the subject: the two disciplines are modelled
+        # with the suite's own mutexes so no case depends on PICO_STDOUT_MUTEX.
+        "-DPICO_STDOUT_MUTEX=0",
+    ],
+    target_compatible_with = compatible_with_rp2(),
+)
+
+extra_copts_for_all_deps(
     name = "sync_interop_test_sw",
     testonly = True,
-    srcs = [
-        "interop_harness.c",
-        "interop_harness.h",
-        "interop_platform.c",
-        "interop_platform.h",
-        "sync_interop_test.c",
+    src = ":sync_interop_test_actual",
+    extra_copts = [
+        "-DPICO_USE_SW_SPIN_LOCKS=1",
+        "-DPICO_STDOUT_MUTEX=0",
     ],
-    defines = [
-        "PICO_USE_SW_SPIN_LOCKS=1",
-        "PICO_STDOUT_MUTEX=0",
-    ],
-    # software spin locks need Armv8-M or RISC-V atomics, so there is no RP2040 variant
     target_compatible_with = ["//bazel/constraint:rp2350"],
-    deps = [
-        "//src/common/pico_sync",
-        "//src/common/pico_time",
-        "//src/rp2_common/hardware_clocks",
-        "//src/rp2_common/pico_multicore",
-        "//src/rp2_common/pico_stdlib",
-    ],
 )
 
 # Used only by the CMake FreeRTOS variants; listed so the file is accounted for.
diff --git a/test/sync_interop_test/CMakeLists.txt b/test/sync_interop_test/CMakeLists.txt
index 561142f..abfee6f 100644
--- a/test/sync_interop_test/CMakeLists.txt
+++ b/test/sync_interop_test/CMakeLists.txt
@@ -16,6 +16,7 @@
     # with our own mutexes so that no case depends on the value of PICO_STDOUT_MUTEX.
     target_compile_definitions(${NAME} PRIVATE
             PICO_STDOUT_MUTEX=0
+            PICO_PLATFORM_TEST_HEADER=interop_test_hook.h
             ${ARGN}
             )
     # D1.7/D2.5 do a blocking lock_core wait from ISR context. That is legal bare (and so
diff --git a/test/sync_interop_test/interop_platform.c b/test/sync_interop_test/interop_platform.c
index 110f942..8111ffc 100644
--- a/test/sync_interop_test/interop_platform.c
+++ b/test/sync_interop_test/interop_platform.c
@@ -35,28 +35,31 @@
  * distinguish the two. A wait that really blocks takes 1-5 iterations; a busy-poll runs to
  * thousands.
  *
- * NOTE: keep these in step with mutex.c, as pico_sync_test.c already has to.
  */
 static uint32_t inline_wait_sleep_delta;
 uint32_t last_inline_wait_sleep_delta(void) { return inline_wait_sleep_delta; }
 
-uint32_t counted_mutex_enter_blocking(mutex_t *mtx) {
-    uint32_t waits = 0;
+static uint32_t interop_wakeups[NUM_CORES];
+static uint32_t interop_sleep_mark[NUM_CORES];
+
+void interop_wait_wakeup(__unused bool timed_out) {
+    uint core = get_core_num();
+    uint32_t sc = harness_sleep_counter();
+    inline_wait_sleep_delta += harness_sleep_delta(interop_sleep_mark[core], sc);
+    interop_sleep_mark[core] = sc;
+    interop_wakeups[core]++;
+}
+
+static void interop_count_reset(void) {
     inline_wait_sleep_delta = 0;
-    lock_owner_id_t caller = lock_get_caller_owner_id();
-    do {
-        uint32_t save = spin_lock_blocking(mtx->core.spin_lock);
-        if (!lock_is_owner_id_valid(mtx->owner)) {
-            mtx->owner = caller;
-            spin_unlock(mtx->core.spin_lock, save);
-            break;
-        }
-        waits++;
-        uint32_t sc0 = harness_sleep_counter();
-        lock_internal_spin_unlock_with_wait(&mtx->core, save);
-        inline_wait_sleep_delta += harness_sleep_delta(sc0, harness_sleep_counter());
-    } while (true);
-    return waits;
+    interop_sleep_mark[get_core_num()] = harness_sleep_counter();
+    interop_wakeups[get_core_num()] = 0;
+}
+
+uint32_t counted_mutex_enter_blocking(mutex_t *mtx) {
+    interop_count_reset();
+    mutex_enter_blocking(mtx);
+    return interop_wakeups[get_core_num()];
 }
 
 uint32_t bare_mutex_enter_blocking(mutex_t *mtx) {
@@ -71,7 +74,7 @@
             break;
         }
         waits++;
-        /* deliberately NOT lock_internal_spin_unlock_with_wait() */
+        /* deliberately NOT lock_internal_spin_unlock_with_wait(), so this one stays a copy */
         uint32_t sc0 = harness_sleep_counter();
         spin_unlock(mtx->core.spin_lock, save);
         __wfe();
@@ -82,22 +85,9 @@
 
 uint32_t counted_mutex_enter_block_until(mutex_t *mtx, absolute_time_t until,
                                                 bool *acquired) {
-    uint32_t waits = 0;
-    lock_owner_id_t caller = lock_get_caller_owner_id();
-    *acquired = false;
-    do {
-        uint32_t save = spin_lock_blocking(mtx->core.spin_lock);
-        if (!lock_is_owner_id_valid(mtx->owner)) {
-            mtx->owner = caller;
-            spin_unlock(mtx->core.spin_lock, save);
-            *acquired = true;
-            return waits;
-        }
-        waits++;
-        if (lock_internal_spin_unlock_with_best_effort_wait_or_timeout(&mtx->core, save, until)) {
-            return waits;   /* timed out */
-        }
-    } while (true);
+    interop_count_reset();
+    *acquired = mutex_enter_block_until(mtx, until);
+    return interop_wakeups[get_core_num()];
 }
 
 #if INTEROP_HAS_SDK_CORE
diff --git a/test/sync_interop_test/interop_platform.h b/test/sync_interop_test/interop_platform.h
index b264c88..89db3f3 100644
--- a/test/sync_interop_test/interop_platform.h
+++ b/test/sync_interop_test/interop_platform.h
@@ -86,12 +86,13 @@
  */
 void plat_hold_for_ms(uint32_t ms);
 
-/* ---- counted copies of the SDK primitives ------------------------------------------
- * Faithful copies of mutex_enter_blocking() / mutex_enter_block_until() with the wait
- * iterations counted. Counting is the only reliable way to tell sleeping from spinning:
- * a timeout that fires exactly on time looks the same either way. Available locally as
- * well as via the agent, so the driving core's waits can be judged too.
- * NOTE: keep in step with src/common/pico_sync/mutex.c.
+/* ---- counted calls of the SDK primitives -------------------------------------------
+ * mutex_enter_blocking() / mutex_enter_block_until() with the wait iterations counted, via
+ * the blocked_waiter_wakeup hook those primitives call. Counting is the only reliable way to tell
+ * sleeping from spinning: a timeout that fires exactly on time looks the same either way.
+ * Available locally as well as via the agent, so the driving core's waits can be judged too.
+ * Not reentrant, and it counts every contended wait in the program, so nothing else may wait
+ * while one of these is in progress.
  */
 uint32_t counted_mutex_enter_blocking(mutex_t *mtx);
 uint32_t counted_mutex_enter_block_until(mutex_t *mtx, absolute_time_t until, bool *acquired);
diff --git a/test/sync_interop_test/interop_test_hook.h b/test/sync_interop_test/interop_test_hook.h
new file mode 100644
index 0000000..8a7f933
--- /dev/null
+++ b/test/sync_interop_test/interop_test_hook.h
@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2026 Raspberry Pi (Trading) Ltd.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+
+// Injected into every translation unit via PICO_PLATFORM_TEST_HEADER so that the wait loops
+// inside pico_sync and pico_time can be recorded
+
+#ifndef _INTEROP_TEST_HOOK_H
+#define _INTEROP_TEST_HOOK_H
+
+#ifndef __ASSEMBLER__
+extern void interop_wait_wakeup(bool);
+#endif
+
+#define blocked_waiter_wakeup interop_wait_wakeup
+
+#endif