Platform & RMW implementation notes
Implementation-level detail relocated out of CLAUDE.md so the auto-loaded instruction file
stays a thin router. This is reference material (how the current impl behaves), not design
rationale — for the why, see the RFCs under docs/design/. Pitfall one-liners
that agents need hot stay indexed in CLAUDE.md; the detail is here.
Build-profile constraints (phase-336)
Two platforms cannot use whatever profile the build resolved. Both are named
constants in packages/tooling/nros-cargo-profile (NUTTX_RUST_PROFILE,
FREERTOS_QEMU_PROFILE, reachable from shell as nros dev profile carve-out <name>) so the builder and every fixture resolver read one value — when they
were separate literals, a test looked for a binary in a directory the builder
never wrote to (#156).
- NuttX Rust images require
lto = "fat". Atlto = "off"a non-deterministicarmv7a-nuttx-eabihfcross-CGU miscompile corrupts the stdlang_startmain-closure fat pointer: the image reboots beforemainwith no console output. Never root-caused (phase-177.8.c; phase-285 W5 rode the same dodge for nuttx-riscv). - FreeRTOS QEMU (Cortex-M3) images require real optimization. Not a
miscompile — a timing floor.
qemu-system-armemulating an M3 is slow enough that a lightly-optimized zenoh-pico misses its session handshake window, and the image boots but never connects. Issue #126 is the C-side face of the same constraint (fixture rows pinCMAKE_BUILD_TYPE=Release).
Both resolve to nros-minsizerel, which carries the settings [profile.release]
had before phase-336 split size from speed — so these images are unchanged by
that phase.
Spin / yield
zpico_spin_once event-driven wake:
- POSIX/Zephyr:
_z_condvar_wait_untilong_spin_cv. - FreeRTOS:
xSemaphoreTake(g_spin_sem, …). - NuttX:
sem_timedwait(&g_spin_sem_posix, …)(pthread condvar hangs — archived Phase 55.12). - Bare-metal: single-thread
zp_readloop.
Cooperative yield (PlatformYield): POSIX/NuttX sched_yield(), Zephyr k_yield(), FreeRTOS
vPortYield(), ThreadX tx_thread_relinquish(), bare-metal core::hint::spin_loop() default,
opt-in cortex_m::asm::wfi() via BoardIdle. RTOS yields are not ISR-safe; spin_loop() is.
Multi-threaded zpico_spin_once pitfall (critical): on multi-threaded platforms
(Zephyr, POSIX) use z_sleep_ms(), not select(). select() returns immediately on any zenoh
protocol traffic (keep-alives, interest msgs), burning a Promise::wait() budget of 500
spin_once(10) iterations in ~39 ms instead of 5000 ms. FreeRTOS uses vTaskDelay(), smoltcp a
clock loop, single-threaded select()+zp_read() — all correct. Zephyr native_sim also needs
CONFIG_NATIVE_SIM_SLOWDOWN_TO_REAL_TIME=y for service/action clients.
smoltcp multicast (bare-metal)
Interface::join_multicast_group(addr)needs a multicast addr; smoltcp 0.12 returnsUnaddressablefor0.0.0.0. Pass the GROUP (239.255.0.1).set_recv_timeout(_, 0)indefine_smoltcp_platform!= non-blocking poll.- LAN9118 emulator filter rejects multicast unless
MAC_CR.MCPAS; promiscuous (PRMS) recommended for QEMU-nic socket,…. MAX_UDP_SOCKETSdefault 4. RTPS needs 3/participant; zenoh/xrce 0..=1.
NetX Duo BSD (ThreadX)
SO_RCVTIMEOtakesstruct nx_bsd_timeval *, NOTINTms. Wrong type →wait_option = NX_WAIT_FOREVER→ deadlock. Usenros-platform-threadx::set_recv_timeout_ms.fcntl(F_SETFL, O_NONBLOCK)works (togglesNX_BSD_SOCKET_ENABLE_OPTION_NON_BLOCKING).- NSOS-NetX shim translates
SO_RCVTIMEOfor threadx-linux. Accepts INT-ms andnx_bsd_timeval.
Board transport features
ethernet (default MPS2-AN385/STM32F4/ESP32-QEMU) or wifi (ESP32) → TCP/UDP via
zpico-smoltcp; serial → UART via zpico-serial (bare-metal) or zenoh-pico built-in (ESP32,
Zephyr). Config fields are #[cfg(feature)]-gated. ≥1 transport required (compile_error!).
Transports coexist (locator selects). ESP32/ESP32-QEMU use zenoh-pico’s serial (no zpico-serial).
Parameter services
param-services feature in nros-node → ~/get_parameters, ~/set_parameters, etc. Uses
nros-rcl-interfaces. Handlers return Box<Response>.
XRCE embedded build
nros-rmw-xrce-cffi (C FFI shim) gates UCLIENT_PROFILE_{UDP,TCP,SERIAL} +
UCLIENT_PLATFORM_POSIX + transport_posix_{udp,serial}.c on target_os = linux|macos|*bsd.
Bare-metal (target_os = "none") gets only UCLIENT_PROFILE_{DISCOVERY,CUSTOM_TRANSPORT, STREAM_FRAMING} and must inject its own custom transport. just check workspace-embedded
excludes nros-rmw-xrce{,-cffi,-cffi-staticlib} (header-only backend’s internal.h references
UDP types unconditionally; the staticlib sibling needs panic_handler resolution at compile time).
The -staticlib sibling lets Corrosion import a real staticlib target without forcing the cffi
rlib to emit one.
XRCE-DDS RMW bugs (critical): uxr_buffer_request_data must be flushed with
uxr_run_session_time immediately after the call — unflushed request_data in the reliable output
stream causes intermittent timeouts when a later service request is buffered (pre-301 call_raw; fixed in all
three create_* methods). Reliable streams need STREAM_HISTORY >= 2 (we use 4); history=1 fails
to recycle the single slot. The service client (requester) needs uxr_buffer_request_data to
receive replies, same as subscribers and repliers.
Probe-only opaque sizes
EXECUTOR_OPAQUE_U64S etc. derive from nros::sizes::EXECUTOR_SIZE via the nros_sizes_build
rlib probe — no hand-math upper bound. Per-consumer const _: () = assert!(size_of::<Ty>() <= STORAGE_SIZE …) enforces compile-time correctness. Probe=0 only on cargo check --no-default-features (warns + 1-word placeholder; the resulting rlib must not be linked).
CppContext adds explicit CPP_CONTEXT_OVERHEAD = 8 (u32 domain_id + alignment padding) on top
of Executor.
Wrapper timing
Future::wait(), Stream::wait_next(), Executor::spin(duration_ms) budget by wall-clock via
nros_cpp_time_ns(). Iteration-count loops collapse on early-wake from signaled condvars
(keep-alives, discovery gossip).
nros-cpp over the Rust core
nros-cpp does not sit on the C API. Its headers wrap nros-node through a
typed extern "C" FFI (rclcpp/detail/nros_cpp_ffi.h), so type safety is kept
per message type. The C++ user surface is
the user book’s C++ API page.
- Generated message bindings.
nano_ros_generate_interfaces(<pkg> … LANGUAGE CPP)(cmake/NanoRosGenerateInterfaces.cmake) resolves the.msg/.srv/.actionfiles (local directory, ament index, or bundled), generates the C++ headers and the Rust FFI glue (ffi_serialize/ffi_deserializeper type), compiles the glue into a static library through Corrosion, and exposes the result as the<pkg>__nano_ros_cppCMake target. The Zephyr module spells the same functionnros_generate_interfaces(). - Action client storage moves with the object.
rclcpp_action::Client<A>keeps its goal/feedback/result buffers and its four transport channels inside the instance. It is move-only, and a move callsnros_cpp_action_client_relocateso the trampolinecontextpointer the executor holds follows the newthis.
cbindgen output as canonical FFI
nros-cpp *.hpp headers #include "nros_cpp_ffi.h" directly; per-file hand-written
extern "C" redeclaration blocks were removed (drift broke things once). qos.hpp keeps a
fallback redef under #ifndef NROS_CPP_FFI_H. Exceptions: parameter.hpp cross-references
nros-c’s <nros/parameter.h>; action_{client,server}.hpp reinterpret_cast goal_id at FFI
callsites (cbindgen renders *const [u8; 16] as ptr-to-array); set_callbacks excluded from
cbindgen via [export.exclude] and declared locally with plain fn-ptr typedefs. cbindgen variants
are prefixed with the enum name (prefix_with_name = true) to avoid C++ name collisions.
QEMU networked tests
- Slirp networking (no TAP/sudo/bridges).
- Per-platform zenohd ports in
nros_tests::platform: baremetal=7450, freertos=7451, nuttx=7452, threadx-riscv=7453, esp32=7454, threadx-linux=7455, zephyr=7456.ZenohRouter::start(platform::FREERTOS.zenohd_port). Bridge-net (threadx-linux veth):ZenohRouter::start_on("0.0.0.0", port). - Subscriber first, then publisher. 5–10 s stabilization. Per-platform nextest groups
(
max-threads = 1); platforms run in parallel. - Domain ID: compile-time on embedded (Zephyr Kconfig
CONFIG_NROS_DOMAIN_ID; others via each example’sconfig.tomldomain_id→ generatedapp_config.h), runtime env on native vianros_tests::unique_ros_domain_id(). For Cyclone (RTPS ports =7400 + 250*domain), parallel fixtures bake a distinct domain per communicating role-set. - Patched
qemu-system-arm(Phase 143): usenros_tests::qemu::qemu_system_arm_cmd(), neverCommand::new("qemu-system-arm"). New justfile recipes gate through theQEMU_BINpath_exists check. Seebook-internals/src/testing/qemu-patched-binary.md. - QEMU clock:
-icount shift=auto(sleep=on) makes virtual time track wall-clock during WFI; full detail in qemu-icount.md.
FreeRTOS pitfalls
-
On an A/R-profile port (
ARM_CRx_No_GIC,ARM_CR5,ARM_CA9, AArch64) every task must get an FPU context — issue 0997. Those ports defaultconfigUSE_TASK_FPU_SUPPORTto 1, which saves a task’s VFP/NEON registers only after it callsvPortTaskUsesFPU(); nothing calls it, and a hard-float build uses the d-registers in integer code (64-bit constants, struct copies, NEONmemcpy). The result does not look like an FPU bug: Cyclone stamped its timed eventstsched = 0(avstr d8ofDDS_NEVER), stopped rescheduling SPDP and PMD, and the participant went silent ~100 ms after discovery; the same stale stamp later surfaced as a heap_4 double free. SetconfigUSE_TASK_FPU_SUPPORT 2in the board’sFreeRTOSConfig.handconfigFPU_D32withnros_freertos_config_define()— NOT in the header, becauseportASM.Snever includes it and the two halves of the task frame would disagree. Gate:check-freertos-fpu-context. Cortex-M FPU ports stack the FPU lazily in hardware and need none of this. -
Stack overflow → “Invalid mbox”. The three numbers this entry used to name are all gone — read it as a symptom, not as a recipe (corrected phase-392 W6, 2026-09-06):
Executorhas NOT held an inlinearena: [MaybeUninit<u8>; ARENA_SIZE]since phase-271 (issue 0110). It holdsarena: &'s mut [MaybeUninit<u8>], a slice borrowed from caller-supplied backing, and on FreeRTOS that backing is a named.bssstatic (nros_node::executor::backing::EXECUTOR_BACKING, phase-392 W6) — not the task stack.APP_TASK_STACKwas deleted in phase-76. The live knob isapp_stack_bytes(nros_board_common::freertos_config::DEFAULT_APP_STACK_BYTES, 131072 = 128 KiB since issue 1146; it was 393216), overridable withNROS_FREERTOS_APP_STACK_KB. No file in the tree sets that override. The C/C++ carrier’s own mirror incmake/templates/freertos_app_config.c.inis still 524288, and that file says why.- No example pins
NROS_EXECUTOR_ARENA_SIZE=8192; that was pre-phase-271 prose.
And the SYMPTOM this entry is named for is not the one you will see (issue 1146, measured). “Invalid mbox” is a possible landing, not the landing: an
action-serverbuilt at 32 KiB — 4 KiB under its measured 36 160-byte peak — died with*** MALLOC FAILED ***, because heap_4 hands out the task stack and the overflow lands in the next block’s header beforeconfigCHECK_FOR_STACK_OVERFLOW 2gets a context switch to check the pattern. Read*** MALLOC FAILED ***, “Invalid mbox” and*** STACK OVERFLOW: ***as three faces of the same fault.Do not size it by argument. Read the boot line. Every FreeRTOS Rust image now prints
nros: <task> stack peak <used> of <total> bytes (<free> free)at the end of its register pass (report_stack_peak,nros-board-freertos/src/entry.rs), which is the deepest point bring-up reaches. In-tree peaks are 22–36 KiB — the whole table is onfreertos_config::app_stack_bytes. -
Deterministic
rand()starts from seed 1 → duplicate Zenoh session IDs across QEMU instances;srand()with an IP-based unique seed innros_freertos_init_network(). -
Manual-polling action server:
create_action_server()is not arena-registered, sospin_once()does not process get_result queries — callserver.try_handle_get_result()aftercomplete_goal(). -
Poll task priority must be ≥ 4 (same as zenoh-pico read/lease tasks) to drain the RX FIFO.
-
Debug guide: freertos-lan9118-debugging.md.
Zephyr POSIX resource limits
Defaults CONFIG_MAX_PTHREAD_MUTEX_COUNT=5 / CONFIG_MAX_PTHREAD_COND_COUNT=5 are too low;
zenoh-pico needs ~8+ mutexes (transport TX/RX/peer + a write-filter mutex per publisher under
Z_FEATURE_INTEREST=1). Exhaustion makes pthread_mutex_init fail → zenoh-pico returns -80.
Set CONFIG_MAX_PTHREAD_MUTEX_COUNT=32 and CONFIG_MAX_PTHREAD_COND_COUNT=16 in prj.conf.
These pools are per-OBJECT, so anything that allocates a mutex per entity turns them into a
cap on workload size, not just a startup constant. Zephyr’s pthread_mutex_t and
pthread_cond_t are handles into static arrays sized by those knobs; a library that puts a
mutex in every object scales its demand with the number of objects. CycloneDDS does exactly
that (three per writer: e.lock, qos_lock, rdary_lock, plus one per addrset), so a
native_sim image joining a ~40-participant Autoware graph exhausted 16384 slots ~19 s in and
needed 131072 (~4.1 MiB static) — issue 0371 was that crash, 0496 the sizing rule.
Cyclone on Zephyr uses a NATIVE ddsrt sync backend, not the POSIX one (issue 0496)
Since cyclonedds@a09babf3, ddsrt_mutex_t is an embedded struct k_mutex and ddsrt_cond_t
an embedded struct k_condvar, so cyclone takes NO pthread pool slots for either. The knob is
back to the 256 example default and no longer scales with graph size. Wiring: DDSRT_WITH_ZEPHYR
in zephyr/cyclonedds-config/dds/config.h picks the types
(ddsrt/sync/zephyr.h), and zephyr/cmake/nros_rmw_cyclonedds.cmake swaps
sync/posix/sync.c for sync/zephyr/sync.c. Both halves must move together — the types and
the implementation — or the struct layouts disagree.
Two consequences worth knowing:
k_mutexis RECURSIVE for its owner; a pthread NORMAL mutex deadlocks. Correct code cannot tell the difference, but code that re-acquires its own lock hangs on POSIX and silently succeeds on Zephyr. So a locking bug can reproduce natively and NOT on Zephyr — which is what happened with the striped addrset locks, whose nesting hazard only ever hung the native build. When a lock-shaped bug is platform-asymmetric in that direction, suspect this.ddsrt_rwlock_tandddsrt_once_tare still pthreads (one rwlock exists in all of cyclone — thelog.csink — andpthread_once_tis caller-owned), so the pools are used but not scaled.
Zephyr zsock per-fd serialization vs zenoh-pico (issues 0129/0139)
Zephyr’s socket layer takes a per-fd fdtable mutex for the ENTIRE blocking call —
NSOS offload included — so zenoh-pico’s blocking read task holds the session socket
for a full SO_RCVTIMEO window between inbound packets, and every tx (entity or
liveliness declare, lease keepalive, publish, query reply) queues behind it.
Consequences and rules:
- Total image tx ≈ sends-per-second on the shared socket, NOT the sum of
publish rates (issue #145 / phase-279). Measured on native_sim (ws-realtime
ctrl 100 Hz + telem 10 Hz, 20 s window): at the 100 ms default both sched tasks
converge to ~4.3 msg/s each (~8.6 total — a 100 Hz and a 10 Hz publisher get
the SAME throughput under contention); at 5 ms, ~39 total (ctrl 33 / telem 5.5).
Z_CONFIG_SOCKET_TIMEOUTtrades read-wake rate for tx budget but stays window-bound. Mitigation (phase-279, opt-in):NROS_ZENOH_TX_BATCH=1env / KconfigCONFIG_NROS_ZENOH_TX_BATCH=yenables tx batching PLUS a dedicated flush thread (multi-threaded platforms except ThreadX; flush cadenceNROS_ZENOH_TX_BATCH_FLUSH_MS, default 50 ms) — measured 4× total throughput at the 100 ms default (34.1 vs 8.6 msg/s) and 1.35× at 5 ms (52.5 vs 39), with the 10 Hz sched task reaching ≈ ideal. Flushing from the sched task threads themselves measured WORSE-or-equal (4.7-9.2) — the flush must live on its own thread. Phase-282 closed the residual:NROS_ZENOH_TX_SPLIT_LOCK=1/CONFIG_NROS_ZENOH_TX_SPLIT_LOCK=y(requires TX_BATCH) makes the flush STEAL the batch (buffer swap) and write the socket under a separate link mutex, and a batch overflow PARKS the buffer instead of blocking the publisher — sched tasks 43.2 total (+27%), streaming ~181 msg/s vs ~9 baseline (20×) with a tight-loop publisher completing instead of stalling. Wire order stays == SN order. Per-publisher escape:tx_expressin the QoS profile (Rust.tx_express(true)/ Crclc_qos_t.tx_express/ C++rclcpp::QoS().tx_express(true)) bypasses the batch for latency-critical low-rate topics (on Zephyr an express put pays the socket window itself — never use it for streams). Flush-thread attrs:zpico_set_flush_task_config(). Gets + query replies go express under the knob; keepalives bound batch sit-time. NOTE: TX_BATCH flipsZ_FEATURE_BATCHINGand TX_SPLIT_LOCK flipsZ_FEATURE_TX_SPLIT_LOCKin the SHARED generated zenoh config (both gate transport-struct fields — the issue-0135 every-TU rule); rebuild fixtures after changing them. Harnesses:tests/w1_zephyr_tx_throughput_measure.rs(--ignored, sched tasks) +packages/testing/nros-bench/stress-zenoh-zephyr(streaming; bench listeners MUST build withNROS_ZENOH_SUBSCRIBER_RING_DEPTH=1024— the default 4-slot ring evicts most of a batched callback burst, keeping its newest samples — issue 1979). User-facing decision tree: book “TX Throughput & Latency Tuning” page. Z_CONFIG_SOCKET_TIMEOUTmust stay short (100 ms on every port). At 5000 ms the client’s ~3.3 s lease keepalives miss the 10 s lease and zenohd silently drops the session — the image keeps spinning against a dead transport (boot-time declares crawl at one per recv window, then everything wedges). Fork patch inzenoh-pico/include/zenoh-pico/config.h(+ thezenoh_generic_config.htwin). NuttX kept 5000 until 2026-10-29 (issue 2094); on the fixed clock it takes 100 too, and its cells ran 2–16x faster.- A zenoh image’s socket receive MUST time out, and on Zephyr that takes
CONFIG_NET_CONTEXT_RCVTIMEO(issue 2105). zenoh-pico 1.10’s executor thread runs the lease, keep-alive and query timeouts only between receives. Zephyr ignoresSO_RCVTIMEOwithout that option, andtcp_zephyr.conly logs the refusal. On a native-IP image (any real board) the receive then blocked until the router spoke: the keep-alive went out once a minute and the session re-dialled.NROS_RMW_ZENOHselects the option, andzpico.c#errors without it. native_sim cannot show this, because its offloaded host sockets honour the timeout anyway. - NuttX’s zenoh-pico clock is
nuttx_clock.c, never the alias TU’s (issue 2094).ZENOH_NUTTXmakes zenoh-pico’s TUs seeunix.h, wherez_clock_tis astruct timespecreturned through a hidden pointer, whileplatform_aliases.cis compiled against the genericuint64_tms clock. Sonros-zpico-builddefinesNROS_PLATFORM_ALIASES_SKIP_CLOCKfor NuttX. Re-enabling the alias clock there is a duplicate-symbol link error, becausenuttx_clock.calso owns_z_condvar_wait_until, which zenoh-pico always references. Before this, every executor wake-up was stack garbage, and a NuttX link with no traffic lost its session at the 60 s lease while every cell with traffic passed. Count nanoseconds inuint64_t: 32 bits wraps every 4.29 s. With the old 5 s socket timeout NuttX timers fired up to ~10 s late (keep-alives every 25–30 s); at 100 ms the bound is ~200 ms. - Intra-image pub→sub needs
Z_FEATURE_LOCAL_SUBSCRIBER=1(set byzephyr/cmake/nros_rmw_zenoh.cmakeon Zephyr; the generatedzenoh_generic_config.hfromnros-zpico-buildsets it for every cargo-built lane, embedded included): all nodes in an image share ONE session (RFC-0015 Model 1), and neither zenoh-pico nor zenohd loops a publication back to the session it came from. Without it a same-image pair (e.g. ws-qos-rust’sreliable_talker → qos_listener, or the multi-sched-task FreeRTOS safety island’s gate→actuator command) never delivers, silently — remote routes keep working, so the drop masquerades as a per-route scheduling bug. - The #129 per-node-liveliness “deadlock” was this same mechanism — the first
declare to hit the recv window. The
platform-zephyrgate it prompted was LIFTED after the timeout fix (issue 0143): per-node NN tokens declare fine on Zephyr now, and multi-node images list every component inros2 node list. - tx throughput ceiling: because every send waits for the read task’s recv
window, total tx is capped at ~one send per window (plus inbound-traffic
wakes). At the 100 ms default that is ~10 msg/s for the WHOLE image — fine
for 1 Hz demo lanes, fatal for high-rate publishers. Tune per-app via
CONFIG_NROS_ZENOH_SOCKET_TIMEOUT_MS(→Z_CONFIG_SOCKET_TIMEOUT); the ws-realtime zephyr entry (100 Hz ctrl sched task) uses 5 ms. - Concurrent entity declares race the interest write filter: a declare
triggers an interest handshake, and when two threads declare concurrently
on one session (only on
Z_FEATURE_MULTI_THREAD=0— embedded; native’s MT=1 has internal session mutexes) the losing publisher’s zenoh-pico write filter can stay closed —z_publisher_putsucceeds while nothing reaches the wire. All embeddedrun_sched_taskspaths (ZephyrBoard::run_sched_tasks, the FreeRTOS Rustrun_sched_tasks_entry, and the FreeRTOS Cnros_board_freertos_run_sched_tasks) therefore chain-spawn the sched tasks: the boot sched task runs its setup, then spawns exactly one task for the next sched task, which runs ITS setup and spawns the next — so no twosetup()(declare) bursts ever overlap, for any sched task count (issue 0144; the 0128 fix only closed boot↔sched task for two sched tasks). Spins may overlap the next sched task’s setup — safe, a spin exchanges keepalives/data, not declares.
NuttX ↔ zenoh-pico cooperation (Phase 225.O)
zenoh-pico is not platform-agnostic on the C side and is not meant to be;
the Phase 227.3 “platform-agnostic” refactor (365d5cdce) only made the
Rust shim (nros-rmw-zenoh) generic (no target_os/NuttX branches —
just feature gates). zenoh-pico C keeps per-platform system layers and
#ifdef ZENOH_NUTTX accommodations. How NuttX wires up:
- Feature → define.
nros/platform-nuttxforwardsnros-rmw-zenoh?/platform-nuttx→zpico-sys/nuttx→CARGO_FEATURE_NUTTX, andnros-zpico-buildthen#define ZENOH_NUTTX+ selects theunixsystem layer (zenoh-pico/system/common/platform.h: NuttX is grouped withZENOH_LINUX/MACOS/BSD) +LinkPolicy::nuttx(). The forwarding clause is load-bearing: without itZENOH_NUTTXis undefined and the setsockopt guards below stay off. unixsystem layer = direct POSIX. NuttX is a hosted POSIX RTOS, sosystem/unix/system.cbacks the primitives directly —z_malloc→libcmalloc,_z_task_*→pthread_create/join,_z_mutex_*→pthread_mutex_*, sockets→NuttX kernel BSD sockets. No bare-metal platform shim is needed (contrast bare-metal/ESP32, which route throughnros-platform-*+ smoltcp).nros-platform-nuttxis a thin C-only glue crate (platform.c/net.c).ZENOH_NUTTXaccommodations (6 branches insystem/unix/network.c): skip<ifaddrs.h>/getifaddrs(NuttX lacks it → multicast bindsINADDR_ANY); skipSO_LINGER(noCONFIG_NET_SOLINGER); skipTCP_NODELAY(host vs NuttX optname value mismatch under cross-compile); useMSG_NOSIGNALonsendand freegetaddrinforesults (both shared withZENOH_LINUX). The SO_LINGER/TCP_NODELAY skips are why step 1’sZENOH_NUTTXdefine matters — otherwise those setsockopts fail on NuttX and_z_open_tcpreturnsTransport(ConnectionFailed).- Backend registration is explicit on NuttX. The unified-RMW
nros_rmw_register_backend!macro expands to alinkmedistributed-slice entry on supported targets but to nothing on NuttX (linkme unsupported), and the standalone flat image does not run the auto-register.init_array. Sonros-board-nuttx::run_entrycallsnros_rmw_zenoh::register()explicitly beforeExecutor::open(featurermw-zenoh, wired entry →nros-board-nuttx-qemu→nros-board-nuttx) — same shape as the esp32 board. - pthread pool. Like Zephyr, NuttX needs enough pthread mutex/sem
resources (zenoh-pico uses ~8+ mutexes; transport TX/RX/peer + per-publisher
write-filter under
Z_FEATURE_INTEREST=1). The referenceqemu-armv7adefconfig suffices for talker+listener; raise the NuttX pthread limits for heavier graphs.
Verified 2026-06-09: native_sim-less, on qemu-system-arm -M virt -cpu cortex-a7, the NuttX workspace Entry boots → registers → publishes /chatter
→ an external native listener receives it cross-process.
Manual liveliness assertion is per BACKEND, and only cyclonedds does it
The phase-467 RMW gap-closure design study’s Row 7. assert_liveliness() is
one API call with four answers, and the divergence belongs to the backends
rather than to the API — which is why it is recorded here and not as an
API-parity divergence.
| backend | AUTOMATIC / NONE | MANUAL_BY_TOPIC / MANUAL_BY_NODE |
|---|---|---|
| cyclonedds | OK | OK, and real — dds_assert_liveliness on the writer renews the lease and sends a Heartbeat |
| zenoh-pico | OK | UNSUPPORTED — session-scoped keepalive only; no per-topic lease exists to renew |
| XRCE-DDS | OK | UNSUPPORTED — NULL vtable slot, no lease in the protocol |
| uORB | OK | UNSUPPORTED — NULL vtable slot, no wire liveliness at all |
OK under a manual kind therefore MEANS the assertion reached the wire. That
is the whole change: zenoh returned Ok(()) unconditionally while sending
nothing, and the NULL-slot arm in nros-rmw-cffi returned Unsupported
unconditionally — including for an AUTOMATIC publisher that had asked for
nothing. rmw_vtable.h has stated the split since phase 108 and neither half
implemented it.
Two things NOT to change while reading this:
- zenoh’s local watchdog stays.
assert_liveliness()still stampslast_assert_at_ms, andcheck_liveliness_loststill fires this publisher’s ownLivelinessLostwhen the gap exceeds the lease. It is a real capability; it is LOCAL, and the return value is about what a PEER can see. Recording the call and reportingUNSUPPORTEDis not a contradiction. - Do not “fix” zenoh by re-declaring the
@ros2_lvtoken on every assert. A peer reads the churn as leave/join, which is worse than silence.
The test is packages/rmw/cffi/tests/publisher_gid.rs; the one spelling of
“the application owns the assertion” is QoSLivelinessPolicy::is_manual.
Publisher GID: one width, one value
Same study, Q1, and issue 1495. Publisher::get_gid() — upstream’s
rmw_get_gid_for_publisher — is 24 bytes on every surface, matching
upstream’s RMW_GID_STORAGE_SIZE, and on every backend that answers it is
produced from the same source as the gid a take reports:
| backend | get_gid() and MessageInfo::publisher_gid on a take |
|---|---|
| cyclonedds | the DDS writer GUID, 16 bytes zero-extended to 24. The take reaches it through take_with_info (publication handle → dds_get_matched_publication_data, cached per subscription) |
| zenoh-pico | XXH3-128 of the entity’s liveliness keyexpr (shim/entity_gid.rs), 16 bytes zero-extended — rmw_zenoh_cpp’s derivation. A peer RE-SERIALISES our token before hashing, so the token’s QoS field must be upstream’s canonical form (defaults elided) or the peer’s gid for us diverges |
| XRCE-DDS, uORB | UNSUPPORTED — NULL slot; no metadata on a take |
Verified against live peers in graph_interop (cells
native-graph-rust-{zenoh,cyclone}-r2n). A Humble rmw_cyclonedds_cpp
subscriber reports the 8-byte publication handle as its take-side gid, so
it is not a valid oracle for ours.
Never report an all-zero gid as an answer. That is what an uninitialised
rmw_gid_t holds, and a caller cannot tell it from an identity. A backend
with none says UNSUPPORTED.
Platform-ABI carve-outs: Serial / IVC / PlatformLibc are Rust-only (issue 0244)
Not every platform primitive has a C-ABI mirror in the nros_platform_*
headers. PlatformSerial and PlatformIvc (Tegra IVC mailbox) are Rust
traits only — no nros_platform_serial / nros_platform_ivc header — because
they are consumed exclusively by Rust boards. PlatformLibc is documentary-only
(link-resolved from nros-baremetal-common, never dispatched).
This is a deliberate carve-out, not an oversight. Post-RFC-0054 the C headers are the platform-ABI SSoT and the Rust side consumes committed bindgen output; hand-written C mirrors of Rust traits are the RETIRED model. So the rule is: a primitive joins the portable C ABI only when a C consumer needs it, at which point it is AUTHORED as an SSoT header and the Rust trait wraps the generated bindings — never hand-mirrored ahead of a consumer. Until then, Rust-only is correct.
Separately, zpico-sys/src/platform_smoltcp.rs exposes smoltcp_set_clock_ms /
smoltcp_clock_now_ms — an externally-fed monotonic tick (PUSH: the board timer
ISR calls set_clock_ms) the bare-metal smoltcp stack needs, beside the
canonical nros_platform_clock_ms (PULL). It is required today; unifying it onto
the SSoT platform-clock header is a phase-230 normalization item.