Skip to content

Add emscripten-wasm32 support with rmw_zenoh_pico - #46

Open
Tobias-Fischer wants to merge 57 commits into
RoboStack:codex/cross-distro-syncfrom
Tobias-Fischer:feature/emscripten-wasm32-zenoh-pico
Open

Tobias-Fischer wants to merge 57 commits into
RoboStack:codex/cross-distro-syncfrom
Tobias-Fischer:feature/emscripten-wasm32-zenoh-pico

Conversation

@Tobias-Fischer

@Tobias-Fischer Tobias-Fischer commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds emscripten-wasm32 as an opt-in target platform for rolling, using
rmw_zenoh_pico as the
RMW implementation. This follows the same emscripten-wasm32 port already
proven on humble (Tobias-Fischer/ros-humble, backed by
RoboStack/vinca#154), but
swaps the custom hand-rolled wasm_cpp/rmw_wasm_cpp RMW used there for
zenoh-pico, which has real, actively-maintained upstream WASM/pthreads
support — significantly less custom code to maintain.

All changes are gated by if: wasm32 / if: not wasm32 selectors (or
per-platform robostack.yaml dicts) and are additive — no behavior change
for existing platforms.

Verified end-to-end, both a C and a Python talker:

  • A WebAssembly rclc talker node running in a real browser publishes
    std_msgs/String via rmw_zenoh_pico over a real WebSocket to a native
    zenohd router, received and correctly decoded by an independent native
    process.
  • A real rclpy talker (CPython built with --enable-wasm-pthreads,
    including a real, non-stubbed numpy) running the same way in the same
    browser, also verified against a native subscriber.

Both are genuine cross-process interop, not just "doesn't crash."

Update: pthreads replaced with Asyncify

The rclpy/pthreads work described further down (CPython built with
--enable-wasm-pthreads, the vinca MODULE-vs-SHARED pthreads-flag fix)
has since been reverted. Real pthreads requires WebAssembly
--shared-memory, which is viral — every module that gets dlopen'd into
a host must also be pthreads/shared-memory, or linking fails. That's
exactly what blocked rclpy from loading inside a JupyterLite kernel
(xeus-python, built without pthreads upstream): rebuilding xeus/
xeus-python with pthreads to match got past the link error, but produced
a hard, total deadlock — xeus-python's own thread model can't tolerate a
blocking wait either.

Replaced with Asyncify: rmw_zenoh_pico's rmw_wait() now polls
zenoh-pico's own Z_FEATURE_MULTI_THREAD=0 single-threaded transport mode
(zp_read()/zp_send_keep_alive(), already used for e.g.
WITH_ARDUINO_OPENCR) in a loop that cooperatively yields via
emscripten_sleep(), instead of blocking a real OS thread on a
condition_variable. This removes the shared-memory requirement from the
whole closure — xeus/xeus-python never need to touch pthreads at all.

Also fixed along the way:

  • rmw_zenoh_pico's own rmw_zenoh_pico_condition.h was missing a
    <pthread.h> include for pthread_condattr_t — the type is declared
    unconditionally by emscripten's libc regardless of -pthread, but
    zenoh-pico's own platform header only pulls it in under
    Z_FEATURE_MULTI_THREAD==1.
  • rmw_zenoh_pico_set_unicast/set_mode/get_mode (used by consumers to
    override the compiled-in zenoh connect address at runtime) aren't part
    of the standard rmw.h ABI, so wasm-ld's side-module dead-code
    elimination dropped them from the export table — now explicitly exported
    via target_link_options(... "-Wl,--export=...").
  • The two local patch/ros-rolling-rosidl-typesupport-microxrcedds-{c,cpp}.patch
    files mentioned below are gone — both are now fully subsumed by the
    Tobias-Fischer/rosidl_typesupport_microxrcedds fork pin (verified: both
    patches fail to apply against that fork's commit because it already
    contains every hunk they were adding).
  • geometry_msgs/sensor_msgs added to the wasm32 closure.
  • pixi.toml's build-emscripten is now a fixed two-pass sequence
    (build → mirror-sync → build again), replacing an open "loop until the
    package count stops changing" — nothing in this recipe set nests the
    native-mirror workaround more than one level deep, so two passes is
    provably sufficient, verified by clearing the mirror-dependent packages
    and confirming a full rebuild converges without a third pass.

What's included

  • vinca.yaml / robostack.yaml / patch/dependencies.yaml /
    pkg_additional_info.yaml: thread the emscripten-wasm32 platform through
    the recipe pipeline (selectors, per-platform dependency-name mappings,
    rmw_implementation/typesupport wiring for the ~230-recipe closure).
  • conda_build_config.yaml / vinca_pinning.yaml: emscripten toolchain pin
    (emscripten-forge's compiler/SDK) plus targeted pin overrides where
    emscripten-forge's package set diverges from conda-forge (python 3.13 vs
    3.14, graphviz/libffi versions) — base pins are preserved for every other
    platform.
  • pixi.toml: points the vinca pypi dependency at
    RoboStack/vinca#154's branch
    until it merges upstream.
  • patch/ros-rolling-*.patch: per-package emscripten build fixes (rclpy
    narrowing warning + -latomic link condition, rcutils, rosidl-generator-py,
    spdlog/yaml-cpp vendor policy, osrf-testing-tools-cpp,
    rmw_test_fixture_implementation).
  • patch/ros-rolling-rmw-zenoh-pico.patch: builds rmw_zenoh_pico for
    wasm32 (see "Update" above for the Asyncify-specific hunks). The
    typesupport-backend codegen gap this PR originally patched locally
    (rosidl_typesupport_microxrcedds_{c,cpp}) is now fixed upstream instead
    — see "Upstream PRs" below — by pinning to a fork commit that already has
    it, so no local patch is needed for that package anymore.
  • extra_recipes/zenoh-pico, extra_recipes/microcdr: new recipes for
    rmw_zenoh_pico's own dependencies, not otherwise packaged for RoboStack.

Making this genuinely buildable from a clean checkout

Earlier revisions of this PR had only been exercised incrementally, on a
local output/ directory that had accumulated a lot of by-hand state. Since
then it's been rebuilt from a genuinely empty output/ directory (twice, to
also re-verify the vinca ordering fix below), which surfaced several real
bugs a from-scratch build hits that an incremental one never does:

  • patch/dependencies.yaml: rosidl_typesupport_c/rosidl_typesupport_cpp
    now declare a real add_host dependency on
    ros2-rosidl-typesupport-microxrcedds-{c,cpp} (and likewise test_msgs/
    example_interfaces, needed for the same reason as the packages already
    listed on pixi.toml's build-emscripten comment below). Without this,
    rattler-build's solver has no declared reason to build the microxrcedds
    typesupport packages before the packages that find_package() them, so a
    from-scratch build's ordering came down to luck — sometimes it built in
    the right order, sometimes a plain find_package() failed outright. This
    replaced an earlier, hacky retry-loop workaround with the actual missing
    dependency declaration, so rattler-build now builds the correct order
    itself, every time.
  • extra_recipes/{microcdr,zenoh-pico}/build.sh: these existed only on a
    local machine, never actually committed — silently swallowed by a blanket
    *.sh .gitignore rule (now negated for extra_recipes/**/*.sh). Any
    genuinely fresh checkout failed with "build.sh: command not found".
  • pixi.toml's new sync-native-bootstrap-mirror task (wired into
    build-emscripten's depends-on) scripts a workaround that used to be
    manual, undocumented tribal knowledge: several message packages' generated
    recipes declare a build:-time (build-platform, i.e. native osx-arm64)
    dependency on rosidl_default_generators, but no channel publishes a
    native ros2-*-prefixed build of anything — it's permanently
    unsatisfiable as declared. CMake's own find_package() resolution only
    ever needed the host-prefix (emscripten-wasm32) copy (already
    cross-compilation-safe via -DCMAKE_FIND_ROOT_PATH=$PREFIX); rattler-build's
    solver is what insists on a real build-platform channel entry. Mirroring
    already-built emscripten-wasm32 packages into a same-named osx-arm64
    channel directory and reindexing satisfies the solver without any actual
    native rebuilding.
  • pixi.toml's vinca pin is now on
    Tobias-Fischer/vinca@c974b23
    (part of RoboStack/vinca#154),
    which fixes a genuine CMake target-registration-ordering bug found while
    re-verifying this PR from scratch — see that PR's description for the
    full root-cause writeup. In short: pre-finding the static typesupport
    override for a message-generating package (rather than only for
    consumers, which is what it's actually for) made that package's own
    typesupport target jump ahead of the generator target its own
    Export.cmake requires, breaking every downstream find_package() of it.
    First surfaced here on builtin_interfaces, breaking service_msgs.
  • The last blocker this surfaced: rosidl_typesupport_microxrcedds_cpp's
    codegen didn't handle ROS 2's auto-generated service/action "_Event"
    message, which used to mean rebuilding a dozen-plus affected packages by
    hand with different typesupport overrides after the main build (see the
    old revision of pixi.toml's build-emscripten comment in this PR's
    history for exactly how involved that was). Fixed at the source instead
    — see "Upstream PRs" below — so a single pixi run build-emscripten pass
    with both typesupport overrides set globally now builds the entire
    closure correctly. No more scoped rebuilds, and no more risk of silently
    routing a package through the wrong one (confirmed happened once, to
    type_description_interfaces, before this fix).

Upstream PRs

The genuine bugs found along the way (not RoboStack-specific) have been
submitted upstream rather than only patched here:

Getting rclpy running: what it actually took

(Historical — the pthreads approach described in this section was reverted; see "Update" above. rclpy now runs without any custom pthreads-enabled CPython build at all, using the same Asyncify-based rmw_zenoh_pico as everything else.)

Earlier revisions of this PR reported rclpy as blocked by CPython for
wasm32 not being pthread-enabled. That's fixed, and it turned out to be
one configure flag: CPython upstream has shipped --enable-wasm-pthreads
since 3.11 (adds exactly -pthread -sUSE_PTHREADS -sPROXY_TO_PTHREAD, the
same flags this whole pipeline already uses) — emscripten-forge's own
python recipe just doesn't turn it on. Building a custom variant with
that flag, plus registering numpy's ~14 eagerly-imported compiled
extension modules ahead of time (this build doesn't enable CPython's
--enable-wasm-dynamic-linking — its own docs flag that combination with
pthreads as known-crashy — so nothing can rely on Python's normal import
dynamically dlopen-ing a .so), got a real rclpy talker with real
numpy running end-to-end.

One more narrow gap surfaced along the way, documented in this repo rather
than silently patched around:

  • rmw_zenoh_pico doesn't support publisher/subscriber QoS event handlers
    (RCL_PUBLISHER_OFFERED_INCOMPATIBLE_QOS etc.) or TypeDescriptionService
    — the former fails with a plain RCLError instead of the
    UnsupportedEventTypeError rclpy already handles gracefully, and the
    latter hangs rather than erroring. Worked around locally for the demo
    (not upstreamed — didn't root-cause the rmw_zenoh_pico side tonight);
    worth a closer look if rclpy support here gets formalized further.

The rclpy demo itself (build script, patched rclpy/numpy, browser
harness) is local-only exploratory tooling, same as the existing rclc
one — not part of this PR's recipe changes.

Test plan

  • Full recipe closure (~230 packages) builds green for
    emscripten-wasm32 + rmw_zenoh_pico, from a genuinely empty
    output/ directory (not incrementally on top of prior local state),
    in a single pixi run build-emscripten pass with both typesupport
    overrides set globally — no scoped per-package rebuilds.
  • Every package that defines a service or action (action_msgs,
    rcl_interfaces, type_description_interfaces, test_msgs, ...)
    confirmed building with the correct typesupport backend actually
    wired into its dispatch table (checked via strings on the built
    .so).
  • Browser demo: wasm32 rclc talker → real WebSocket → native zenohd
    independent native subscriber, verified receiving correctly-decoded
    messages via the router's own REST API (not just the browser's own
    console).
  • Same, with a real rclpy talker (real numpy, not stubbed), also
    independently confirmed via the router's REST API.
  • Confirmed against a real (non-sandboxed) browser, not just automated
    tooling.

Full write-up

All the changes this required, across every repo, are documented together in Tobias-Fischer/ros2-emscripten-zenoh-demo — including a working rclc and rclpy browser demo verified end-to-end against a native zenohd router.

🤖 Generated with Claude Code

Ports the emscripten-wasm32 build target (proven on ros-humble via
Tobias-Fischer/ros-humble, see RoboStack/vinca#154) to rolling, using
rmw_zenoh_pico instead of a custom RMW, since zenoh-pico has real upstream
WASM/pthreads support. Verified end-to-end: a wasm32 rclc talker running in
a browser publishes std_msgs/String over a real WebSocket to a native
zenohd router, received by an independent native subscriber.

- vinca.yaml/robostack.yaml/patch/dependencies.yaml/pkg_additional_info.yaml:
  gate the emscripten-wasm32 platform throughout, same pattern as the humble
  port (if: wasm32 / not wasm32 selectors, per-platform robostack.yaml dicts).
- conda_build_config.yaml/vinca_pinning.yaml: emscripten toolchain + pin
  overrides (compiler, python 3.13, graphviz/libffi pins that only exist on
  emscripten-forge).
- pixi.toml: point vinca at the fork branch backing vinca#154 until merged.
- patch/ros-rolling-*.patch: per-package emscripten build fixes (rclpy
  narrowing/-latomic, rcutils, rosidl-generator-py, spdlog/yaml-cpp vendor,
  osrf-testing-tools-cpp, rmw_test_fixture_implementation).
- patch/ros-rolling-rmw-zenoh-pico.patch, patch/ros-rolling-rosidl-typesupport-
  microxrcedds-{c,cpp}.patch: build rmw_zenoh_pico and its typesupport backend
  for wasm32 (upstream PRs open, see below).
- extra_recipes/zenoh-pico, extra_recipes/microcdr: new recipes for
  rmw_zenoh_pico's dependencies (not otherwise packaged for RoboStack).

Upstream bug fixes have been submitted separately:
- eclipse-zenoh/zenoh-pico#1314
- esol-community/rmw_zenoh_pico#7
- micro-ROS/rosidl_typesupport_microxrcedds#83

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer and others added 9 commits September 9, 2026 14:47
- Bump the vinca pin to 325d0af, which also sets
  CMAKE_SHARED_MODULE_CREATE_C/CXX_FLAGS (not just the SHARED-library
  variants) -- CMake's MODULE library type, used by pybind11_add_module()
  for every Python C extension (rclpy's own _rclpy_pybind11, and each
  message package's rosidl_generator_py typesupport accessor), was linking
  without USE_PTHREADS=1 even though its objects compiled with atomics
  fine, producing a load-time "mismatch in shared state of memory" next to
  the rest of a pthreads build. Invisible until something used
  pybind11_add_module() -- found getting a live rclpy demo running.

- Add two Emscripten guards to patch/ros-rolling-rcutils.emscripten.patch
  (upstreamed as ros2/rcutils#591) so rcl_logging_implementation's
  dlopen-by-name backend selection works: rcutils_get_platform_library_name()
  had no emscripten case (always "failed to format library name"), and the
  post-dlopen path resolution took a dlinfo(RTLD_DI_LINKMAP) branch that
  Emscripten's JS-backed dlopen doesn't support, treating a successful
  dlopen() as a failure.

- Document a known gap (see pixi.toml comment): rosidl_typesupport_
  microxrcedds_cpp's codegen doesn't handle service "_Event" messages, so
  action_msgs/lifecycle_msgs/rcl_interfaces/rosgraph_msgs/statistics_msgs/
  type_description_interfaces/micro_ros_msgs need a second, C-only rebuild
  pass rather than the global STATIC_ROSIDL_TYPESUPPORT_CPP override this
  task sets -- a real rclpy talker (not just rclc) verified end-to-end
  through this same rmw_zenoh_pico pipeline surfaced this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…shot

vinca's PR (RoboStack/vinca#154) had a merge conflict against current
master -- vinca/main.py has since been split into several modules
(configuration.py, pipeline.py, recipes.py, sources.py, etc.) by an
unrelated upstream refactor. Merged and verified: none of this branch's
actual changes touch main.py in a way the refactor didn't already
independently resolve (the one main.py hunk this branch touched -- dropping
an unsatisfiable build-time rosidl_default_generators requirement for
emscripten cross builds -- turned out to already be gone from the
refactored file), and `pixi run generate-recipes-emscripten` against the
merged vinca commit still produces the same 229-recipe closure with all of
this repo's template customizations (real pthreads, configurable
RMW_IMPLEMENTATION/typesupport backend) intact.

The refactored vinca requires a newer rosdistro_snapshot.yaml schema (a
per-package `dependencies:` list) that the snapshot committed here predates
-- regenerated via the existing `create_snapshot` task. As a side effect
this also re-syncs every package's pinned tag to current rolling (the
prior snapshot was from 2026-08-24); spot-checked several packages and the
version bumps are real upstream rolling releases, not the migration itself
introducing drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ature/emscripten-wasm32-zenoh-pico

# Conflicts:
#	patch/dependencies.yaml
#	pkg_additional_info.yaml
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…river patch

The previous commit's full vinca-snapshot regeneration (needed for the new
vinca's dependencies: schema) also silently bumped every package's pinned
git tag to whatever's currently on rolling. That's far more blast radius
than intended and broke at least one unrelated, already-passing patch
(ros-rolling-mujoco-3d-lidar.patch, whose context assumed the older pinned
mjtnum.h-vs-mjtype.h header layout).

Replaced it with a merge: keep every already-tracked package's original
tag/version/url exactly as they were, and only backfill the new
dependencies: field from a fresh vinca-snapshot run. Packages genuinely new
to rosdistro since the original snapshot (no old entry to preserve) still
get their current tag -- one of those, sbg_driver, needed its own patch
regenerated against upstream's newer release, which turned out to have
already picked up 3 of our patch's 4 hunks itself (ament_target_dependencies
-> target_link_libraries modernization, usleep -> sleep_for); only the
maybe_unused parameter fix still applied.

`pixi run check-patches` (the full non-emscripten closure) now passes
clean, and `pixi run generate-recipes-emscripten` still produces the same
229-recipe closure as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ature/emscripten-wasm32-zenoh-pico

# Conflicts:
#	conda_build_config.yaml
#	vinca_pinning.yaml
get_used_typesupports.cmake finds candidate typesupports via a plain
find_package(rosidl_typesupport_microxrcedds_c QUIET), not a real
package.xml/recipe dependency -- same class of ordering issue already
documented and fixed for rmw_implementation just above. With
VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C/_CPP naming that implementation,
a genuinely from-scratch build of the full closure is order-dependent:
rattler-build's own topological sort has no way to know
rosidl_typesupport_microxrcedds_c/cpp need to exist before
rosidl_typesupport_c/cpp without a declared dependency saying so.

Caught by a real CI run doing an atomic from-scratch build of the
whole closure for the first time (the local build this was developed
against accreted incrementally over many separate runs, which happened
to build these in a working order by chance). Verified with an
isolated rebuild of just these recipes plus their now-declared
dependents, starting from none of them existing: builds cleanly in one
rattler-build pass, no retry/continue-on-failure needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A blanket *.sh in .gitignore (meant for vinca-generated build scripts
under recipes/, already separately ignored) also silently swallowed
these two hand-written build.sh files, which are genuine recipe
source, not generated output. Neither ever actually made it into a
commit -- a real CI run doing a genuinely fresh checkout hit
"build.sh: command not found" building microcdr, since
generate-recipes-emscripten's `cp -r extra_recipes/. recipes/` only
had recipe.yaml to copy.

Add a negation (!extra_recipes/**/*.sh) rather than removing the
blanket rule outright, since it's presumably still doing its job for
whatever vinca-generated scripts prompted it in the first place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…vent codegen gap globally

Same class of fix as the previous commit's rosidl_typesupport_c/cpp
ordering one, for two packages that were missed: test_msgs and
example_interfaces use rosidl_generate_interfaces() like the other 11
core message packages, but weren't in patch/dependencies.yaml's
add_host list for ros2-rosidl-typesupport-microxrcedds-c/-cpp -- their
own build environment never had those typesupport backends installed,
so STATIC_ROSIDL_TYPESUPPORT_C silently fell back to
rosidl_typesupport_introspection_c and get_used_typesupports.cmake
rejected it outright once something else (rcl, via a real
build_depend/test_depend) needed them to actually exist.

Also adds --continue-on-failure to build-emscripten itself, matching
build_continue_on_failure's existing rationale for the native build:
rosidl_typesupport_microxrcedds_cpp's codegen gap for auto-generated
service "_Event" messages is a real, permanent upstream limitation for
action_msgs/lifecycle_msgs/rosgraph_msgs/statistics_msgs/
micro_ros_msgs/test_msgs/example_interfaces (confirmed generating
uncompilable C++), not something a dependency declaration can fix --
those packages need a second pass without the CPP override (see
README's Known limitations), so the first pass has to tolerate their
failure instead of aborting the whole ~230-package closure over seven
packages with a known, accepted, understood gap.

Verified against a genuinely from-scratch build (cleared local caches,
rebuilt the closure with no pre-existing packages): all ~230 recipes
build cleanly across a first pass (tolerating the seven known
failures) plus the established second-pass fixups, and the resulting
rclpy talker publishes real std_msgs/String messages through a native
zenoh router.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer added a commit to Tobias-Fischer/vinca that referenced this pull request Sep 10, 2026
A message package's *Config.cmake only re-exports find_dependency()
calls for what its own package.xml/CMakeLists.txt declares. It has no
idea VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C/_CPP named an extra
typesupport backend, so it never propagates *that* as an exported
dependency to its own downstream consumers -- a package that
find_package()s only one message package at a time never notices (it
already found the backend itself while configuring its own
rosidl_generate_interfaces() call), but one that find_package()s
several message packages together hits "the target was not found ...
A find_package call is missing for an IMPORTED target" the first time
a downstream *Export.cmake references
rosidl_typesupport_microxrcedds_c(pp)::rosidl_typesupport_microxrcedds_c(pp)
without anyone upstream having found it first.

Pre-finding it in the same CMAKE_PROJECT_INCLUDE file already used for
the pthreads/shared-lib flags (included right after every project()
call, so it's in every target's CMake namespace before that project's
own find_package() calls run) covers every consumer uniformly instead
of needing a patch per affected package.

Found via RoboStack/ros-rolling#46's test_msgs, the first package in
that closure to find_package() enough message packages together to
expose the gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer and others added 4 commits September 10, 2026 16:10
Tobias-Fischer/vinca@b1960a5 -- see that commit and the previous
ros-rolling commit's message for the actual fix and why it was needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pins to Tobias-Fischer/vinca@c974b23, which stops pre-finding
VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C/_CPP for packages that call
rosidl_generate_interfaces() themselves. Re-verifying the pipeline from
a clean rebuild surfaced a real bug: pre-finding the override backend
for a message-generating package makes it "already a target" before
that package's own typesupport discovery runs, which -- confirmed by
inspecting the built package's own ament_cmake_export_targets-extras.cmake
-- bumps its typesupport entry ahead of the generator target its own
Export.cmake requires. Every downstream find_package(<that package>)
then failed with "referenced, but are missing:
<pkg>::<pkg>__rosidl_generator_c(pp)", surfaced here on
builtin_interfaces (breaking service_msgs, which consumes it).

Also adds a sync-native-bootstrap-mirror task (wired into
build-emscripten's depends-on) instead of the same mirroring having to
be done by hand each time -- see its own comment for why the mirror is
needed at all (a build:-time dependency on a native
rosidl_default_generators that no channel actually publishes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…omment

The two documented workaround groups had gotten blurred together: the
"no override" group (falls back to introspection, fine since nothing
downstream needs their C backend specifically) and the "C-only" group
(rcl_interfaces, type_description_interfaces, service_msgs -- rmw_zenoh_pico
needs the C backend from these three specifically) are disjoint. Routing
a C-only package through the no-override rebuild "succeeds" (no build
error) but silently leaves its rosidl_typesupport_c dispatch table
pointing at introspection instead of microxrcedds -- caught this on
type_description_interfaces via `strings ... | grep
get_message_type_support_handle` showing introspection_c, not
microxrcedds_c, wired into the dispatch table. Also clarifies the C-only
group needs no separate no-override pass first: leaving CPP unset already
falls back to introspection_cpp on its own, sidestepping the same _Event
gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… at the source

Root-causes and fixes the gap that previously required rebuilding a
dozen-plus packages by hand with scoped typesupport overrides after the
main build-emscripten pass: rosidl_typesupport_microxrcedds_cpp's
srv__type_support_cpp.cpp.em (and its matching header template) never
generated typesupport for the auto-generated service/action "_Event"
message, unlike the C generator, which already handled it correctly. Any
package defining a service or action failed to compile as soon as
something needed get_message_type_support_handle<...Event>() for it.

Patched onto the same pinned commit this package already builds from
(patch/ros-rolling-rosidl-typesupport-microxrcedds-cpp.patch, alongside
the existing ament_cmake_ros_core/export-set fixes) rather than worked
around here. Upstream PR:
micro-ROS/rosidl_typesupport_microxrcedds#83.

Verified building the full closure in a single `pixi run build-emscripten`
pass with both typesupport overrides set globally (the "single approach")
-- action_msgs, rcl_interfaces, type_description_interfaces, test_msgs and
the rest of the previously-affected packages all build correctly now, with
the correct typesupport backend confirmed wired into their dispatch
tables. This removes the whole two-group scoped-rebuild workaround from
build-emscripten's own comment -- simplified accordingly, along with the
CI script and docs in the demo repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer added a commit to Tobias-Fischer/ros2-emscripten-zenoh-demo that referenced this pull request Sep 10, 2026
RoboStack/ros-rolling#46 patches rosidl_typesupport_microxrcedds_cpp's
own _Event codegen gap at the source now (upstream PR:
micro-ROS/rosidl_typesupport_microxrcedds#83) instead of needing a dozen
affected packages rebuilt by hand with different typesupport overrides
after the main build. A single `pixi run build-emscripten` pass now
builds the entire closure correctly, so build_ros_rolling_closure.sh only
needs to handle the (unrelated) native bootstrap mirror requirement --
loops sync+build until a pass adds nothing new, instead of the previous
two-group scoped-rebuild machinery.

Updates docs/demo_env.md and README.md's Known limitations to match --
the _Event gap is fixed, not worked around, so the reproduction steps and
limitations list no longer describe a workaround that doesn't exist
anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer and others added 3 commits September 10, 2026 20:15
cp output/emscripten-wasm32/*.tar.bz2 fails with "No such file or
directory" when that directory doesn't exist yet (a truly cold build, no
prior output/ at all) or is empty -- the shell has no files to expand the
glob against, so it's passed to cp as a literal, non-existent filename.
Every local test of this task happened to already have some previously-
built packages lying around, so this never showed up until the first
genuinely-cold CI run (RoboStack/ros-rolling has never had this task
before tonight) hit it immediately, on the very first pixi run
build-emscripten invocation, before a single package had been built.

find ... -exec cp, unlike a bare glob, does nothing (no error) when
nothing matches -- also adding mkdir -p for output/emscripten-wasm32
itself so the find has somewhere to look on the very first invocation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
rattler-index's default incremental update can leave repodata.json
pointing at a stale hash for a file whose content changed without its
name/version/build-string changing (e.g. rebuilding a package after
fixing a bug in it, same output filename each time) -- surfaced tonight
as a confusing "failed to fetch <package>" on a completely unrelated
package that merely depended on the one with the stale hash. --force
(full reindex instead of incremental) costs a fraction of a second even
on this whole ~230-package channel, so there's no real tradeoff here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
For the browser demo's upcoming teleop (geometry_msgs/Twist), camera
(sensor_msgs/Image), and GPS (sensor_msgs/NavSatFix) pages. Neither
package is a transitive dependency of anything already in this closure,
so both need selecting explicitly in vinca.yaml's wasm32
packages_select_by_deps, plus the same per-package microxrcedds add_host
entry every message package in this closure needs (see the new comment
in patch/dependencies.yaml -- there's no generic/wildcard rule, this is
genuinely per-package).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer added a commit to Tobias-Fischer/ros2-emscripten-zenoh-demo that referenced this pull request Sep 10, 2026
Publishes Twist on 'cmd_vel' at 10 Hz, driven by W/A/S/D or the arrow
keys. Verified end-to-end: keyboard event -> published Twist -> confirmed
via the zenohd router's own REST API (linear.x/angular.z match the
expected scaled values, zero when no keys are held).

The one real wrinkle: this build uses -sPROXY_TO_PTHREAD=1 (same as
talker_rclc, needed by rmw_zenoh_pico), so main() runs on a pthread Web
Worker, not the page's real main thread -- which has no window/DOM
access. Tried MAIN_THREAD_EM_ASM* first (the "proxy JS to the main
thread" mechanism this is normally for), but its macro internals turned
out too fragile for a multi-branch JS body to get through the C
preprocessor's stringification reliably. Landed on a more robust
approach instead: the keyboard listener lives entirely in plain JS in
index_teleop.html (already on the real main thread, no proxying needed),
writing directly into wasm linear memory -- a SharedArrayBuffer in a
pthreads build -- at an address teleop_get_state_ptr() exports once. The
worker-side timer callback just reads a C global directly on its 10 Hz
hot path, no JS call needed there at all.

Requires ccall and HEAPF64 explicitly added to
-sEXPORTED_RUNTIME_METHODS -- neither is exposed by default, and their
absence surfaces as a runtime abort, not a link/build error.

Also adds geometry_msgs to demo_env's own dependency list now that
RoboStack/ros-rolling#46 builds it (see that repo's own commit for adding
it to the wasm32 closure in the first place).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sync-native-bootstrap-mirror mirrored built emscripten-wasm32 packages
into a directory literally named output/osx-arm64 -- right for local dev
on this project's own Apple Silicon Mac, but rattler-build's solver only
ever looks at the channel subdirectory matching build_platform, which is
whatever machine is *actually running the build*. On this repo's own CI
(ubuntu-latest, i.e. linux-64), the mirror silently wrote into a
directory the solver never looked at, so the entire rest of the closure
past the handful of "core" packages that don't need the native
rosidl_default_generators bootstrap never got past "No candidates were
found for ros2-<package>" -- confirmed as the actual first-ever CI
failure of the corrected pipeline, 2026-09-10.

sync_native_bootstrap_mirror.sh now detects uname -s/-m and mirrors into
the matching platform directory (osx-arm64, osx-64, linux-64, or
linux-aarch64) instead. Also had to add a .gitignore negation for the
new script -- caught myself about to repeat the exact same mistake this
repo's own .gitignore already documents (a blanket *.sh rule meant for
vinca-generated recipe scripts silently swallowing a genuine, hand-written
one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer added a commit to Tobias-Fischer/ros2-emscripten-zenoh-demo that referenced this pull request Sep 10, 2026
…oyment

Two real gaps this closes, not just visual polish:

- None of the new demo pages (teleop, GPS, camera, IMU, robot_view) had
  any deployment path at all -- deploy.yml's asset-copying step only ever
  copied non-HTML files from browser_demo/out/ into _pages/assets/ (for
  the two talkers embedded directly into the main site via demo.js's "Run
  demo" buttons). The new pages, and the existing standalone
  index_rclc.html/index_rclpy.html, were never reachable on the live
  site. Now the whole browser_demo/out/ directory (HTML alongside its
  .js/.wasm/.so files, self-contained) also ships as its own demos/
  subtree.
- site/index.html's "Known limitations" section still described the
  rosidl_typesupport_microxrcedds_cpp _Event codegen gap as a live
  workaround (introspection-only fallback / scoped C-only rebuilds) --
  fixed at the source in RoboStack/ros-rolling#46 earlier tonight, so
  that bullet no longer describes anything that actually happens anymore.

Styling: browser_demo/demo-page.css reuses the same design tokens as
site/style.css (dark theme, teal/purple accents) but as its own
self-contained file, since these pages deploy into a separate subtree
from the main site rather than sharing a directory with it. Mobile-first
--- GPS and phone-tilt are meant to be opened on an actual phone -- with
a shared nav between all seven demo pages and 44px-minimum tap targets.
Verified visually at both desktop and 375px mobile viewport widths.

Also adds a "More demos" section to site/index.html linking to all five
new pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer and others added 3 commits September 11, 2026 07:26
…usercontent.com URLs

CI started failing recipe generation outright: vinca -m --platform
emscripten-wasm32 crashed fetching ros2cli's pinned release package.xml
with a 404, because raw.githubusercontent.com's short <ref>/<path> URL
form can't tell where a slash-containing tag name
(release/rolling/ros2cli/0.41.1-1) ends and the path begins -- a guess
that's cached inconsistently across CDN edges and 404s from some
vantage points (GitHub Actions runners included) while resolving fine
from others.

Cherry-picked the fix (vinca@9e44663, from a separate branch, not a
direct ancestor) onto the tip of our feature/emscripten-pthreads branch
rather than just re-pointing the pin there directly, since that branch
lacks the emscripten pthreads/demangle/typesupport-override fixes this
whole pipeline depends on. Fast-forward push, so nothing on the branch
was rewritten. Verified locally: the same URL that 404s with the short
form resolves (200) with refs/tags/, and a full `vinca -m --platform
emscripten-wasm32` run now generates all 233 recipes without error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sm32

rmw_zenoh_pico_set_unicast() (and set_mode()/get_mode()) aren't part of
the standard rmw.h ABI surface, so nothing inside this library or a
normal ROS 2 caller ever references them -- unlike the standard rmw_*
functions (looked up by rmw_implementation via dlopen+dlsym, so
implicitly kept live), wasm-ld's side-module build drops anything with
zero references from the dylink export table. Confirmed via
wasm-objdump: the built .so exported every standard rmw_* symbol but
none of these three.

A wasm32 app that wants to override the compiled-in zenoh connect
address at runtime (127.0.0.1:7447 -- see
ros2-emscripten-zenoh-demo's browser_demo/, which now does exactly
this) needs rmw_zenoh_pico_set_unicast() actually callable. Explicit
-Wl,--export= for the three extension functions, scoped to this one
target and gated on EMSCRIPTEN, fixes it -- verified with a scoped
local rebuild (wasm-objdump now shows all three exported) and an
end-to-end browser_demo test overriding the connect address via a URL
param.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…h_pico gaps; deterministic two-pass build

Real pthreads required WebAssembly shared memory, which is viral across
every dlopen'd module and deadlocked xeus-python's own thread model when
loading rclpy in JupyterLite. Switches to Asyncify: rmw_zenoh_pico's
rmw_wait() now polls zenoh-pico's Z_FEATURE_MULTI_THREAD=0 transport
instead of blocking on a real condition_variable.

Also fixes surfaced along the way:
- rmw_zenoh_pico: missing pthread_condattr_t declaration (needed even
  without real pthreads) and a wasm-ld export gap for
  rmw_zenoh_pico_set_unicast/set_mode/get_mode.
- rosidl_typesupport_microxrcedds_cpp: re-point to the fork with the
  Event-message typesupport codegen fix (upstream PR
  micro-ROS/rosidl_typesupport_microxrcedds#83).
- rcutils/rcl_yaml_param_parser: drop patches upstream has since
  absorbed natively.
- geometry_msgs/sensor_msgs added to the wasm32 closure.

Replaces the old sync-native-bootstrap-mirror "loop until package count
stabilizes" with a fixed two-pass build-emscripten (build, mirror, build)
-- nothing in this recipe set nests the native-mirror workaround more
than one level deep, so the loop was solving a problem that doesn't
exist; verified by clearing the mirror-dependent packages and confirming
a full rebuild converges in exactly the two passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer added a commit to Tobias-Fischer/ros2-emscripten-zenoh-demo that referenced this pull request Sep 11, 2026
…link error

Real pthreads deadlocked the JupyterLite kernel (xeus-python) when
loading rclpy -- shared memory is viral across every dlopen'd module,
and xeus-python's own thread model can't tolerate a blocking wait.
Matches the Asyncify-based rmw_zenoh_pico fix in RoboStack/ros-rolling#46:
all seven browser_demo build scripts drop -pthread/USE_PTHREADS/
PROXY_TO_PTHREAD in favor of -sASYNCIFY, and override EMCC_CFLAGS to
drop the toolchain's own injected -fwasm-exceptions (incompatible with
Asyncify).

demo_env no longer needs a custom pthreads-patched CPython/numpy build
(EMSCRIPTEN_FORGE_OUTPUT channel) -- stock emscripten-forge-4x python
works now that rcl/rclpy don't require --shared-memory. Also drops the
now-nonexistent numpy _simd module from build_rclpy.sh's link list
(numpy 2.5.3 folds its SIMD dispatch into _multiarray_umath).

_serve_local.py now sends a content-hash ETag on every response so a
rebuild is always picked up on the next load, instead of needing
several manual clear-site-data-and-reload cycles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer added a commit to Tobias-Fischer/ros2-emscripten-zenoh-demo that referenced this pull request Sep 11, 2026
deploy.yml / build_ros_rolling_closure.sh / demo_env.md described the
now-reverted pthreads approach (see RoboStack/ros-rolling#46 and
RoboStack/vinca#154):

- Drop the "build pthreads python + numpy" CI step entirely -- demo_env
  now pulls stock python/numpy straight from emscripten-forge-4x, since
  rcl/rclpy no longer need --shared-memory. Keep only the
  cross-python_emscripten-wasm32 toolchain package build ros-rolling's
  own recipes still need (unrelated to pthreads -- a build-time
  cross-compile tool, not the target python/numpy build), via the
  fork's dedicated build-cross-python-pkg task.
- build_ros_rolling_closure.sh: ros-rolling's own build-emscripten task
  is now a deterministic two-pass sequence internally, so this script's
  external "loop sync+build until nothing new appears" is redundant --
  simplified to a single build-emscripten call. Also fixes the same
  compgen -G false-positive bug found in local testing (returns success
  with zero matches in some shells) by switching to find.
- Remove the EMSCRIPTEN_FORGE_OUTPUT env var (dropped from
  demo_env_build/build.sh already) and the now-deleted
  rosidl-typesupport-microxrcedds-cpp local patch reference.
- Correct the COI-serviceworker comments that attributed the need for
  cross-origin isolation specifically to xeus-python's pthreads runtime
  -- xeus-python here was never rebuilt with pthreads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer added a commit to Tobias-Fischer/ros2-emscripten-zenoh-demo that referenced this pull request Sep 11, 2026
…tead of static-linking them

rclpy_boot.c statically linked every ROS .so, numpy, and rclpy's own
pybind11 module directly into one MAIN_MODULE=2 executable via
PyImport_AppendInittab(), plus a statically-linked libpython3.13.a --
a workaround for the (now-reverted, see RoboStack/ros-rolling#46)
pthreads-enabled CPython build, which couldn't safely use
--enable-wasm-dynamic-linking. Combined with Asyncify, wasm-ld crashed
outright (SIGSEGV, relocating a CustomSection) linking that many .so's
worth of relocations into one binary -- reproduced deterministically,
independent of --allow-multiple-definition, threading, embed-file size,
or ASYNCIFY_ONLY scoping.

Now that plain (non-pthreads) CPython is in use, none of that static
embedding is needed: rclpy_boot.c is a plain Py_Initialize() +
PyRun_SimpleString() boot stub, and Python's own `import` machinery
dlopen()s _rclpy_pybind11.so and everything else from site-packages at
runtime instead -- the same mechanism jupyterlite-xeus's xeus-python
kernel already uses for this exact rclpy build. This removes the
crash entirely and gets significantly further: every ROS .so
(librcl.so, librmw.so, the microxrcedds typesupport chain, etc.)
dlopens successfully.

Needed along the way:
- openssl added to demo_env (Python's statically-built _ssl module
  needs libcrypto/libssl symbols that were never linked before).
- wasm_link_stubs_rclpy.c repurposed: was rmw_get_*() graph-
  introspection stubs (needed when ROS .so's were linked directly);
  now stubs three legacy TLS 1.0/1.1/1.2 SSL_METHOD constructors
  emscripten-forge-4x's openssl build doesn't provide (never actually
  called -- this demo only ever uses plain ws://, not wss://).
- EMCC_FORCE_STDLIBS (scoped to libc/libc++/libc++abi, not "1" for
  literally everything -- that also drags in unrelated WebGPU stub
  bindings) plus an explicit EXPORTED_FUNCTIONS list: this module's own
  code barely touches libc directly, so without forcing and exporting
  them, symbols the *dlopen'd* side modules need at runtime (stdio
  globals, libc++'s exception-handling/RTTI machinery pybind11 needs to
  translate a thrown C++ exception into a Python one) never survive
  MAIN_MODULE's dead-code elimination.

Not yet resolved: `import rclpy` still fails with a generic "unknown
dlopen() error" specifically on _rclpy_pybind11.so itself (the largest,
most symbol-heavy of the ~190 side modules) -- see build_rclpy.sh's own
comment for the current state and suggested next step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer and others added 19 commits September 13, 2026 21:21
…TS.md

Full narrative of this session's investigation: the "disassembled the
actual trap site" confirmation that Asyncify + runtime dlopen() of a
SIDE_MODULE is a genuine, unresolved upstream limitation; the "MAJOR
PIVOT" to dropping Asyncify project-wide and using stock python/xeus/
numpy/pyjs packages; the zenoh-pico dylib-link and rmw_zenoh_pico
CMake-export fixes that got rclpy.init() to succeed; the full
resumable-session-open-handshake build-out (persistent fd, phase-tracked
Init/Open handshake, the Z_LINK_CAP_FLOW_DATAGRAM/-119 root cause) that
got Node() construction working end-to-end; the rclpy QoS-event
workaround; the session-lease-expiry false alarm around a second Node();
and the still-open message-delivery bug (zenoh-pico's write-filter
silently dropping all but the first published message, most likely via a
spurious CONNECTION_DROPPED interest event) with the exact
file/function/mechanism pinned down for whoever continues it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…b/sub delivery

Root-caused the "only ever received count: 0" message-delivery bug: this
project's rmw_zenoh_pico publisher and subscriber share one process-wide
zenoh session, so the router correctly never echoes data back to its own
originating face -- same-session local delivery is a separate, opt-in
zenoh-pico feature (Z_FEATURE_LOCAL_SUBSCRIBER/Z_FEATURE_LOCAL_QUERYABLE)
that was never enabled. Enabling both (zenoh-pico build.sh) and verifying
via printf tracing all the way down the call stack (now removed again)
confirms local pub/sub now genuinely dispatches, with byte-for-byte
correct CDR-encoded data, to the matching local subscription.

This surfaces a new, separate bug one layer up: rmw_zenoh_pico's
microxrcedds-based string typesupport never grows the destination
string buffer to fit an incoming variable-length string, so rmw_take()
fails with "Typesupport deserialize error" the moment any non-empty
string message is actually received on the wasm side (never previously
exercised, since nothing had ever reached rmw_take() there before this
fix). Root-caused and documented in AGENTS.md and
rmw_zenoh_pico's pkg_additional_info.yaml entry (builds 29-31, tracing
left in place); not yet fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root-caused and fixed the "Typesupport deserialize error" bug found
immediately after last session's zenoh-pico local-delivery fix started
actually exercising rmw_take() on the wasm side for the first time ever.

rosidl_typesupport_microxrcedds_c's generated _cdr_deserialize() calls
ucdr_deserialize_sequence_char() with the destination string's
pre-existing capacity -- but micro-CDR's own capacity check only sets an
out-of-band error flag, it does not stop the subsequent unconditional
memcpy of the full incoming length into the destination. Since rclpy
always constructs messages with String__init()'s default 1-byte "empty
string" buffer before calling rmw_take(), any non-empty incoming string
overflowed that 1-byte heap allocation -- a real, silent memory
corruption bug, not merely a rejected/truncated string as the existing
fallback code assumed.

Fixed by patching rosidl_typesupport_microxrcedds_c's own .em codegen
template (patch/ros-rolling-rosidl-typesupport-microxrcedds-c.emscripten.patch,
new vinca-auto-discovered patch) to peek the incoming length via a
throwaway ucdrBuffer cursor copy and realloc() the destination to fit
before the real deserialize call, for both single-string and
sequence-of-strings members.

Rebuilt rosidl_typesupport_microxrcedds_c (28) and std_msgs (27) to pick
up the fix, then confirmed genuinely end-to-end in a real browser run:
all 39 published messages are now received and correctly deserialized
by the wasm-side subscriber (received count: 39) -- the first fully
working, non-Asyncify ROS2 pub/sub loop in this project's history. All
temporary debug tracing added while diagnosing this (in both zenoh-pico
and rmw_zenoh_pico) is removed (rmw_zenoh_pico build 32).

Also flagged (not fixed, see spawned follow-up task): a pre-existing,
unrelated duplicated hunk in patch/ros-rolling-rmw-zenoh-pico.patch that
makes it fail strict `git apply --check` against a fresh clone, even
though rattler-build's own fuzzier patch tool has applied it correctly
in every build this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
rosidl-generator-py's and spdlog-vendor's emscripten patches both had
comments explaining why they work in terms of the project's OLD
pthreads/Asyncify-based build (e.g. "our USE_PTHREADS=1 SIDE_MODULE",
"the atomics/bulk-memory wasm features our pthread-enabled build
requires") -- wording left over from before the project dropped
pthreads/Asyncify entirely. The underlying mechanisms (skip linking a
static libpython3.a into every side module; forward CMAKE_PROJECT_INCLUDE
so a nested ExternalProject build gets the same SIDE_MODULE flags) are
still correct and needed, so only the comment text is reworded to
describe the actual reason accurately for the current no-pthread build.
No functional change; rebuilt both packages to confirm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pten patch

Found while auditing this project's emscripten patches for pthreads-era
leftovers: an unconditional `set(CMAKE_CXX_FLAGS "-pthread
${CMAKE_CXX_FLAGS}")` for EMSCRIPTEN, left over from before this project
dropped pthreads/Asyncify entirely. This whole build is pthread-free now,
so adding -pthread to only this one package's flags was actively wrong
(exactly the kind of atomics/bulk-memory ABI mismatch this project's own
zenoh-pico/rmw_zenoh_pico bugs earlier this session were caused by).

The same hunk's removal of the include(CTest)/BUILD_TESTING/googletest
block turned out to still be genuinely necessary (verified by testing
the alternative): this package builds via build_catkin.sh, whose
emscripten branch never loads Emscripten.cmake's own toolchain file, so
CMake's `EMSCRIPTEN` variable is never actually set here -- confirmed
empirically when an `if(NOT EMSCRIPTEN)` gate (tried first) still built
the whole googletest/test_runner suite and hit the exact
--shared-memory/atomics link error the stale -pthread flag was
originally added to paper over. Kept as an unconditional skip, matching
what the existing patch already did, and documented why in
pkg_additional_info.yaml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…was silently never compiled

patch/ros-rolling-rmw-zenoh-pico.patch had accumulated a duplicated hunk
in its CMakeLists.txt diff (the -Wl,--export=... comment/hunk content
appeared twice, with mismatched line-count headers), throwing off strict
git apply's hunk-position tracking badly enough that rmw_wait.c's last
hunk failed silently.

That hunk is the unconditional zp_read()/zp_send_keep_alive() network
pump at the top of rmw_wait() -- the fix this project's own AGENTS.md
credits with resolving the session-lease-expiry hang. rattler-build's
own fuzzier patch tool tolerated the CMakeLists.txt duplication (merging
it back into one correct copy) but silently rejected the rmw_wait.c
hunk to a .rej file with no hard build failure -- meaning that fix was
never actually compiled into any build, confirmed by diffing a correct
from-scratch apply against what every prior build actually used.

Regenerated the whole patch cleanly from a fresh pristine clone (every
existing change reapplied by hand, including manually restoring the
missing hunk), verified with a strict `git apply --check`. Rebuilt
(build 33) and re-ran the full end-to-end browser demo as a regression
check: still 39/39 messages received.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d Asyncify

Both packages' build 25 was produced 2026-09-12, in the window between
vinca commit 69fa7de (re-enabling Asyncify) and f6c8903 (dropping it
project-wide again) -- so their .so's still import __asyncify_state/
__asyncify_data even though every other package in this build is
Asyncify-free. Bumped build_number to force a rebuild against the
current template; no patch changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
micro-CDR's own CMakeLists.txt defaults UCDR_ISOLATED_INSTALL to ON,
appending "/${PROJECT_NAME}-${PROJECT_VERSION}" onto
CMAKE_INSTALL_PREFIX -- every other package here installs flat under
$PREFIX/lib, $PREFIX/include, but microcdr's own landed nested under
$PREFIX/microcdr-2.0.2/lib, $PREFIX/microcdr-2.0.2/include instead.

This broke two things downstream: browser_demo/build_rclc.sh and
build_rclpy.sh both needed this one package special-cased to a
different path than every other .so they link/copy, and -- the actual
bug this was chasing -- jupyterlite-xeus's own kernel-package eager-
preload logic has no idea to look in a nested directory at all,
producing a real 404 on libmicrocdr.so.2.0.2 in the live deployed
JupyterLite kernel (confirmed: rmw_zenoh_pico's own dylink dependency
chain needs this exact SONAME, and the flattened kernel_packages
extraction jupyterlite-xeus does for every other package left this one
unreachable).

-DUCDR_ISOLATED_INSTALL=OFF fixes it at the source. Bumped build_number
1 -> 2; nothing links against microcdr in a way that requires
rebuilding dependents (dylink resolution is by SONAME string, not
baked-in path).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
rclpy.shutdown() crashed with an uncaught "RuntimeError: memory access
out of bounds" wasm trap in the deployed JupyterLite kernel, reproduced
deterministically even with no publisher/subscriber ever created, right
after a plain destroy_node() -- see
Tobias-Fischer/ros2-emscripten-zenoh-demo's commit reverting its
demo.ipynb workaround for the investigation that led here.

Root cause, confirmed via an isolated minimal wasm repro that
reproduces the exact same trap from nothing but this one pattern:
zenoh_pico_generate_session() (zenoh_pico_session.c) did

    session->graph_guard_condition.data = zenoh_pico_guard_condition_data;

-- assigning the bare FUNCTION zenoh_pico_guard_condition_data (a code
pointer) instead of CALLING it (zenoh_pico_guard_condition_data()) to
get the heap-allocated ZenohPicoGuardConditionData* it actually
allocates and returns. Dormant during normal operation, since
graph_guard_condition.data is never touched except at session
teardown -- but zenoh_pico_destroy_session() later casts this "pointer"
back to ZenohPicoGuardConditionData* and dereferences/frees it. On
Emscripten, code and linear memory are separate address spaces, so
that read lands miles outside the heap: a wasm trap, not some
platform-agnostic C-level UB that happens to limp along on other
targets (a native ASan build of the same session-open/close sequence,
built to rule out an emscripten-specific zenoh-pico transport bug
first, ran clean -- this is what actually narrowed it down to
rmw_zenoh_pico's own code instead).

This is a genuine upstream bug in esol-community/rmw_zenoh_pico, not
something introduced by this fork's own Emscripten patches -- worth
reporting upstream too.

Verified end-to-end: rebuilt ros2-rmw-zenoh-pico with this fix,
swapped the patched librmw_zenoh_pico.so into a real JupyterLite build,
ran rclpy.init() -> Node() -> publisher/subscriber -> destroy_node()
-> rclpy.shutdown() against a real zenohd router -- "shut down
cleanly" prints, execute_reply status "ok", no crash.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
That section's own header read "active sub-project" and its opening
paragraph described dropping pthreads for Asyncify as the current plan --
but the file's own later "MAJOR PIVOT" section abandoned Asyncify too, in
favor of the non-blocking/no-real-threads design actually shipped today.
A reader skimming headers would land on the wrong mental model. Added a
pointer to the real current state and fixed the branch name reference
(feature/emscripten-wasm32-zenoh-pico -> pico-update-tmp) rather than
rewriting ~2500 lines of historical journal.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Z_FEATURE_MULTI_THREAD==0 poll loop in rmw_wait() slept between
polls via z_sleep_ms() -- but that function is a hard no-op on this
platform (this project dropped Asyncify project-wide; see
emscripten-nonblocking-sleep.patch), so "sleeping between polls" was
really spinning zp_read()/zp_send_keep_alive() as fast as possible with
zero yield back to the browser event loop, for up to the full requested
timeout. Worse, with wait_timeout == NULL ("wait indefinitely"), the
loop had no exit condition other than becoming triggered -- a genuine
unbounded hang if whatever it's waiting for never arrives.

Nothing in this project currently calls rmw_wait with a nonzero timeout
(rclpy.spin_once(timeout_sec=0) everywhere), which is the only reason
this never surfaced in testing -- but it was a real landmine, not just
a stale comment.

A synchronous call on this platform can never usefully wait longer than
a single attempt anyway -- only the caller, across separate top-level
calls with a real yield in between, can actually retry-with-delay. So
this now always behaves like the old timeout_ms == 0 case regardless of
what's requested: poll exactly once (zp_read + zp_send_keep_alive),
then return immediately. Removes the now-dead
RMW_ZENOH_PICO_SINGLE_THREAD_POLL_MS constant and ~25 lines of loop/
timeout bookkeeping along with it.

Verified against a real zenohd router: rclpy.spin_once(timeout_sec=2.0)
now returns in a bounded, predictable way (no C-level busy-spin) instead
of risking never returning at all; the timeout_sec=0 path everything
else in this project actually uses is unaffected (verified via a full
Node()/pubsub/shutdown run, all still clean).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…PORTED

rmw_zenoh_pico (build 34): rmw_publisher_event_init()/
rmw_subscription_event_init() now return RMW_RET_UNSUPPORTED instead of
RMW_RET_INCORRECT_RMW_IMPLEMENTATION when support_event_type() finds no
match -- which, since this RMW's own _support_event[] table is
permanently empty upstream (no gid_cache), is every event type, always.
rclpy's own event_handler.py already treats RCL_RET_UNSUPPORTED as
"silently no-op the default callback", but the wrong code surfaced as a
raw RCLError instead -- exactly why every demo needed
use_default_callbacks=False explicitly. Verified live end-to-end
against the real compiled wasm binary: create_publisher() with no
event_callbacks argument (the real rclpy default) no longer raises.

rclpy (build 29): rclpy.spin(node) can't literally block in a real
pyjs/xeus-python kernel (JupyterLite) -- no pthreads, no Asyncify means
nothing can yield to the browser's event loop from inside one
still-running synchronous call, so a blocking while-loop would freeze
the tab forever. Adds a new _emscripten_webloop_active() check
(sys.platform == 'emscripten' AND pyjs.webloop.WebLoop is genuinely the
active event loop) that, only in that specific context, schedules the
same non-blocking spin_once() as a background asyncio task instead and
returns immediately -- ported from ros2-emscripten-zenoh-demo's own
already-shipped spin_async() notebook cell, folded into rclpy itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…agnosed

Both rclpy/node.py workarounds added in build 28 turned out to be
unnecessary once actually re-tested live:

- The /parameter_events publisher's use_default_callbacks=False was only
  ever needed because of the same rmw_event.c bug already fixed in
  rmw_zenoh_pico build 34 (RMW_RET_UNSUPPORTED instead of
  RMW_RET_INCORRECT_RMW_IMPLEMENTATION) -- confirmed live that rclpy's
  own unmodified default now works for this internal publisher too.

- TypeDescriptionService never actually hung at construction. That
  conclusion came from grepping the wrong upstream checkout (a trimmed
  release branch under a shared repo name that happened to hold
  rcl_action, not rcl, so rcl's real
  rcl_node_type_description_service_init() was never seen). Restoring
  TypeDescriptionService(self) and testing live: Node() constructs fine
  and the demo keeps publishing continuously. A real, narrower gap exists
  one level in -- a client actually calling ~/get_type_description never
  gets its response back, even though the server receives and processes
  the request correctly. That's a general rclpy client-response-delivery
  gap on this platform (the same executors.py code path any service's
  client would use), not something TypeDescriptionService construction
  itself triggers.

Bumped to build 30 to force a fresh identity.
templates_2_cmake.py (ament_cmake_core's own CMake configure step) runs
under $BUILD_PREFIX's native python during cross-compilation and does
`from ament_package.templates import ...`, but ament_package was only
ever declared as a host dependency (installed into the emscripten-wasm32
target env) -- confirmed via a real from-scratch build failing configure
with "ModuleNotFoundError: No module named 'ament_package'".

This had gone unnoticed because CI's ros-rolling-emscripten-output cache
(keyed on patch/recipe content hash) has apparently always hit before
now, so ament_cmake_core was never actually rebuilt fresh in Actions;
building it directly here reproduced it immediately and identically on
every attempt.

ros2-ament-package is pure Python (no compiled extension), so the same
wasm32-built package already produced here works fine as a native
build:-time dependency once mirrored into the build_platform channel --
the same sync-native-bootstrap-mirror mechanism this repo already uses
for ros2-rosidl-default-generators/ros2-rosidl-core-generators. Verified
directly: rebuilt ros2-ament-package, ran sync_native_bootstrap_mirror.sh,
then rebuilt ros2-ament-cmake-core fresh -- configure now succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…level instead

The previous commit's fix (declaring ros2-ament-package as a build:-time
dependency, resolved via the existing sync-native-bootstrap-mirror
mechanism) resolved correctly in the solver but still left
ament_cmake_core's own CMake configure step unable to `import
ament_package` on a real Linux CI run -- confirmed by reproducing it
there after the fix was already in place. Root cause: ros2-ament-package
isn't a noarch package (nothing in this project's recipes is), so
installing a package built for one platform's subdir into a different
platform's build_env as a build:-time dependency doesn't reliably make
its Python content importable, even though the solver is happy to
resolve it -- unlike the CMake-only, near-empty ros2-rosidl-default-
generators/ros2-rosidl-core-generators packages this same mirror
mechanism already handles correctly (those just need to satisfy a
find_package(), not actually be imported by a running interpreter).

Fixed instead at the actual point of use: Tobias-Fischer/vinca@58c1574
(rev bumped below) makes build_ament_cmake.sh.in export PYTHONPATH so
$BUILD_PREFIX's native python can read $PREFIX's site-packages directly
during cross-compilation -- sidesteps the whole native-mirror-subdir
question, since it's plain environment-variable visibility, not a conda
package install. Verified directly: rebuilt ros2-ament-cmake-core from
scratch with the new vinca pin, no native-mirror dependency needed at
all -- configure succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit bumped pixi.toml's vinca rev but never regenerated/
committed pixi.lock to match -- CI kept resolving the old, pre-fix vinca
commit from the stale lock file, so the ament_package PYTHONPATH fix
never actually took effect there despite working locally (pixi install
had already regenerated this file locally, just not committed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Chasing why the ament_package PYTHONPATH fix (58c1574) works in a local
osx-arm64 rebuild but still fails identically on Linux CI. See
Tobias-Fischer/vinca@e64aa1b for what's being logged.
Tobias-Fischer/vinca@55cb379 fixes the actual root cause (pip installs
during cross-compiled ament_python builds not using PIP_ARGS, so
ros2-ament-package's real content never reached $PREFIX on Linux) that
the PYTHONPATH-only fix (58c1574) didn't fully address. Verified locally,
end to end: rebuilding ros2-ament-package from scratch now produces an
archive actually containing lib/python3.13/site-packages/ament_package/,
and ros2-ament-cmake-core configures successfully against it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer and others added 7 commits September 15, 2026 20:49
… response

attachment_sequence_num_inc() is a post-increment -- it returns the OLD
value of client_data->attachment.sequence_num and only then bumps the
field. rmw_send_request() used that returned value as *sequence_id (the
id rcl/rclpy key pending requests by), but the very next call,
attachment_gen(), always serializes the struct's CURRENT (already-
bumped) field onto the wire. So *sequence_id was permanently one behind
whatever sequence number actually went out in the request, and therefore
one behind whatever the eventual reply's echoed request_id.sequence_number
reports.

rclpy's own Client.call_async() keys its pending-request dict by
*sequence_id. When a reply arrives, executors.py looks up
_pending_requests[reply.sequence_number] -- which, because of this
off-by-one, was never a real key. That's a plain KeyError, silently
swallowed by `except KeyError: pass`, so the caller's future simply never
resolves. This is completely general: every rclpy service client on this
RMW was affected, not specific to any one service.

Root-caused by adding temporary diagnostics at every step of the
request/response path (send, wait-set attach/detach, deserialize,
sequence numbers logged on both the send and receive side) until the
exact mismatch (sent seq=0, echoed-back seq=1) was directly observed in
a live browser run. Fix: read *sequence_id back from the struct field
*after* incrementing, so it matches exactly what attachment_gen() is
about to embed. Verified live end-to-end: a client's future now resolves
with the actual deserialized response.

Upstream PR: esol-community/rmw_zenoh_pico#11

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a session-wide liveliness discovery subscriber (new
zenoh_pico_graph_cache.c) that matches locally-declared publishers/
subscriptions against discovered remote peers on the same topic,
driving the existing (previously unreachable) rmw_take_event()
machinery for REQUESTED/OFFERED_QOS_INCOMPATIBLE and
SUBSCRIPTION/PUBLICATION_MATCHED, and implementing
rmw_publisher_count_matched_subscriptions()/
rmw_subscription_count_matched_publishers() (previously
RMW_RET_UNSUPPORTED stubs).

Along the way, fixes three previously-dormant upstream bugs this
feature is the first thing to ever exercise: a broken #ifndef guard
that silently disabled all event bookkeeping project-wide, a
copy-paste nested loop in rmw_wait() whose `return true;` made the
whole call fail with RMW_RET_ERROR on any ready event, and an
event-status array zeroed with the wrong sizeof. Also adds
EventStatus.last_policy_kind (defaulted to RMW_QOS_POLICY_INVALID) so
rclpy's default incompatible-QoS callback doesn't crash on unset
state.

Verified live end-to-end against a real zenohd/WebSocket transport:
matched-subscription/publisher counts correctly increment on peer
discovery and decrement back to 0 on peer loss. See
pkg_additional_info.yaml's rmw_zenoh_pico entry (build 36) for the
full writeup, including two known, out-of-scope gaps found during
verification (an rclpy-level threading/coroutine issue, and a
harmless spurious-warning path in rcl's own wait-set plumbing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
rmw_wait.c was casting events->events[i] directly to DataEventManager*
-- but that array holds the rmw_event_t* handle itself (rcl's own
rcl_wait_set_add_event() explicitly overwrites the correct
rmw_handle->data value with rmw_handle one line later; every other rmw
implementation, e.g. rmw_fastrtps, agrees this is the real contract).
Reinterpreting an unrelated rmw_event_t's bytes as a DataEventManager
meant total_count/current_count/changed read garbage that almost never
happened to be a real "not changed" state, so every registered event
looked permanently ready regardless of anything graph_cache.c did.

This was the actual cause of both gaps build 36 left open:
- The rclpy _WorkTracker/threading.Condition crash was a downstream
  symptom of an event that never legitimately quiesced re-triggering
  rclpy's coroutine dispatch far more aggressively than any real event
  would.
- The spurious "New subscription discovered ... requesting incompatible
  QoS" warning on a plain, unmatched publisher came from the same
  garbage-as-changed misread.

Also fixes a second, smaller bug found while verifying: rmw_take_event()
reported "taken=true" unconditionally, even when the *specific* event
type it was asked about hadn't itself changed (only some other type
sharing the same DataEventManager had) -- gated on the type's own
`changed` flag first, matching standard rmw_take_event() semantics.

Verified live: 300+ spin_once() ticks with two real publisher/
subscription pairs and rclpy's fully-default (unmodified) event
callbacks active throughout -- zero crashes, zero spurious callback
fires, matched-status counts correct throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The rclpy build-30 note documented a real gap: a client calling this
node's own ~/get_type_description service never got its future to
resolve. Re-tested the exact scenario against the currently-shipped
rmw_zenoh_pico (build 37) -- it now resolves cleanly. Root cause was
rmw_zenoh_pico's own rmw_send_request() reading attachment_sequence_num_inc()'s
already-bumped field instead of the pre-increment value it returns, so
the sequence_id handed back to rclpy's Client.call_async() never
matched the one actually embedded on the wire -- every reply's
lookup KeyError'd, silently swallowed by executors.py. That fix
predates the QoS-matching work in builds 34-37; this note just never
caught up.

Also documents a distinct, more far-reaching bug found while
re-verifying this: rosidl_typesupport_microxrcedds_c's generated
cdr_deserialize() can never successfully deserialize an unbounded
sequence field (confirmed with byte-identical wire data across both
ends) -- affects any message with e.g. a plain `string[]` field, not
just this one. Left unfixed for now (a rosidl_typesupport_microxrcedds
codegen bug, not this project's own), but documented precisely so it's
not lost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…quence<string>

Sibling bug to build 28's per-string-element capacity fix, one level
up: an unbounded sequence<string> member (e.g.
rcl_interfaces/GetParameters.Request's `string[] names`) has its own
outer capacity -- how many String elements the sequence itself holds,
separate from each element's own byte capacity. The generated
deserialize code gated entry to the per-element loop (where build 28's
fix lives) with `if (size > ros_message->names.capacity) { return 0; }`
and never grew that capacity -- and a freshly-constructed destination
(the normal case: rcl_take_request_with_info()'s own create_from_py()
building an empty request before the real wire size is known) always
starts at capacity 0, so any non-empty unbounded sequence<string>
failed unconditionally, never even reaching build 28's fix.

Confirmed with byte-identical, correctly-formed wire data on both
ends (hex-dumped) still failing cdr_deserialize(); a request built
only from scalar fields (lifecycle_msgs/ChangeState) round-trips
correctly by contrast, isolating the bug to sequence-of-string.

Fixed by growing the destination with
rosidl_runtime_c__String__Sequence__fini() + __init(&seq, size)
before the loop -- fini() is a safe no-op on an already-empty
sequence, so this also handles a reused, non-empty destination
without leaking its old element buffers. Scoped to sequence<string>
only, matching build 28's own scope; sequence-of-namespaced-type has
an analogous gap but needs the *other* message package's own
generated Sequence__init(), which this file doesn't link against --
left as a documented follow-up rather than risking a new link-time
bug.

Verified live: a real rclpy service request carrying
names=['foo', 'bar', 'baz'] is received and deserialized correctly
end-to-end; the plain rclpy talker demo (scalar std_msgs/String only)
still publishes continuously with no regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…spacedType>

Two more fixes, both natural extensions of work already shipped:

rmw_zenoh_pico: rmw_service_server_is_available() was an unconditional
RMW_RET_UNSUPPORTED stub -- hit directly this session testing rclpy's
Client class: service_is_ready()/wait_for_service() both raise RCLError
instead of returning False. Implemented via the same liveliness-
discovery graph cache (zenoh_pico_graph_cache.c) PR RoboStack#12 already built
for QoS matching: local Clients now register themselves the same way
Publishers/Subscriptions do, and the liveliness sample handler also
recognizes "SS" (service) tokens now, matching them against local
Clients by topic_name/topic_type and updating a plain available_services
counter instead of running the Publisher/Subscription-specific MATCHED
event logic. The existing code's own comment claiming service/client
liveliness keys "have fewer segments" (and so could never reach the
matching logic at all) was simply wrong -- confirmed both entities
carry the same topic_info segments a publisher/subscription does, via
the same zenoh_pico_generate_topic_info() call. Verified live: a
client created before any matching service exists correctly reports
service_is_ready()=False; creating the matching service flips it to
True within a single tick; destroying that service flips it back to
False.

rosidl_typesupport_microxrcedds_c: fixed the sequence-of-namespaced-
type gap build 29's own note flagged as a known follow-up -- an
unbounded sequence<NamespacedType> member (e.g.
rcl_interfaces/GetParameters.Response's `ParameterValue[] values`, a
nested-message array) has the exact same outer-capacity-never-grown
bug already fixed for sequence<string>. Growing it *properly* needs
the other message package's own generated <Type>__Sequence__init(),
not available here since this generated file deliberately never links
directly against sibling packages beyond their struct layout. Fixed
instead with a plain zero-filled calloc() (sized via
sizeof(*seq.data), a compile-time property of the pointee type that
needs no literal type name) -- equivalent to a proper per-element
__init() for the overwhelming majority of message types, whose fields
all default to zero. A destination that's already partially populated
and still too small falls back to the previous return-0 instead of
growing, since releasing its existing elements' own sub-allocations
needs that same unavailable fini(). Verified live: a real rclpy
service response carrying two real ParameterValue entries (a string
and an integer) is received and deserialized correctly end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Third sibling bug in rosidl_typesupport_microxrcedds_c's generated
cdr_deserialize(), same file the string and sequence-of-message fixes
already landed in -- and more severe than either: an unbounded
sequence<BasicType> (e.g. `float64[] data` in
std_msgs/Float64MultiArray) calls ucdr_deserialize_sequence_<T>() with
the destination's current (often 0) capacity. Unlike the two previous
bugs' outer-capacity checks (which safely bail out *before* touching
the buffer), this call's own internal capacity check
(ucdr_deserialize_sequence_header(), micro-CDR's sequence.c) only sets
an out-of-band error flag -- it does NOT stop the very next step, an
unconditional ucdr_deserialize_endian_array_<T>() call
(UCDR_DESERIALIZE_ARRAY_BYTE_N, micro-CDR's array.c) that
memcpy()s/writes the FULL incoming length into the undersized
destination regardless. This is a genuine heap buffer overflow, the
exact same bug class as the original string-content fix, just for
numeric sequences instead of strings -- confirmed by reading
micro-CDR's own source rather than inferring from symptoms.

Fixed the same way: peek the incoming length first and realloc() the
destination to fit before the real deserialize call. Simpler than the
string/namespaced-type sequence fixes: a BasicType element is a bare
scalar with no sub-allocations of its own to preserve or release, and
the real deserialize call fully overwrites every element up to the new
size regardless -- so a single realloc() is correct for every case,
not just a freshly-constructed destination.

Also bumps std_msgs (build 28) to regenerate Float64MultiArray's
generated code against the fixed template -- the message used to find
and verify this bug.

Verified live: a real rclpy publisher/subscriber pair exchanging a
Float64MultiArray with a 20-element float64[] data field (destination
starting at capacity 0) delivers all 20 values correctly, no crash, no
corruption. The existing std_msgs/String path re-verified alongside it
with no regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant