From f692af7213bfb6da02fb6493949c142b1f5d1fed Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Mon, 7 Sep 2026 13:01:19 +1000 Subject: [PATCH 01/16] fix: remove unsupported -s DEMANGLE_SUPPORT=1 emcc flag for emscripten 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 --- vinca/templates/build_ament_cmake.sh.in | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index cc25a25..647e69f 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -70,13 +70,13 @@ if [[ $target_platform =~ emscripten.* ]]; then echo "set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH) # fixes an error where numpy header files are not found correctly">> $SRC_DIR/__vinca_shared_lib_patch.cmake # if [ "${PKG_NAME}" == "ros-humble-examples-rclcpp-minimal-publisher" ] || [ "${PKG_NAME}" == "ros-humble-examples-rclcpp-minimal-subscriber" ] || [ "${PKG_NAME}" == "ros-humble-rclcpp-components" ]; then - # echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s DEMANGLE_SUPPORT=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - # echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s DEMANGLE_SUPPORT=1 -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -O3 -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - # echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -s USE_PTHREADS=0 -s DEMANGLE_SUPPORT=1 -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY -O3 -s ASYNCIFY_STACK_SIZE=24576 -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -O3 -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -s USE_PTHREADS=0 -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY -O3 -s ASYNCIFY_STACK_SIZE=24576 -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake # else - echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s ALLOW_MEMORY_GROWTH=1 -s DEMANGLE_SUPPORT=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s ALLOW_MEMORY_GROWTH=1 -s DEMANGLE_SUPPORT=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -s USE_PTHREADS=0 -sALLOW_MEMORY_GROWTH=1 -s DEMANGLE_SUPPORT=1 -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -s USE_PTHREADS=0 -sALLOW_MEMORY_GROWTH=1 -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake # fi export BUILD_TYPE="Debug" From 26be199502207a3def36f28645d383726d0620aa Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Mon, 7 Sep 2026 14:14:05 +1000 Subject: [PATCH 02/16] fix: evaluate v1 if/then/else selectors in pinning_overrides values 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 --- vinca/pinning.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/vinca/pinning.py b/vinca/pinning.py index b773e6b..df80d1b 100644 --- a/vinca/pinning.py +++ b/vinca/pinning.py @@ -219,11 +219,69 @@ def _migration_name(name: str) -> str: return name +def _existing_eol_comment_text(source: Any, index: int) -> Optional[str]: + """Return the plain text of a CommentedSeq item's trailing EOL comment, if any.""" + ca = getattr(source, "ca", None) + if ca is None: + return None + entry = ca.items.get(index) + if not entry: + return None + token = entry[0] + if token is None: + return None + return str(token.value).lstrip("#").strip() + + +def _flatten_v1_selectors(value: Any) -> Any: + """Convert v1-style `- if: COND then: [...]` list entries into the legacy + `- VALUE # [COND]` comment-annotated form that rattler-build's variant + config loader actually evaluates lazily per target_platform (unlike the + v1 if/then/else mapping form, which it treats as an opaque literal value + rather than a selector -- confirmed via `Could not parse version spec + for variant key ...: invalid channel` / `multiple bracket sections not + allowed` errors when left unconverted). + + Passthrough items (plain scalars, possibly already carrying their own + `# [selector]` EOL comment) must have that existing comment re-attached + at their new index -- ruamel stores comments keyed by list position on + the *source* CommentedSeq, not on the item itself, so a naive + `result.append(item)` into a freshly created CommentedSeq silently + drops it, turning a platform-scoped entry into an unconditional one. + """ + if not isinstance(value, list): + return value + import ruamel.yaml.comments as _rc + + result = _rc.CommentedSeq() + for old_index, item in enumerate(value): + if isinstance(item, Mapping) and "if" in item and "then" in item: + cond = str(item["if"]) + for entry in item["then"]: + idx = len(result) + result.append(entry) + result.yaml_add_eol_comment(f"[{cond}]", idx) + else_branch = item.get("else") + if else_branch is not None: + not_cond = f"not ({cond})" + for entry in else_branch: + idx = len(result) + result.append(entry) + result.yaml_add_eol_comment(f"[{not_cond}]", idx) + else: + idx = len(result) + result.append(item) + comment_text = _existing_eol_comment_text(value, old_index) + if comment_text: + result.yaml_add_eol_comment(comment_text, idx) + return result + + def _overlay(target: Any, source: Any) -> None: for key, value in source.items(): if key == "migrator_ts" or str(key).startswith("__"): continue - target[key] = value + target[key] = _flatten_v1_selectors(value) def _migration_timestamp(payload: bytes) -> float: From 7133b302498edda9bc87a939329353fac95177c5 Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Tue, 8 Sep 2026 17:23:16 +1000 Subject: [PATCH 03/16] feat: enable real pthreads for emscripten-wasm32 builds 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 --- vinca/templates/build_ament_cmake.sh.in | 39 +++++++++++++++++++------ 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index 647e69f..057cadf 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -69,15 +69,36 @@ if [[ $target_platform =~ emscripten.* ]]; then echo "set(CMAKE_STRIP FALSE) # used by default in pybind11 on .so modules">> $SRC_DIR/__vinca_shared_lib_patch.cmake echo "set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH) # fixes an error where numpy header files are not found correctly">> $SRC_DIR/__vinca_shared_lib_patch.cmake - # if [ "${PKG_NAME}" == "ros-humble-examples-rclcpp-minimal-publisher" ] || [ "${PKG_NAME}" == "ros-humble-examples-rclcpp-minimal-subscriber" ] || [ "${PKG_NAME}" == "ros-humble-rclcpp-components" ]; then - # echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - # echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -O3 -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - # echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -s USE_PTHREADS=0 -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY -O3 -s ASYNCIFY_STACK_SIZE=24576 -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake - # else - echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=0 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -s USE_PTHREADS=0 -sALLOW_MEMORY_GROWTH=1 -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake - # fi + # Real pthreads (USE_PTHREADS=1) are required so that blocking waits + # (std::condition_variable / rmw wait sets) actually work: without a real + # OS-level thread, libc++'s condition_variable timed-wait never wakes up + # on its own, and code that spins/blocks the main thread (e.g. rclcpp's + # executor) hangs forever with no way for the browser's JS event loop + # (and thus wall-clock time) to ever advance underneath it. This must be + # consistent across every emscripten-wasm32 package: mixing a + # pthread-enabled module with a non-pthread one is a hard ABI-level + # mismatch ("memory import shared state mismatch") since a wasm module's + # shared-vs-non-shared linear memory is fixed at compile+link time. + # + # This flag has to be set at COMPILE time too (not just link time) for + # every translation unit -- it bakes in the wasm 'atomics'/'bulk-memory' + # features that the linker later requires when producing shared memory + # ("wasm-ld: error: --shared-memory is disallowed ... because it was not + # compiled with 'atomics' or 'bulk-memory' features"). add_compile_options + # here (via CMAKE_PROJECT_INCLUDE, included right after every project() + # call) applies it to every target compiled in every package. + echo "add_compile_options(\"SHELL: -s USE_PTHREADS=1\")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + + echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # PTHREAD_POOL_SIZE and MAXIMUM_MEMORY are only meaningful on the final + # MAIN_MODULE executable link (they configure the Worker pool and the + # shared SharedArrayBuffer's reserved size respectively -- side modules + # don't have memory of their own, they use the main module's). Emscripten + # requires MAXIMUM_MEMORY to be set explicitly whenever + # ALLOW_MEMORY_GROWTH is combined with USE_PTHREADS, since a shared + # wasm memory's maximum size can't be left unbounded. + echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -s USE_PTHREADS=1 -s PTHREAD_POOL_SIZE=4 -sALLOW_MEMORY_GROWTH=1 -s MAXIMUM_MEMORY=1024MB -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake export BUILD_TYPE="Debug" export EXTRA_CMAKE_ARGS=" \ From dd38b4a7d24ca3d2af4951ad5bda31669b4bc066 Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Tue, 8 Sep 2026 23:09:27 +1000 Subject: [PATCH 04/16] fix: don't require a build_platform copy of rosidl_default_generators 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 --- vinca/main.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/vinca/main.py b/vinca/main.py index 4aa962c..abb7715 100644 --- a/vinca/main.py +++ b/vinca/main.py @@ -593,15 +593,6 @@ def sortkey(k): } ) - rosidl_generators_name = f"{package_prefix}-rosidl-default-generators" - if rosidl_generators_name in output["requirements"]["host"]: - output["requirements"]["build"].append( - { - "if": "target_platform == 'emscripten-wasm32'", - "then": [rosidl_generators_name], - } - ) - output["requirements"]["run"] = sorted(output["requirements"]["run"], key=sortkey) output["requirements"]["host"] = sorted(output["requirements"]["host"], key=sortkey) From b50406304d8009b44d4e56e50abab444a3912942 Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Wed, 9 Sep 2026 05:49:20 +1000 Subject: [PATCH 05/16] feat: make the default emscripten RMW_IMPLEMENTATION configurable 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 --- vinca/templates/build_ament_cmake.sh.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index 057cadf..6b5ddda 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -103,7 +103,7 @@ if [[ $target_platform =~ emscripten.* ]]; then export BUILD_TYPE="Debug" export EXTRA_CMAKE_ARGS=" \ -DPYTHON_SOABI="cpython-${ROS_PYTHON_VERSION//./}-wasm32-emscripten" \ - -DRMW_IMPLEMENTATION=rmw_wasm_cpp \ + -DRMW_IMPLEMENTATION=${VINCA_EMSCRIPTEN_RMW_IMPLEMENTATION:-rmw_wasm_cpp} \ -DCMAKE_FIND_ROOT_PATH=$PREFIX \ -DCMAKE_POSITION_INDEPENDENT_CODE=TRUE \ -DCMAKE_PROJECT_INCLUDE=$SRC_DIR/__vinca_shared_lib_patch.cmake \ From 235bd2fcf88a7e61a084a39cc216e1271b753486 Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Wed, 9 Sep 2026 09:40:28 +1000 Subject: [PATCH 06/16] feat: make the emscripten static rosidl typesupport backend configurable 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 --- vinca/templates/build_ament_cmake.sh.in | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index 6b5ddda..539e7da 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -113,8 +113,8 @@ if [[ $target_platform =~ emscripten.* ]]; then export CMAKE_GEN="emcmake cmake" export CMAKE_BLD="cmake" - export STATIC_ROSIDL_TYPESUPPORT_C=rosidl_typesupport_introspection_c - export STATIC_ROSIDL_TYPESUPPORT_CPP=rosidl_typesupport_introspection_cpp + export STATIC_ROSIDL_TYPESUPPORT_C=${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C:-rosidl_typesupport_introspection_c} + export STATIC_ROSIDL_TYPESUPPORT_CPP=${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_CPP:-rosidl_typesupport_introspection_cpp} else export BUILD_TYPE="Release" export CMAKE_GEN="cmake" From 325d0afb5e4a862c47a65addf113eff62d090486 Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Wed, 9 Sep 2026 13:22:38 +1000 Subject: [PATCH 07/16] fix: apply emscripten pthreads flags to CMake MODULE libraries too 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 --- vinca/templates/build_ament_cmake.sh.in | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index 539e7da..06fb73d 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -91,6 +91,17 @@ if [[ $target_platform =~ emscripten.* ]]; then echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # CMake's MODULE library type (add_library(... MODULE), what + # pybind11_add_module() uses for Python C extensions e.g. rclpy's + # _rclpy_pybind11) is a distinct target type from SHARED and reads its + # own CMAKE_SHARED_MODULE_CREATE_*_FLAGS variables -- setting only the + # SHARED ones above left every MODULE-type .so linked without + # USE_PTHREADS=1, producing a non-shared-memory module that fails to + # load ("mismatch in shared state of memory") next to the rest of a + # pthreads build, even though its own object files were compiled with + # atomics support and looked fine individually. + echo "set(CMAKE_SHARED_MODULE_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_MODULE_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake # PTHREAD_POOL_SIZE and MAXIMUM_MEMORY are only meaningful on the final # MAIN_MODULE executable link (they configure the Worker pool and the # shared SharedArrayBuffer's reserved size respectively -- side modules From b1960a506297db666bfa152910a98c90208cede6 Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Thu, 10 Sep 2026 16:09:11 +1000 Subject: [PATCH 08/16] fix: pre-find the static typesupport override, not just build it 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 --- vinca/templates/build_ament_cmake.sh.in | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index 06fb73d..fb3cdaf 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -111,6 +111,28 @@ if [[ $target_platform =~ emscripten.* ]]; then # wasm memory's maximum size can't be left unbounded. echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -s USE_PTHREADS=1 -s PTHREAD_POOL_SIZE=4 -sALLOW_MEMORY_GROWTH=1 -s MAXIMUM_MEMORY=1024MB -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # A message package's *Config.cmake only exports find_dependency() calls + # for what its own package.xml/CMakeLists.txt actually declares -- it has + # no idea VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C/_CPP named an extra + # typesupport backend, so it never re-exports *that* dependency to ITS + # OWN consumers. A package that only uses 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 here (via + # CMAKE_PROJECT_INCLUDE, so it's already in every target's CMake + # namespace before that project's own find_package() calls run) covers + # every consumer uniformly instead of patching each one individually. + if [ -n "${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C:-}" ]; then + echo "find_package(${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C} QUIET)">> $SRC_DIR/__vinca_shared_lib_patch.cmake + fi + if [ -n "${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_CPP:-}" ]; then + echo "find_package(${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_CPP} QUIET)">> $SRC_DIR/__vinca_shared_lib_patch.cmake + fi + export BUILD_TYPE="Debug" export EXTRA_CMAKE_ARGS=" \ -DPYTHON_SOABI="cpython-${ROS_PYTHON_VERSION//./}-wasm32-emscripten" \ From ff8ed107d4baecc273ae731864406a606fba3a2c Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Thu, 10 Sep 2026 16:16:26 +1000 Subject: [PATCH 09/16] fix: drop the unsatisfiable build-platform rosidl_default_generators dep 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 --- vinca/recipes.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/vinca/recipes.py b/vinca/recipes.py index dbba622..5d18c84 100644 --- a/vinca/recipes.py +++ b/vinca/recipes.py @@ -232,16 +232,6 @@ def _adjust_requirements(requirements: Requirements, package_prefix: str) -> Non destination=requirements["build"], ) - # Emscripten resolves the generators out of `build` rather than `host`. - rosidl_generators = f"{package_prefix}-rosidl-default-generators" - if rosidl_generators in requirements["host"]: - requirements["build"].append( - { - "if": "target_platform == 'emscripten-wasm32'", - "then": [rosidl_generators], - } - ) - requirements["run"].sort(key=_requirement_sort_key) requirements["host"].sort(key=_requirement_sort_key) From 11462ea2ec1c1c756eb9d163afdaac84a2386103 Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Thu, 10 Sep 2026 16:52:29 +1000 Subject: [PATCH 10/16] Revert "fix: drop the unsatisfiable build-platform rosidl_default_generators dep" This reverts commit ff8ed107d4baecc273ae731864406a606fba3a2c. 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 --- vinca/recipes.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/vinca/recipes.py b/vinca/recipes.py index 5d18c84..dbba622 100644 --- a/vinca/recipes.py +++ b/vinca/recipes.py @@ -232,6 +232,16 @@ def _adjust_requirements(requirements: Requirements, package_prefix: str) -> Non destination=requirements["build"], ) + # Emscripten resolves the generators out of `build` rather than `host`. + rosidl_generators = f"{package_prefix}-rosidl-default-generators" + if rosidl_generators in requirements["host"]: + requirements["build"].append( + { + "if": "target_platform == 'emscripten-wasm32'", + "then": [rosidl_generators], + } + ) + requirements["run"].sort(key=_requirement_sort_key) requirements["host"].sort(key=_requirement_sort_key) From c974b23ab07dace8e5f67350601609903857c78d Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Thu, 10 Sep 2026 17:57:55 +1000 Subject: [PATCH 11/16] fix: don't pre-find the static typesupport override for message packages 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 ::__rosidl_generator_c(pp)). Every downstream find_package() then fails with "referenced, but are missing: ::__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 --- vinca/templates/build_ament_cmake.sh.in | 32 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index fb3cdaf..55befb5 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -126,11 +126,33 @@ if [[ $target_platform =~ emscripten.* ]]; then # CMAKE_PROJECT_INCLUDE, so it's already in every target's CMake # namespace before that project's own find_package() calls run) covers # every consumer uniformly instead of patching each one individually. - if [ -n "${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C:-}" ]; then - echo "find_package(${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C} QUIET)">> $SRC_DIR/__vinca_shared_lib_patch.cmake - fi - if [ -n "${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_CPP:-}" ]; then - echo "find_package(${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_CPP} QUIET)">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # A package that calls rosidl_generate_interfaces() itself (i.e. defines + # its own messages/services/actions) must NOT get this pre-find: that + # macro discovers available typesupport implementations itself and + # registers each one's ament_export_targets() call in a fixed relative + # order (each backend's generator target before its own typesupport + # target). Pre-finding the override backend here makes it "already a + # target" before that macro runs, which -- empirically confirmed by + # inspecting the resulting package's own ament_cmake_export_targets-extras.cmake + # -- causes THIS package's typesupport entry to jump to the front of its + # own _exported_targets list, ahead of the generator target its own + # Export.cmake requires (INTERFACE_LINK_LIBRARIES references + # ::__rosidl_generator_c(pp)). That makes every downstream + # find_package() fail with "referenced, but are missing: + # ::__rosidl_generator_c(pp)" -- reproducible regardless of + # whether the C or C++ (or both) override is set. Without any pre-find, + # rosidl_generate_interfaces() discovers the same override backend on its + # own (via STATIC_ROSIDL_TYPESUPPORT_C/_CPP below) in the correct order, + # so skipping it here loses nothing for this package's own typesupport + # selection -- it only loses the (here unneeded) benefit described above + # of pre-registering the backend for *consumers* of this package. + if ! grep -q "rosidl_generate_interfaces(" "$SRC_DIR/$PKG_NAME"/src/work/CMakeLists.txt 2>/dev/null; then + if [ -n "${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C:-}" ]; then + echo "find_package(${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_C} QUIET)">> $SRC_DIR/__vinca_shared_lib_patch.cmake + fi + if [ -n "${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_CPP:-}" ]; then + echo "find_package(${VINCA_EMSCRIPTEN_STATIC_TYPESUPPORT_CPP} QUIET)">> $SRC_DIR/__vinca_shared_lib_patch.cmake + fi fi export BUILD_TYPE="Debug" From c3f1185b7344c219b41266b898f4f91b34feecee Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Fri, 11 Sep 2026 06:54:18 +1000 Subject: [PATCH 12/16] fix: use explicit refs/tags/ in GitHub raw package.xml URLs ros2-gbp release tags look like "release/jazzy/foo_pkg/1.2.3-1" -- the short raw.githubusercontent.com /// 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/ 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 --- vinca/distro.py | 18 ++++++++-- vinca/test_github_raw_url.py | 59 +++++++++++++++++++++++++++++++++ vinca/test_snapshot_metadata.py | 6 ++-- 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 vinca/test_github_raw_url.py diff --git a/vinca/distro.py b/vinca/distro.py index c56010a..1b23bb0 100644 --- a/vinca/distro.py +++ b/vinca/distro.py @@ -543,12 +543,26 @@ def _construct_raw_url_github(self, pkg_info): # Extract owner/repo owner_repo = raw_url_base.split("github.com/")[-1] # Use rev if available, otherwise fallback to tag - ref = pkg_info.get("rev") or pkg_info.get("tag") + rev = pkg_info.get("rev") + tag = pkg_info.get("tag") xml_name = pkg_info.get("package_xml_name", "package.xml") additional_folder = pkg_info.get("additional_folder", "") if additional_folder != "": additional_folder = additional_folder + "/" - raw_url = f"https://raw.githubusercontent.com/{owner_repo}/{ref}/{additional_folder}{xml_name}" + if rev: + # A commit hash is unambiguous as-is. + ref_path = rev + else: + # ros2-gbp release tags look like "release/jazzy/foo_pkg/1.2.3-1" -- + # raw.githubusercontent.com's short /// form + # has to guess where a slash-containing ref ends and the path + # begins, and that guess is inconsistently cached across CDN edges: + # the same URL can 404 from some vantage points (including GitHub + # Actions runners) while resolving fine from others. The explicit + # refs/tags/ form removes the ambiguity and resolves + # reliably everywhere. + ref_path = f"refs/tags/{tag}" + raw_url = f"https://raw.githubusercontent.com/{owner_repo}/{ref_path}/{additional_folder}{xml_name}" return raw_url # format (checked against GitLab 19.x): https://gitlab.com//-/raw// diff --git a/vinca/test_github_raw_url.py b/vinca/test_github_raw_url.py new file mode 100644 index 0000000..b4474aa --- /dev/null +++ b/vinca/test_github_raw_url.py @@ -0,0 +1,59 @@ +from typing import Any + +from vinca.distro import Distro + + +def _distro() -> Any: + return Distro.__new__(Distro) + + +def test_tag_ref_uses_explicit_refs_tags_prefix(): + # ros2-gbp release tags look like "release/jazzy/foo_pkg/1.2.3-1" -- the + # short /// raw.githubusercontent.com form has to + # guess where a slash-containing ref ends and the path begins, and that + # guess is inconsistently cached across CDN edges (the same URL 404s from + # some vantage points, including GitHub Actions runners, while resolving + # fine from others). The explicit refs/tags/ form is unambiguous. + pkg_info = { + "url": "https://github.com/ros2-gbp/ros2_control-release.git", + "tag": "release/jazzy/controller_interface/4.47.0-1", + } + + url = _distro()._construct_raw_url_github(pkg_info) + + assert url == ( + "https://raw.githubusercontent.com/ros2-gbp/ros2_control-release/" + "refs/tags/release/jazzy/controller_interface/4.47.0-1/package.xml" + ) + + +def test_rev_ref_is_used_as_is(): + # A commit hash is already unambiguous -- it must not get the refs/tags/ + # prefix, since it isn't a tag name. + pkg_info = { + "url": "https://github.com/ros2-gbp/ros2_control-release.git", + "rev": "abc123def456", + } + + url = _distro()._construct_raw_url_github(pkg_info) + + assert url == ( + "https://raw.githubusercontent.com/ros2-gbp/ros2_control-release/" + "abc123def456/package.xml" + ) + + +def test_tag_ref_with_additional_folder_and_custom_xml_name(): + pkg_info = { + "url": "https://github.com/example/some-release.git", + "tag": "release/rolling/some_pkg/1.0.0-1", + "additional_folder": "some_pkg", + "package_xml_name": "package.xml", + } + + url = _distro()._construct_raw_url_github(pkg_info) + + assert url == ( + "https://raw.githubusercontent.com/example/some-release/" + "refs/tags/release/rolling/some_pkg/1.0.0-1/some_pkg/package.xml" + ) diff --git a/vinca/test_snapshot_metadata.py b/vinca/test_snapshot_metadata.py index ca7eb56..e616572 100644 --- a/vinca/test_snapshot_metadata.py +++ b/vinca/test_snapshot_metadata.py @@ -61,9 +61,11 @@ def make_snapshot_distro(monkeypatch): distro._distro = Mock() snapshot_xml_by_url = { "https://raw.githubusercontent.com/example/snapshot-package-release/" - "release/rolling/snapshot_package/1.0.0-1/package.xml": (SNAPSHOT_PACKAGE_XML), + "refs/tags/release/rolling/snapshot_package/1.0.0-1/package.xml": ( + SNAPSHOT_PACKAGE_XML + ), "https://raw.githubusercontent.com/example/snapshot-dependency-release/" - "release/rolling/snapshot_dependency/1.0.0-1/package.xml": ( + "refs/tags/release/rolling/snapshot_dependency/1.0.0-1/package.xml": ( SNAPSHOT_DEPENDENCY_XML ), } From 69fa7de8cd8b199f6b0743cfcf182f0f685ece3e Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Fri, 11 Sep 2026 20:44:27 +1000 Subject: [PATCH 13/16] revert: drop real pthreads for emscripten-wasm32, use Asyncify instead Real pthreads (enabled in 7133b30/325d0af) fixed rcl/rmw's blocking condition_variable wait, but require wasm --shared-memory, which is viral across every dlopen'd module and deadlocks completely inside single-thread hosts (e.g. JupyterLite's xeus-python kernel never responds again once a pthreads rclpy blocks it -- confirmed by direct reproduction). Asyncify lets the one real blocking call (rmw_zenoh_pico's rmw_wait, via zenoh-pico's WS transport read) cooperatively yield instead, without needing OS threads at all. Follow-up commits patch rmw_zenoh_pico's wait/condition code to actually use this instead of a real condvar. Co-Authored-By: Claude Sonnet 5 --- vinca/templates/build_ament_cmake.sh.in | 59 +++++++++---------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index 55befb5..95d3f08 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -69,47 +69,32 @@ if [[ $target_platform =~ emscripten.* ]]; then echo "set(CMAKE_STRIP FALSE) # used by default in pybind11 on .so modules">> $SRC_DIR/__vinca_shared_lib_patch.cmake echo "set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH) # fixes an error where numpy header files are not found correctly">> $SRC_DIR/__vinca_shared_lib_patch.cmake - # Real pthreads (USE_PTHREADS=1) are required so that blocking waits - # (std::condition_variable / rmw wait sets) actually work: without a real - # OS-level thread, libc++'s condition_variable timed-wait never wakes up - # on its own, and code that spins/blocks the main thread (e.g. rclcpp's - # executor) hangs forever with no way for the browser's JS event loop - # (and thus wall-clock time) to ever advance underneath it. This must be - # consistent across every emscripten-wasm32 package: mixing a - # pthread-enabled module with a non-pthread one is a hard ABI-level - # mismatch ("memory import shared state mismatch") since a wasm module's - # shared-vs-non-shared linear memory is fixed at compile+link time. + # No real pthreads here (deliberately -- see history below). Instead, + # Asyncify lets the one genuinely blocking call in this stack (rmw_wait, + # which polls zenoh-pico's WS transport) cooperatively yield back to the + # browser's JS event loop instead of really blocking -- that's enough for + # wall-clock time (and incoming WebSocket messages) to advance while a + # "blocking" wait is outstanding, without needing an OS-level thread. # - # This flag has to be set at COMPILE time too (not just link time) for - # every translation unit -- it bakes in the wasm 'atomics'/'bulk-memory' - # features that the linker later requires when producing shared memory - # ("wasm-ld: error: --shared-memory is disallowed ... because it was not - # compiled with 'atomics' or 'bulk-memory' features"). add_compile_options - # here (via CMAKE_PROJECT_INCLUDE, included right after every project() - # call) applies it to every target compiled in every package. - echo "add_compile_options(\"SHELL: -s USE_PTHREADS=1\")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - - echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # Real pthreads (USE_PTHREADS=1) were tried instead of this and reverted. + # They do make a blocking std::condition_variable/rmw wait set actually + # wake up, but they require wasm --shared-memory, which is viral: every + # module dlopen'd into an eagerly-linked host must also be pthreads/ + # shared-memory or linking fails ("mismatch in shared state of memory"), + # and worse, a genuinely blocking wait on a thread that also needs to + # service a message loop (e.g. JupyterLite's xeus-python kernel) just + # deadlocks outright -- there's nothing left free to deliver the wakeup. + # Asyncify avoids needing real concurrency at all for this. + echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake # CMake's MODULE library type (add_library(... MODULE), what # pybind11_add_module() uses for Python C extensions e.g. rclpy's # _rclpy_pybind11) is a distinct target type from SHARED and reads its - # own CMAKE_SHARED_MODULE_CREATE_*_FLAGS variables -- setting only the - # SHARED ones above left every MODULE-type .so linked without - # USE_PTHREADS=1, producing a non-shared-memory module that fails to - # load ("mismatch in shared state of memory") next to the rest of a - # pthreads build, even though its own object files were compiled with - # atomics support and looked fine individually. - echo "set(CMAKE_SHARED_MODULE_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_SHARED_MODULE_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s USE_PTHREADS=1 -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - # PTHREAD_POOL_SIZE and MAXIMUM_MEMORY are only meaningful on the final - # MAIN_MODULE executable link (they configure the Worker pool and the - # shared SharedArrayBuffer's reserved size respectively -- side modules - # don't have memory of their own, they use the main module's). Emscripten - # requires MAXIMUM_MEMORY to be set explicitly whenever - # ALLOW_MEMORY_GROWTH is combined with USE_PTHREADS, since a shared - # wasm memory's maximum size can't be left unbounded. - echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -s USE_PTHREADS=1 -s PTHREAD_POOL_SIZE=4 -sALLOW_MEMORY_GROWTH=1 -s MAXIMUM_MEMORY=1024MB -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # own CMAKE_SHARED_MODULE_CREATE_*_FLAGS variables -- keep it consistent + # with the SHARED flags above. + echo "set(CMAKE_SHARED_MODULE_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_MODULE_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake # A message package's *Config.cmake only exports find_dependency() calls # for what its own package.xml/CMakeLists.txt actually declares -- it has From 31fbd83e07cf14292c2aaf8f3e8db8ddd62ec852 Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Fri, 11 Sep 2026 21:59:51 +1000 Subject: [PATCH 14/16] fix: override EMCC_CFLAGS to drop -fwasm-exceptions, incompatible with Asyncify The emscripten-forge toolchain package's own activation script (activate_emscripten_emscripten-wasm32.sh) sets EMCC_CFLAGS="... -sSUPPORT_LONGJMP=wasm -fwasm-exceptions" globally, so every em++/emcc invocation gets native wasm exception-handling by default regardless of what CMake is told. Binaryen's Asyncify pass hard-crashes ("UNREACHABLE executed ... Asyncify.cpp") on any object using wasm EH instructions -- confirmed via a real build failure on ros2-rosidl-buffer. emcc.py only recognizes -fwasm-exceptions to *set* settings.WASM_EXCEPTIONS; there's no -fno-wasm-exceptions counter-flag it honors, so the only place this can actually be fixed is by overriding EMCC_CFLAGS itself, dropping the exception-handling part while keeping the toolchain's own base flags (EM_FORGE_CFLAGS_BASE). Co-Authored-By: Claude Sonnet 5 --- vinca/templates/build_ament_cmake.sh.in | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index 95d3f08..e3197d7 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -85,6 +85,23 @@ if [[ $target_platform =~ emscripten.* ]]; then # service a message loop (e.g. JupyterLite's xeus-python kernel) just # deadlocks outright -- there's nothing left free to deliver the wakeup. # Asyncify avoids needing real concurrency at all for this. + # + # Asyncify is also flatly incompatible with wasm's native + # exception-handling proposal: binaryen's Asyncify pass hard-crashes + # ("UNREACHABLE executed ... Asyncify.cpp") on any object file compiled + # with wasm EH instructions, rather than just producing a slower build. + # The emscripten-forge toolchain package's own activation script + # (etc/conda/activate.d/activate_emscripten_emscripten-wasm32.sh) sets + # EMCC_CFLAGS="... -sSUPPORT_LONGJMP=wasm -fwasm-exceptions" globally, so + # *every* em++/emcc invocation gets wasm EH by default regardless of what + # CMake is told -- confirmed by tracing emcc.py: -fwasm-exceptions is the + # only thing that sets settings.WASM_EXCEPTIONS=1, and there's no + # -fno-wasm-exceptions counter-flag it recognizes to un-set it again. + # Overriding EMCC_CFLAGS here (dropping just the exception-handling part, + # keeping the rest of the toolchain's own base flags) is the only place + # that actually works -- back to this toolchain's other documented + # default, the older JS-based mechanism, which Asyncify does handle. + export EMCC_CFLAGS="${EM_FORGE_CFLAGS_BASE:-}" echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake # CMake's MODULE library type (add_library(... MODULE), what From 2a281b4d67782ad72ced65392f9cfec2d2b96b1e Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Sat, 12 Sep 2026 15:31:57 +1000 Subject: [PATCH 15/16] fix: give every emscripten-wasm32 module its own ASYNCIFY_IMPORTS Asyncify + dynamic linking (MAIN_MODULE/SIDE_MODULE) needs each individual module -- not just the final demo executable -- to declare which of its own outgoing cross-module calls might suspend. Binaryen's Asyncify pass runs once per module at that module's own build time, and a call it wasn't told about gets a hard `unreachable` trap right after the call site instead of letting the callee's state change propagate. Declaring ASYNCIFY_IMPORTS only on the browser_demo link line (matching upstream emscripten examples) satisfies a separate JS-side check but leaves every other module's own wasm-level guard armed. The list here is the closure discovered by running RoboStack/ros-rolling's rclc browser demo end-to-end and adding exactly the name each crash named next, until it published successfully and a real zenohd router confirmed receiving the messages. Applied to every package rather than gated per-package since an unused entry is a harmless no-op. Co-Authored-By: Claude Sonnet 5 --- vinca/templates/build_ament_cmake.sh.in | 53 ++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index e3197d7..e06d6ee 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -101,17 +101,58 @@ if [[ $target_platform =~ emscripten.* ]]; then # keeping the rest of the toolchain's own base flags) is the only place # that actually works -- back to this toolchain's other documented # default, the older JS-based mechanism, which Asyncify does handle. - export EMCC_CFLAGS="${EM_FORGE_CFLAGS_BASE:-}" - echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # ZENOH_EMSCRIPTEN: rmw_zenoh_pico's own patch (ros-rolling-rmw-zenoh-pico.patch) + # guards its wasm32-specific "ws/" locator scheme (vs. native's "tcp/") behind + # `#if defined(ZENOH_EMSCRIPTEN)` -- nothing previously defined that macro + # anywhere in this build, so it was silently always taking the native "tcp/" + # branch even on this target, and a browser sandbox has no raw TCP sockets + # (zenoh-pico here is built with Z_FEATURE_LINK_WS instead, dialing only + # "ws/" locators) -- confirmed via a real socket(AF_INET, SOCK_STREAM, + # IPPROTO_TCP) call reaching -lwebsocket.js's POSIX-socket-over-WebSocket + # shim and failing, instead of a native WebSocket connection ever being + # attempted. Defined project-wide here (not just for rmw_zenoh_pico) since + # zenoh-pico's own sources may rely on the same macro for analogous checks. + export EMCC_CFLAGS="${EM_FORGE_CFLAGS_BASE:-} -DZENOH_EMSCRIPTEN" + + # Asyncify + dynamic linking (MAIN_MODULE/SIDE_MODULE) needs each *individual* + # module -- not just the final browser_demo executable -- to separately know + # which of its OWN outgoing cross-module calls might suspend. Binaryen's + # Asyncify pass runs once per module at that module's own build time, and + # for any call it wasn't told about, it emits a hard `unreachable` trap + # guard right after the call site instead of silently letting the callee's + # state change propagate -- this is a real, narrow trap (confirmed by + # instrumenting the generated JS glue to log Asyncify.state around every + # import and disassembling the exact trap site with wasm-objdump), not the + # "give it more stack" or "arbitrary libc call" failure modes that looked + # plausible earlier. Passing -s ASYNCIFY_IMPORTS= on the *browser_demo* + # link line alone (matching upstream emscripten examples) only satisfies the + # JS-side "was this change expected" check; every module that itself calls + # one of these names needs the same name in ITS OWN link flags too, or the + # wasm-level guard still fires. The list below is the closure discovered by + # running the rclc demo end-to-end and adding exactly the names the crash + # trace named next, each time: node/publisher/timer creation + # (rclc_node_init_default, rcl_node_init, rmw_create_node, z_open, + # z_session_drop, z_liveliness_declare_token, rclc_publisher_init_default, + # rcl_publisher_init, rmw_create_publisher, z_declare_publisher) and the + # non-threaded rmw_wait poll loop plus publish path (rclc_executor_spin_some, + # rcl_wait, rmw_wait, zp_read, zp_send_keep_alive, z_sleep_ms, rcl_timer_call, + # rcl_publish, rmw_publish, z_publisher_put). Applied to every package here + # (rather than only the specific packages that actually define each of these + # functions) since an unused entry is a harmless no-op and this avoids + # per-package template logic; grow this list the same way (rebuild, read the + # next named crash, add it here) if a not-yet-exercised path (subscriptions, + # services, rclcpp/rclpy's own call chains) hits the same trap. + VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS="rclc_node_init_default,rcl_node_init,rmw_create_node,z_open,z_session_drop,z_liveliness_declare_token,rclc_publisher_init_default,rcl_publisher_init,rmw_create_publisher,z_declare_publisher,rclc_executor_spin_some,rcl_wait,rmw_wait,zp_read,zp_send_keep_alive,z_sleep_ms,rcl_timer_call,rcl_publish,rmw_publish,z_publisher_put" + echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -s ASYNCIFY_IMPORTS=$VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -s ASYNCIFY_IMPORTS=$VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake # CMake's MODULE library type (add_library(... MODULE), what # pybind11_add_module() uses for Python C extensions e.g. rclpy's # _rclpy_pybind11) is a distinct target type from SHARED and reads its # own CMAKE_SHARED_MODULE_CREATE_*_FLAGS variables -- keep it consistent # with the SHARED flags above. - echo "set(CMAKE_SHARED_MODULE_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_SHARED_MODULE_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_MODULE_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -s ASYNCIFY_IMPORTS=$VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_MODULE_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -s ASYNCIFY_IMPORTS=$VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -s ASYNCIFY_IMPORTS=$VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake # A message package's *Config.cmake only exports find_dependency() calls # for what its own package.xml/CMakeLists.txt actually declares -- it has From f6c8903111918141129c9ef8d90f2ea482f26d6c Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Sun, 13 Sep 2026 21:17:27 +1000 Subject: [PATCH 16/16] fix: drop Asyncify project-wide, fix EMCC_CFLAGS to preserve wasm exceptions Asyncify + runtime dlopen() of a SIDE_MODULE is a confirmed, unresolved upstream Emscripten/Binaryen limitation (emscripten-core/emscripten#13049, #15594; pyodide/pyodide#4087 reports the identical crash against Pyodide's own CPython fork) -- discovered while getting rclpy dlopen()'d into a JupyterLite kernel. The real fix is architectural: rmw_wait's own zero-timeout fast path is already genuinely non-blocking, so callers poll it from a Python asyncio loop instead of relying on Asyncify to cooperatively yield. Also: EMCC_CFLAGS was being overridden (not appended to) to drop -fwasm-exceptions, which is now actively harmful now that Asyncify is gone -- it left every package compiling with neither explicit exception-handling flag, mismatching stock xeus-python's own -fwasm-exceptions default and causing "cannot resolve symbol invoke_i" dynamic-linking errors. Append to the toolchain's own EMCC_CFLAGS instead. Co-Authored-By: Claude Sonnet 5 --- vinca/templates/build_ament_cmake.sh.in | 118 +++++++++++------------- 1 file changed, 52 insertions(+), 66 deletions(-) diff --git a/vinca/templates/build_ament_cmake.sh.in b/vinca/templates/build_ament_cmake.sh.in index e06d6ee..61b5c10 100644 --- a/vinca/templates/build_ament_cmake.sh.in +++ b/vinca/templates/build_ament_cmake.sh.in @@ -69,38 +69,34 @@ if [[ $target_platform =~ emscripten.* ]]; then echo "set(CMAKE_STRIP FALSE) # used by default in pybind11 on .so modules">> $SRC_DIR/__vinca_shared_lib_patch.cmake echo "set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH) # fixes an error where numpy header files are not found correctly">> $SRC_DIR/__vinca_shared_lib_patch.cmake - # No real pthreads here (deliberately -- see history below). Instead, - # Asyncify lets the one genuinely blocking call in this stack (rmw_wait, - # which polls zenoh-pico's WS transport) cooperatively yield back to the - # browser's JS event loop instead of really blocking -- that's enough for - # wall-clock time (and incoming WebSocket messages) to advance while a - # "blocking" wait is outstanding, without needing an OS-level thread. + # No real pthreads here, and (as of 2026-09-13) no Asyncify either -- + # see git history on this file for the full saga of both. Asyncify was + # added to let rmw_wait's poll loop cooperatively yield instead of really + # blocking, without needing real OS threads -- but combining Asyncify + # with runtime dlopen() of a SIDE_MODULE turned out to be a real, + # unresolved Emscripten/Binaryen limitation (emscripten-core/emscripten + # #13049, #15594; pyodide/pyodide#4087 reports the identical crash + # against Pyodide's own CPython fork), discovered while trying to get + # rclpy dlopen()'d into a JupyterLite kernel. Real pthreads have their + # own, separate dealbreaker: they require wasm --shared-memory, which is + # viral (every module dlopen'd into an eagerly-linked host must also be + # pthreads/shared-memory or linking fails), and a genuinely blocking wait + # on a thread that also needs to service a message loop (e.g. + # JupyterLite's xeus-python kernel) just deadlocks outright. # - # Real pthreads (USE_PTHREADS=1) were tried instead of this and reverted. - # They do make a blocking std::condition_variable/rmw wait set actually - # wake up, but they require wasm --shared-memory, which is viral: every - # module dlopen'd into an eagerly-linked host must also be pthreads/ - # shared-memory or linking fails ("mismatch in shared state of memory"), - # and worse, a genuinely blocking wait on a thread that also needs to - # service a message loop (e.g. JupyterLite's xeus-python kernel) just - # deadlocks outright -- there's nothing left free to deliver the wakeup. - # Asyncify avoids needing real concurrency at all for this. + # The actual fix is architectural, not a build flag: rmw_wait (see this + # project's own rmw_zenoh_pico patch) already takes a genuinely + # non-blocking, single-poll path whenever the requested wait timeout is + # exactly zero -- callers that want to keep checking for readiness + # periodically should call with a zero timeout in a loop and drive the + # "wait a bit, then check again" cadence themselves (e.g. rclpy callers: + # `rclpy.spin_once(node, timeout_sec=0)` inside a Python `asyncio.sleep()` + # loop, bridged to the browser's JS event loop via pyjs's webloop, not + # Asyncify -- exactly the pattern ros2wasm's own published JupyterLite + # demo uses). This matches Tobias-Fischer/ros-humble's own working + # emscripten-wasm32 port, which also builds with no pthreads and no + # Asyncify. # - # Asyncify is also flatly incompatible with wasm's native - # exception-handling proposal: binaryen's Asyncify pass hard-crashes - # ("UNREACHABLE executed ... Asyncify.cpp") on any object file compiled - # with wasm EH instructions, rather than just producing a slower build. - # The emscripten-forge toolchain package's own activation script - # (etc/conda/activate.d/activate_emscripten_emscripten-wasm32.sh) sets - # EMCC_CFLAGS="... -sSUPPORT_LONGJMP=wasm -fwasm-exceptions" globally, so - # *every* em++/emcc invocation gets wasm EH by default regardless of what - # CMake is told -- confirmed by tracing emcc.py: -fwasm-exceptions is the - # only thing that sets settings.WASM_EXCEPTIONS=1, and there's no - # -fno-wasm-exceptions counter-flag it recognizes to un-set it again. - # Overriding EMCC_CFLAGS here (dropping just the exception-handling part, - # keeping the rest of the toolchain's own base flags) is the only place - # that actually works -- back to this toolchain's other documented - # default, the older JS-based mechanism, which Asyncify does handle. # ZENOH_EMSCRIPTEN: rmw_zenoh_pico's own patch (ros-rolling-rmw-zenoh-pico.patch) # guards its wasm32-specific "ws/" locator scheme (vs. native's "tcp/") behind # `#if defined(ZENOH_EMSCRIPTEN)` -- nothing previously defined that macro @@ -112,47 +108,37 @@ if [[ $target_platform =~ emscripten.* ]]; then # shim and failing, instead of a native WebSocket connection ever being # attempted. Defined project-wide here (not just for rmw_zenoh_pico) since # zenoh-pico's own sources may rely on the same macro for analogous checks. - export EMCC_CFLAGS="${EM_FORGE_CFLAGS_BASE:-} -DZENOH_EMSCRIPTEN" - - # Asyncify + dynamic linking (MAIN_MODULE/SIDE_MODULE) needs each *individual* - # module -- not just the final browser_demo executable -- to separately know - # which of its OWN outgoing cross-module calls might suspend. Binaryen's - # Asyncify pass runs once per module at that module's own build time, and - # for any call it wasn't told about, it emits a hard `unreachable` trap - # guard right after the call site instead of silently letting the callee's - # state change propagate -- this is a real, narrow trap (confirmed by - # instrumenting the generated JS glue to log Asyncify.state around every - # import and disassembling the exact trap site with wasm-objdump), not the - # "give it more stack" or "arbitrary libc call" failure modes that looked - # plausible earlier. Passing -s ASYNCIFY_IMPORTS= on the *browser_demo* - # link line alone (matching upstream emscripten examples) only satisfies the - # JS-side "was this change expected" check; every module that itself calls - # one of these names needs the same name in ITS OWN link flags too, or the - # wasm-level guard still fires. The list below is the closure discovered by - # running the rclc demo end-to-end and adding exactly the names the crash - # trace named next, each time: node/publisher/timer creation - # (rclc_node_init_default, rcl_node_init, rmw_create_node, z_open, - # z_session_drop, z_liveliness_declare_token, rclc_publisher_init_default, - # rcl_publisher_init, rmw_create_publisher, z_declare_publisher) and the - # non-threaded rmw_wait poll loop plus publish path (rclc_executor_spin_some, - # rcl_wait, rmw_wait, zp_read, zp_send_keep_alive, z_sleep_ms, rcl_timer_call, - # rcl_publish, rmw_publish, z_publisher_put). Applied to every package here - # (rather than only the specific packages that actually define each of these - # functions) since an unused entry is a harmless no-op and this avoids - # per-package template logic; grow this list the same way (rebuild, read the - # next named crash, add it here) if a not-yet-exercised path (subscriptions, - # services, rclcpp/rclpy's own call chains) hits the same trap. - VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS="rclc_node_init_default,rcl_node_init,rmw_create_node,z_open,z_session_drop,z_liveliness_declare_token,rclc_publisher_init_default,rcl_publisher_init,rmw_create_publisher,z_declare_publisher,rclc_executor_spin_some,rcl_wait,rmw_wait,zp_read,zp_send_keep_alive,z_sleep_ms,rcl_timer_call,rcl_publish,rmw_publish,z_publisher_put" - echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -s ASYNCIFY_IMPORTS=$VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -s ASYNCIFY_IMPORTS=$VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + # + # NOTE (2026-09-13): this used to also override EMCC_CFLAGS to drop + # -fwasm-exceptions (replacing the toolchain's own native wasm + # exception-handling default with the older JS-based mechanism), because + # Binaryen's Asyncify pass hard-crashed on native wasm-EH instructions. + # Now that Asyncify is dropped entirely (see the block above), that + # override is not just unnecessary but actively harmful: it left every + # package here compiling with *neither* explicit exception-handling flag, + # which falls back to Emscripten's own default (the older JS-based + # mechanism) -- a genuine mismatch against the *stock*, unmodified + # xeus-python package this project now dlopen()'s into (built with the + # toolchain's real default, -fwasm-exceptions, since nothing patches it). + # That mismatch surfaced as "Dynamic linking error: cannot resolve symbol + # invoke_i" the first time a C++ exception-handling code path in a + # dlopen()'d ROS package (rmw_zenoh_pico) needed a JS-based invoke_* + # wrapper stock xeus-python's own MAIN_MODULE was never built to provide. + # Appending to whatever EMCC_CFLAGS the toolchain's own activation script + # already set (not overriding it) keeps -fwasm-exceptions intact, matching + # stock xeus-python exactly. + export EMCC_CFLAGS="${EMCC_CFLAGS:-} -DZENOH_EMSCRIPTEN" + + echo "set(CMAKE_SHARED_LIBRARY_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_LIBRARY_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake # CMake's MODULE library type (add_library(... MODULE), what # pybind11_add_module() uses for Python C extensions e.g. rclpy's # _rclpy_pybind11) is a distinct target type from SHARED and reads its # own CMAKE_SHARED_MODULE_CREATE_*_FLAGS variables -- keep it consistent # with the SHARED flags above. - echo "set(CMAKE_SHARED_MODULE_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -s ASYNCIFY_IMPORTS=$VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_SHARED_MODULE_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -s ASYNCIFY_IMPORTS=$VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake - echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY -s ASYNCIFY_STACK_SIZE=24576 -s ASYNCIFY_IMPORTS=$VINCA_EMSCRIPTEN_ASYNCIFY_IMPORTS -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_MODULE_CREATE_C_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_SHARED_MODULE_CREATE_CXX_FLAGS \"-s ASSERTIONS=1 -s SIDE_MODULE=1 -sWASM_BIGINT -s ALLOW_MEMORY_GROWTH=1 \")">> $SRC_DIR/__vinca_shared_lib_patch.cmake + echo "set(CMAKE_EXE_LINKER_FLAGS \"-sMAIN_MODULE=1 -sASSERTIONS=1 -fexceptions -lembind -sWASM_BIGINT -sALLOW_MEMORY_GROWTH=1 -L$SRC_DIR/build -L$PREFIX/lib\") # remove SIDE_MODULE from exe linker flags">> $SRC_DIR/__vinca_shared_lib_patch.cmake # A message package's *Config.cmake only exports find_dependency() calls # for what its own package.xml/CMakeLists.txt actually declares -- it has