Skip to content

Enable real pthreads for emscripten-wasm32 builds - #154

Open
Tobias-Fischer wants to merge 13 commits into
RoboStack:masterfrom
Tobias-Fischer:feature/emscripten-pthreads
Open

Enable real pthreads for emscripten-wasm32 builds#154
Tobias-Fischer wants to merge 13 commits into
RoboStack:masterfrom
Tobias-Fischer:feature/emscripten-pthreads

Conversation

@Tobias-Fischer

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

Copy link
Copy Markdown
Contributor

Summary

Flips USE_PTHREADS 0→1 (compile and link) for every emscripten-wasm32 ament_cmake build in build_ament_cmake.sh.in, plus several smaller emscripten-target fixes needed to get there and to support a second, independent emscripten-wasm32 pipeline (ROS 2 rolling + rmw_zenoh_pico, as opposed to the original ROS 2 Humble + rmw_wasm_cpp one). All changes are confined to the if [[ $target_platform =~ emscripten.* ]] branch of the template (or are purely additive env-var opt-ins) — no effect on any other platform or on emscripten builds that don't set the new env vars.

Built and validated against three full emscripten-wasm32 ROS 2 builds:

  • ~104-package ROS 2 Humble build with a custom rmw_wasm_cpp, running a real rclcpp talker/listener continuously in the browser.
  • ~230-package ROS 2 rolling build with rmw_zenoh_pico, running a real rclc talker in the browser that publishes to a native zenohd router over WebSocket, verified end-to-end by an independent native process receiving the message.
  • The same rolling + rmw_zenoh_pico build running a real rclpy talker (CPython built with --enable-wasm-pthreads) in the browser, also verified end-to-end against a native subscriber — this is what surfaced the CMAKE_SHARED_MODULE_* gap below.

Why real pthreads

