From f692af7213bfb6da02fb6493949c142b1f5d1fed Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Mon, 7 Sep 2026 13:01:19 +1000 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 ), }