Add emscripten-wasm32 support with rmw_zenoh_pico - #46
Open
Tobias-Fischer wants to merge 57 commits into
Open
Tobias-Fischer wants to merge 57 commits into
Tobias-Fischer wants to merge 57 commits into
Conversation
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>
- 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/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>
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>
…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>
…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>
This was referenced Sep 15, 2026
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
emscripten-wasm32as an opt-in target platform for rolling, usingrmw_zenoh_picoas theRMW 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_cppRMW used there forzenoh-pico, which has real, actively-maintained upstream WASM/pthreadssupport — significantly less custom code to maintain.
All changes are gated by
if: wasm32/if: not wasm32selectors (orper-platform
robostack.yamldicts) and are additive — no behavior changefor existing platforms.
Verified end-to-end, both a C and a Python talker:
rclctalker node running in a real browser publishesstd_msgs/Stringviarmw_zenoh_picoover a real WebSocket to a nativezenohdrouter, received and correctly decoded by an independent nativeprocess.
rclpytalker (CPython built with--enable-wasm-pthreads,including a real, non-stubbed
numpy) running the same way in the samebrowser, 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 vincaMODULE-vs-SHAREDpthreads-flag fix)has since been reverted. Real pthreads requires WebAssembly
--shared-memory, which is viral — every module that getsdlopen'd intoa host must also be pthreads/shared-memory, or linking fails. That's
exactly what blocked
rclpyfrom loading inside a JupyterLite kernel(
xeus-python, built without pthreads upstream): rebuildingxeus/xeus-pythonwith pthreads to match got past the link error, but produceda hard, total deadlock —
xeus-python's own thread model can't tolerate ablocking wait either.
Replaced with Asyncify:
rmw_zenoh_pico'srmw_wait()now pollszenoh-pico's own
Z_FEATURE_MULTI_THREAD=0single-threaded transport mode(
zp_read()/zp_send_keep_alive(), already used for e.g.WITH_ARDUINO_OPENCR) in a loop that cooperatively yields viaemscripten_sleep(), instead of blocking a real OS thread on acondition_variable. This removes the shared-memory requirement from the
whole closure —
xeus/xeus-pythonnever need to touch pthreads at all.Also fixed along the way:
rmw_zenoh_pico's ownrmw_zenoh_pico_condition.hwas missing a<pthread.h>include forpthread_condattr_t— the type is declaredunconditionally by emscripten's libc regardless of
-pthread, butzenoh-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 tooverride the compiled-in zenoh connect address at runtime) aren't part
of the standard
rmw.hABI, so wasm-ld's side-module dead-codeelimination dropped them from the export table — now explicitly exported
via
target_link_options(... "-Wl,--export=...").patch/ros-rolling-rosidl-typesupport-microxrcedds-{c,cpp}.patchfiles mentioned below are gone — both are now fully subsumed by the
Tobias-Fischer/rosidl_typesupport_microxrceddsfork pin (verified: bothpatches fail to apply against that fork's commit because it already
contains every hunk they were adding).
geometry_msgs/sensor_msgsadded to the wasm32 closure.pixi.toml'sbuild-emscriptenis 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 theemscripten-wasm32platform throughthe 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 thevincapypi dependency atRoboStack/vinca#154's branch
until it merges upstream.
patch/ros-rolling-*.patch: per-package emscripten build fixes (rclpynarrowing warning +
-latomiclink 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: buildsrmw_zenoh_picoforwasm32 (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 forrmw_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. Sincethen it's been rebuilt from a genuinely empty
output/directory (twice, toalso 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_cppnow declare a real
add_hostdependency onros2-rosidl-typesupport-microxrcedds-{c,cpp}(and likewisetest_msgs/example_interfaces, needed for the same reason as the packages alreadylisted on
pixi.toml'sbuild-emscriptencomment 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 afrom-scratch build's ordering came down to luck — sometimes it built in
the right order, sometimes a plain
find_package()failed outright. Thisreplaced 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 alocal machine, never actually committed — silently swallowed by a blanket
*.sh.gitignorerule (now negated forextra_recipes/**/*.sh). Anygenuinely fresh checkout failed with "build.sh: command not found".
pixi.toml's newsync-native-bootstrap-mirrortask (wired intobuild-emscripten'sdepends-on) scripts a workaround that used to bemanual, 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 anative
ros2-*-prefixed build of anything — it's permanentlyunsatisfiable as declared. CMake's own
find_package()resolution onlyever needed the host-prefix (
emscripten-wasm32) copy (alreadycross-compilation-safe via
-DCMAKE_FIND_ROOT_PATH=$PREFIX); rattler-build'ssolver is what insists on a real build-platform channel entry. Mirroring
already-built
emscripten-wasm32packages into a same-namedosx-arm64channel directory and reindexing satisfies the solver without any actual
native rebuilding.
pixi.toml'svincapin is now onTobias-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.cmakerequires, breaking every downstreamfind_package()of it.First surfaced here on
builtin_interfaces, breakingservice_msgs.rosidl_typesupport_microxrcedds_cpp'scodegen 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'sbuild-emscriptencomment in this PR'shistory for exactly how involved that was). Fixed at the source instead
— see "Upstream PRs" below — so a single
pixi run build-emscriptenpasswith 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:
ament_cmake_ros_core+export-set/
BUILD_INTERFACEfixes, plus the missing Event-messagetypesupport codegen (see below)
rcl_logging_implementation'sdlopen-by-name backend selection (found getting
rclpy's logging initworking)
CMAKE_SHARED_MODULE_CREATE_*_FLAGS, not just theSHARED-libraryvariant. CMake's
MODULElibrary type — whatpybind11_add_module()usesfor every Python C extension, including
rclpy's own_rclpy_pybind11and each message package's
rosidl_generator_pytypesupport accessor —is distinct from
SHAREDand was linking withoutUSE_PTHREADS=1. Neverhit by the
rclc/C-only path; only surfaced once a realrclpydemolinked a pybind11 module into the same pthreads build.
Getting
rclpyrunning: what it actually took(Historical — the pthreads approach described in this section was reverted; see "Update" above.
rclpynow runs without any custom pthreads-enabled CPython build at all, using the same Asyncify-basedrmw_zenoh_picoas everything else.)Earlier revisions of this PR reported
rclpyas blocked by CPython forwasm32 not being pthread-enabled. That's fixed, and it turned out to be
one configure flag: CPython upstream has shipped
--enable-wasm-pthreadssince 3.11 (adds exactly
-pthread -sUSE_PTHREADS -sPROXY_TO_PTHREAD, thesame flags this whole pipeline already uses) —
emscripten-forge's ownpythonrecipe just doesn't turn it on. Building a custom variant withthat flag, plus registering
numpy's ~14 eagerly-imported compiledextension modules ahead of time (this build doesn't enable CPython's
--enable-wasm-dynamic-linking— its own docs flag that combination withpthreads as known-crashy — so nothing can rely on Python's normal
importdynamically
dlopen-ing a.so), got a realrclpytalker with realnumpyrunning end-to-end.One more narrow gap surfaced along the way, documented in this repo rather
than silently patched around:
rmw_zenoh_picodoesn't support publisher/subscriber QoS event handlers(
RCL_PUBLISHER_OFFERED_INCOMPATIBLE_QOSetc.) orTypeDescriptionService— the former fails with a plain
RCLErrorinstead of theUnsupportedEventTypeErrorrclpyalready handles gracefully, and thelatter hangs rather than erroring. Worked around locally for the demo
(not upstreamed — didn't root-cause the
rmw_zenoh_picoside tonight);worth a closer look if
rclpysupport here gets formalized further.The
rclpydemo itself (build script, patchedrclpy/numpy, browserharness) is local-only exploratory tooling, same as the existing
rclcone — not part of this PR's recipe changes.
Test plan
emscripten-wasm32+ rmw_zenoh_pico, from a genuinely emptyoutput/directory (not incrementally on top of prior local state),in a single
pixi run build-emscriptenpass with both typesupportoverrides set globally — no scoped per-package rebuilds.
action_msgs,rcl_interfaces,type_description_interfaces,test_msgs, ...)confirmed building with the correct typesupport backend actually
wired into its dispatch table (checked via
stringson the built.so).zenohd→independent native subscriber, verified receiving correctly-decoded
messages via the router's own REST API (not just the browser's own
console).
rclpytalker (realnumpy, not stubbed), alsoindependently confirmed via the router's REST API.
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
rclcandrclpybrowser demo verified end-to-end against a nativezenohdrouter.🤖 Generated with Claude Code