Status
Accepted
Context
RBC_GP (the reference application built on this framework) guards a netlink handle shared between its own cyclic-executive thread and a background relay-rx task with a mutex (ctx->peer_send_mutex, used by channel_ab_checkpoint.c's own checkpoint send/recv adapter and channel_ab_io.c's own peer-sample/relay-envelope/site-state sends). Found live, during an architecture review of every "backend"-named symbol the application uses: that mutex was declared as a raw pthread_mutex_t and manipulated with pthread_mutex_init()/_lock()/ _unlock()/_destroy() directly, with <pthread.h> included straight into an application header (channel_ab_types.h), not confined to a POSIX-backend implementation file.
This violates this project's own foundational premise (ADR-001/ADR-005): an application built on Platform_RTE should depend only on this framework's own OS-Abstraction-Layer API, never on a specific platform's threading primitives directly - the whole point of the backend-dispatch pattern every other OAL service (rte_timer, rte_task, rte_ipc, rte_netlink, ...) already follows is that swapping the platform/RTOS underneath an application should mean swapping one backend registration call, not auditing every application source file for platform-specific API usage. A pthread_mutex_t baked into an application-level struct defeats that guarantee outright: porting this application to a target with no pthreads (a bare-metal RTOS, for instance) would require rewriting application code, not just registering a different backend.
Checked before deciding on a fix: does Platform_RTE already expose any* portable synchronization primitive under a different name (a critical-section API bundled into rte_task, for instance)? It does not - the OAL surface (clocksync/ipc/log/memory/netlink/nvm/ reboot/task/timer) has no mutex/lock/semaphore module at all. So this is not "the application should have called an existing framework
function instead" - the framework itself was missing the primitive, the same gap-class ADR-031 (rte_mem_util, for <string.h>) and ADR-026/032 (rte_lifecycle, for setup-phase gating) already closed this project's history.
Decision
Add rte_mutex as a new OAL-layer service, following the exact same consumer/backend split every other OAL service uses (ADR-021):
- include/rte/mutex/rte_mutex.h - consumer API: rte_mutex_create()/_lock()/_unlock()/_destroy(), an opaque rte_mutex_handle_t bound to caller-owned RTE_DECLARE_STORAGE storage (128 bytes - sizeof(pthread_mutex_t) is 64 bytes on the POSIX backend's own target platforms, generous headroom for a small wrapper struct around it). Non-recursive by design (REQ-OAL-MUTEX-003)
- matches pthread_mutex_t's own default behavior on Linux/macOS, the only backend this framework ships today; a caller needing recursive locking must track that itself, same as it always would have.
- include/rte_osadapter/mutex/rte_osadapter_mutex.h - the backend vtable (rte_osadapter_mutex_t) and rte_osadapter_mutex_register(), gated by rte_lifecycle_check_setup_allowed() (ADR-026) exactly like every other rte_*_register_backend() call.
- src/mutex/rte_mutex.c - pure dispatch to the registered backend, byte-for-byte the same shape as src/timer/rte_timer.c.
- New RTE_ENABLE_MUTEX CMake option (default ON), no dependency edges on or from any other feature - a leaf OAL module, same as Timer Service/Non-Volatile Memory/Static Memory Reservation/Task/Thread Scheduling.
- rte_mutex_create() is setup-phase-gated (a mutex is created once at init, same posture as rte_timer_create()/rte_task_create()); _lock()/_unlock()/_destroy() are not, since those are genuinely runtime/shutdown operations.
The POSIX backend implementation (rte_posix_osadapter_mutex.c, a thin wrapper over pthread_mutex_init()/_lock()/_unlock()/_destroy()) lives in RBC_GP's own src/posix_osadapter/, not in Platform_RTE - matching every other OAL backend's location per ADR-018's own "a backend is integrator-supplied, not part of the
reusable framework" philosophy. This is exactly where pthread_mutex_t usage belongs: confined to the one file whose entire job is adapting a specific platform to this framework's portable API, never leaking into application code above it.
RBC_GP's own channel_ab_types.h/channel_ab.c/ channel_ab_checkpoint.c/channel_ab_io.c were migrated to rte_mutex_handle_t + rte_mutex_storage_t, dropping <pthread.h> from application code entirely (the one remaining pthread_join() use inside rte_task_destroy()'s own doc-comment cross-reference is a framework-internal detail the application only reads about, not calls directly).
Consequences
- Every application built on this framework now has a portable mutex available without reaching for platform threading primitives directly.
- The POSIX backend's own rte_posix_osadapter_register_all() gained one more call (rte_osadapter_mutex_register(rte_posix_osadapter_mutex())); any other backend integrator adding a new target must do the same.
- channel_ab_negotiate.c's own direct select()/read() on STDIN_FILENO (the interactive ASK_USER transfer-policy prompt) was reviewed in the same pass and found to be a second, separate instance of the same class of violation - deliberately left out of this ADR's scope; it has no framework equivalent to migrate to yet (no portable console/stdin abstraction exists) and was called out to the user as an explicitly separate, not-yet-scoped follow-up.
- Timed lock (2026-10-03, OSA rule R2 "every wait bounded", REQ-OAL-MUTEX-014): rte_mutex_lock_timed(handle, timeout_ms) and a fifth vtable member lock_timed, appended last so an older four-entry positional initialiser stays valid C with the slot NULL (the call then returns RTE_STATUS_NOT_SUPPORTED). RTE_STATUS_OK means the lock is held; RTE_STATUS_TIMEOUT means it was not acquired within timeout_ms and is not held; timeout_ms == 0 is one non-blocking try (the rte_ipc rule); any other platform failure, including a failed clock read, is RTE_STATUS_INTERNAL_ERROR and never an unbounded wait. Not lifecycle-gated, like rte_mutex_lock(). The POSIX OSAdapter uses pthread_mutex_timedlock() on Linux, whose deadline is absolute on CLOCK_REALTIME: a wall-clock step during a wait shortens or lengthens that one wait (pthread_mutex_clocklock(CLOCK_MONOTONIC) would avoid this, but glibc accepts it on a priority-inheritance mutex only with FUTEX_LOCK_PI2 support). Other POSIX hosts (macOS) poll with pthread_mutex_trylock() against a CLOCK_MONOTONIC deadline, without a priority-inheritance boost while polling - development hosts only. rte_mutex_lock() stays as it is; moving callers (first the POSIX memory-pool lock) to the timed lock is a later step.
Verification
- Platform_RTE: full clean rebuild + ctest (matching the existing suite's own pass/fail baseline - no test added specifically for rte_mutex beyond compilation, matching rte_timer's own precedent of relying on downstream consumers, not a dedicated unit test, since the backend dispatch logic is identical in shape to every other already-tested OAL service).
- RBC_GP: full clean rebuild + ctest, smoke.sh, and the real Docker-based safeAPITestEnv Robot Framework suite, after migrating off pthread_mutex_t - confirmed no behavior change (the POSIX mutex backend's own locking semantics are identical to what the application called directly before, just routed through the OAL dispatch layer).
- Timed lock (2026-10-03): tests/mutex/test_rte_mutex.c (dispatch with a fake OSAdapter: NULL handle, no OSAdapter, NULL slot, timeout and result passed through unchanged); the real POSIX timed lock against a second thread holding the mutex in RBC_GP's tests/posix_osadapter/test_rte_posix_osadapter_mutex.c (macOS and Linux).
Location