Without real threads, libc++'s condition_variable timed-wait (used by rclcpp's executor and the RMW wait-set machinery) never wakes up on its own — there is no OS thread to run a genuine blocking wait, so a synchronous wait just blocks the single JS thread forever with no way for wall-clock time to advance underneath it. In practice this meant any rclcpp::spin()-based program executed its first callback and then hung permanently. USE_PTHREADS=1 has to be set at both compile and link time for every translation unit (not just the final link) — it bakes in the wasm atomics/bulk-memory features wasm-ld requires when producing shared memory. This is applied globally via add_compile_options() in the generated CMAKE_PROJECT_INCLUDE file, so every package in the tree gets it consistently (mixing a pthread-enabled module with a non-pthread one is a hard ABI-level mismatch — a wasm module's shared-vs-non-shared linear memory is fixed at compile+link time).

The other commits

  • Removes the -s DEMANGLE_SUPPORT=1 emcc flag, which emscripten 4.0.9 (the SDK version currently published as emscripten_emscripten-wasm32) rejects outright ("No longer supported"), breaking every emscripten-wasm32 ament_cmake build past the first one or two.
  • _overlay() now converts v1 if/then/else selectors in pinning_overrides values into the legacy # [selector] comment form, since rattler-build's variant loader treats the raw if/then mapping as an opaque literal rather than evaluating it.
  • Makes the default RMW_IMPLEMENTATION for emscripten builds configurable via VINCA_EMSCRIPTEN_RMW_IMPLEMENTATION (falls back to the existing rmw_wasm_cpp default), so other emscripten-wasm32 pipelines can select their own RMW (e.g. rmw_zenoh_pico) without forking this template.
  • Makes STATIC_ROSIDL_TYPESUPPORT_C/_CPP configurable the same way via VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C/_CPP (falls back to the existing rosidl_typesupport_introspection_c/_cpp default). A micro-ROS-lineage RMW like rmw_zenoh_pico requires messages' typesupport dispatch table to register rosidl_typesupport_microxrcedds_c/_cpp specifically — the previous hardcoded default left that dispatch table with no matching entry, so rcl_publisher_init failed with "Type support not from this implementation" for every message.
  • Also sets CMAKE_SHARED_MODULE_CREATE_C_FLAGS/CMAKE_SHARED_MODULE_CREATE_CXX_FLAGS, alongside the existing CMAKE_SHARED_LIBRARY_CREATE_*_FLAGS. CMake's MODULE library type (what pybind11_add_module() uses for every Python C extension — rclpy's own _rclpy_pybind11, and every message package's rosidl_generator_py typesupport accessor) is a distinct target type from SHARED and reads its own set of link-flag variables. Setting only the SHARED ones left every MODULE-type .so linked without USE_PTHREADS=1 — its object files were compiled with atomics support fine (add_compile_options applies globally regardless of target type), but the final linked module came out non-shared-memory, which fails to load next to the rest of a pthreads build with a "mismatch in shared state of memory" error. Invisible until something in the tree actually used pybind11_add_module() — the rclc/C-only pipelines never hit it.
  • Stops pre-finding the VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C/_CPP override for a package that calls rosidl_generate_interfaces() itself (i.e. defines its own messages/services/actions). Pre-finding it via CMAKE_PROJECT_INCLUDE was needed so a consumer package's Export.cmake (referencing rosidl_typesupport_microxrcedds_c(pp)::...) doesn't fail with "a find_package call is missing for an IMPORTED target" — but for a message-generating package, it has a side effect: it makes the override "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 to the front of its own _exported_targets list, ahead of the generator target its own Export.cmake requires (INTERFACE_LINK_LIBRARIES references <pkg>::<pkg>__rosidl_generator_c(pp)). Every downstream find_package(<that package>) then fails with "referenced, but are missing: <pkg>::<pkg>__rosidl_generator_c(pp)", reproducible regardless of whether the C or C++ (or both) override is set. Without any pre-find, rosidl_generate_interfaces() still discovers the same override backend on its own (via STATIC_ROSIDL_TYPESUPPORT_C/_CPP, already set unconditionally), so skipping the pre-find for these packages loses nothing for their own typesupport selection. Found and fixed while re-verifying Add emscripten-wasm32 support with rmw_zenoh_pico ros-rolling#46 from a genuinely clean output/ directory — it had been masked until then because every message package already built (with an earlier, non-pre-finding vinca commit) predated this pre-find fix.

Testing

Re-verified end-to-end from a genuinely clean output/ directory (not just incrementally, on top of already-built packages): full ~230-recipe emscripten-wasm32 closure for rolling + rmw_zenoh_pico builds green, both a real rclc and a real rclpy talker running in the browser, each independently confirmed delivering messages via a native zenohd router's own REST API (not just reading the browser's own console).

Companion changes:

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

Tobias-Fischer and others added 3 commits September 7, 2026 13:02
…n builds

Emscripten 4.0.9 (the SDK version emscripten-forge/recipes currently
publishes as emscripten_emscripten-wasm32) has removed the
DEMANGLE_SUPPORT link setting entirely: passing it now hard-errors
with `emcc: error: invalid command line setting -sDEMANGLE_SUPPORT=1:
No longer supported`, rather than being ignored or warned about.

