Skip to content

Add emscripten-wasm32 support with rmw_zenoh_pico - #46

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

Add emscripten-wasm32 support with rmw_zenoh_pico#46
Tobias-Fischer wants to merge 13 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."

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,
    patch/ros-rolling-rosidl-typesupport-microxrcedds-{c,cpp}.patch: build
    rmw_zenoh_pico and its typesupport backend for wasm32. The underlying
    fixes have been submitted upstream separately (see below); these patches
    apply them to the exact pinned commits until they merge.
  • 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.

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

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.

Two more narrow gaps surfaced along the way, both documented in this repo
rather than silently patched around:

  • rosidl_typesupport_microxrcedds_cpp's codegen doesn't handle service
    "_Event" messages — specific to the C++ typesupport variant; the
    C variant those same packages (rcl_interfaces,
    type_description_interfaces, service_msgs) need for rmw_zenoh_pico
    builds fine. See the comment on pixi.toml's build-emscripten task.
  • 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).
  • 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 3 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>
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