This broke every emscripten-wasm32 ament_cmake package build past the
first one or two (confirmed hitting it on ros-humble's rcutils, the
first real C library in its selected package set) -- the flag was
unconditionally injected into CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS,
CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS, and CMAKE_EXE_LINKER_FLAGS for
every wasm32 shared-library build via this template, both in the live
code path and in the dead, already-commented-out per-package branch
above it (fixed for consistency in case it's ever re-enabled).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
vinca_pinning.yaml's pinning_overrides are copied verbatim into the
rendered conda_build_config.yaml by _overlay() -- but rattler-build's
variant config loader does not evaluate v1-style `if: COND then: [...]`
mappings as selectors the way recipe.yaml files do; it treats each one
as an opaque literal value. Depending on how many if/then entries a
key has, this surfaces as either "Could not parse version spec for
variant key X: invalid channel" (single entry) or "multiple bracket
sections not allowed" / a silent cartesian-product explosion across
every branch (multiple entries), since rattler-build sees N distinct
raw-dict "values" instead of one selector to resolve.

The legacy `- VALUE  # [selector]` comment-annotated list form, used
throughout the rest of this file (c_compiler_version, cxx_compiler_version,
etc.), *is* understood and evaluated lazily per --target-platform by
rattler-build itself. Fixed by having _overlay() convert any v1-style
if/then/else list entries in an override's value into that legacy form
before writing it out, so overrides can be authored with the more
readable if/then/else syntax (matching emscripten-forge/recipes' own
variant.yaml style) while still producing a conda_build_config.yaml
rattler-build actually parses as conditional.

Also had to make sure existing `# [selector]` EOL comments on
passthrough (non-if/then) list items survive the rebuild: ruamel keeps
comments keyed by list position on the *source* CommentedSeq, not on
the item value itself, so appending an item into a freshly created
CommentedSeq silently drops its comment unless it's explicitly copied
across -- confirmed by a first pass of this fix accidentally stripping
the selectors off c_compiler/c_compiler_version's existing entries,
which briefly turned every platform's compiler into a candidate for
every other platform's build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Flips USE_PTHREADS=0 to 1 (compile and link) for every emscripten-wasm32
ament_cmake build, plus adds compile-time -s USE_PTHREADS=1 globally via
CMAKE_PROJECT_INCLUDE so every translation unit gets the atomics/bulk-memory
wasm features the linker requires for shared memory.

Without real threads, libc++'s condition_variable timed-wait (used by
rclcpp's executor / rmw wait-set) never wakes up on its own -- there is no
OS thread to run a real blocking wait, so a synchronous C++ wait blocks the
single JS thread forever with no way for wall-clock time to advance
underneath it. This was blocking any ROS2 wasm demo from running more than
one executor iteration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer and others added 3 commits September 8, 2026 23:09
… for emscripten

rattler-build resolves "build:" dependencies against build_platform, not
target_platform, so listing the target-arch-named rosidl_default_generators
package under build: for emscripten-wasm32 cross builds only works if a
native (non-emscripten) copy of that exact package has also been built and
published -- which is not generally the case. The host-prefix copy is
already sufficient for CMake's find_package() during configure (confirmed:
packages using only rosidl_core_generators in host, with no matching
build: entry, already build fine), so this extra requirement was both
unsatisfiable and unnecessary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Hardcoding rmw_wasm_cpp as the default RMW for emscripten-wasm32 builds
only works for repos that actually build that specific RMW. Read it from
VINCA_EMSCRIPTEN_RMW_IMPLEMENTATION instead (falling back to rmw_wasm_cpp
unchanged), so other emscripten-wasm32 experiments -- e.g. one building
rmw_zenoh_pico instead -- can select their own default without forking
this template again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
STATIC_ROSIDL_TYPESUPPORT_C/CPP were hardcoded to
rosidl_typesupport_introspection_c/cpp, which is what rmw_wasm_cpp expects.
A micro-ROS-lineage RMW (e.g. rmw_zenoh_pico) instead requires messages'
rosidl_typesupport_c dispatch table to register
rosidl_typesupport_microxrcedds_c/cpp specifically -- confirmed via a live
browser demo that publisher creation fails with "Type support not from
this implementation" otherwise, since the dispatch table is filtered down
to a single backend at build time and never even considers alternatives at
runtime. Purely additive: defaults to the existing introspection backend
when the new env vars are unset, so non-zenoh consumers of this template
(e.g. ros-humble-emscripten's rmw_wasm_cpp pin) are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pybind11_add_module() (used by rclpy's _rclpy_pybind11 extension, and any
other pybind11-based Python C extension) creates a CMake MODULE-type
library, not SHARED -- a distinct target type with its own
CMAKE_SHARED_MODULE_CREATE_*_FLAGS variables. Setting only the SHARED
ones left every MODULE .so linked without USE_PTHREADS=1: its object
files were compiled with atomics support (add_compile_options applies
globally) and looked fine individually, but the final linked module was
non-shared-memory, causing a load-time
"mismatch in shared state of memory" failure alongside the rest of a
real-pthreads build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tobias-Fischer added a commit to Tobias-Fischer/ros-rolling that referenced this pull request Sep 9, 2026
…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>
Tobias-Fischer and others added 5 commits September 10, 2026 16:09
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>
This build:-side requirement asks for a *build-platform* (native)
build of e.g. ros2-rosidl-default-generators for any emscripten cross
build -- but nothing publishes a native build of these "ros2-"-prefixed
packages anywhere (they only ever exist as emscripten-wasm32 cross
targets), so this dependency was unsatisfiable from a genuinely clean
state. The host-prefix copy vinca already adds elsewhere is enough on
its own: CMake's find_package() resolves it via
-DCMAKE_FIND_ROOT_PATH=$PREFIX regardless of section, and the actual
codegen scripts run through the same
-DCMAKE_CROSSCOMPILING_EMULATOR=node this whole pipeline already
relies on for every other host-resolved build-time tool.

Verified: with this and the previous commit's find_package() fix
together, the full ~230-package ros-rolling closure (RoboStack/
ros-rolling#46) builds cleanly from a genuinely empty local channel --
no native/build-platform package ever needs to exist.

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

This reverts commit ff8ed10.

Re-verifying this fix from a clean output/ directory surfaced a new,
unresolved CMake configure failure in service_msgs
("builtin_interfaces::builtin_interfaces__rosidl_generator_cpp" reported
as a missing imported target, despite builtin_interfaces' own exported
.cmake file appearing well-formed). The previously-verified pre-find
fix (b1960a5) plus the native-bootstrap mirror it was paired with is
known-good end-to-end (both rclc and rclpy browser demos, verified
against a native zenohd subscriber), so reverting this specific commit
keeps the branch at that verified state pending further investigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pre-finding VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C/_CPP via
CMAKE_PROJECT_INCLUDE (b1960a5) fixed downstream consumers missing the
plugin package's own CMake target, but for a package that calls
rosidl_generate_interfaces() itself, it has a side effect: it makes the
override backend "already a target" before that macro's own typesupport
discovery runs, which bumps the override's ament_export_targets() entry
to the front of the package's own _exported_targets list -- ahead of the
generator target its own Export.cmake requires
(INTERFACE_LINK_LIBRARIES references <pkg>::<pkg>__rosidl_generator_c(pp)).
Every downstream find_package(<pkg>) then fails with "referenced, but are
missing: <pkg>::<pkg>__rosidl_generator_c(pp)", reproducible regardless of
whether the C or C++ (or both) override is active.

Confirmed by inspecting the built package's own
ament_cmake_export_targets-extras.cmake: with no override at all, the
generator entry always precedes its typesupport entries (their natural,
correct order); pre-finding the override moves it to the front instead.

Without any pre-find, rosidl_generate_interfaces() still discovers and
selects the same override backend on its own (via
STATIC_ROSIDL_TYPESUPPORT_C/_CPP, already set unconditionally below), so
skipping the pre-find for these packages loses nothing for their own
typesupport selection -- confirmed against builtin_interfaces (self
export ordering) and service_msgs (consuming the fixed builtin_interfaces
while also generating its own interfaces).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ros2-gbp release tags look like "release/jazzy/foo_pkg/1.2.3-1" -- the
short raw.githubusercontent.com <owner>/<repo>/<ref>/<path> form has to
guess where a slash-containing ref ends and the path begins. That guess is
inconsistently cached across CDN edges: the same URL 404s from some
vantage points (including GitHub Actions runners, observed repeatedly and
reproducibly on RoboStack/ros-jazzy CI) while resolving fine from others.
The explicit refs/tags/<name> form removes the ambiguity and resolves
reliably everywhere. A commit hash (rev) is already unambiguous and is
left as-is.

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