Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f692af7
fix: remove unsupported -s DEMANGLE_SUPPORT=1 emcc flag for emscripte…
Tobias-Fischer Sep 7, 2026
26be199
fix: evaluate v1 if/then/else selectors in pinning_overrides values
Tobias-Fischer Sep 7, 2026
7133b30
feat: enable real pthreads for emscripten-wasm32 builds
Tobias-Fischer Sep 8, 2026
dd38b4a
fix: don't require a build_platform copy of rosidl_default_generators…
Tobias-Fischer Sep 8, 2026
b504063
feat: make the default emscripten RMW_IMPLEMENTATION configurable
Tobias-Fischer Sep 8, 2026
235bd2f
feat: make the emscripten static rosidl typesupport backend configurable
Tobias-Fischer Sep 8, 2026
325d0af
fix: apply emscripten pthreads flags to CMake MODULE libraries too
Tobias-Fischer Sep 9, 2026
157c60b
Merge remote-tracking branch 'origin/master' into feature/emscripten-…
Tobias-Fischer Sep 9, 2026
b1960a5
fix: pre-find the static typesupport override, not just build it
Tobias-Fischer Sep 10, 2026
ff8ed10
fix: drop the unsatisfiable build-platform rosidl_default_generators dep
Tobias-Fischer Sep 10, 2026
11462ea
Revert "fix: drop the unsatisfiable build-platform rosidl_default_gen…
Tobias-Fischer Sep 10, 2026
c974b23
fix: don't pre-find the static typesupport override for message packages
Tobias-Fischer Sep 10, 2026
c3f1185
fix: use explicit refs/tags/ in GitHub raw package.xml URLs
Tobias-Fischer Sep 10, 2026
69fa7de
revert: drop real pthreads for emscripten-wasm32, use Asyncify instead
Tobias-Fischer Sep 11, 2026
31fbd83
fix: override EMCC_CFLAGS to drop -fwasm-exceptions, incompatible wit…
Tobias-Fischer Sep 11, 2026
2a281b4
fix: give every emscripten-wasm32 module its own ASYNCIFY_IMPORTS
Tobias-Fischer Sep 12, 2026
f6c8903
fix: drop Asyncify project-wide, fix EMCC_CFLAGS to preserve wasm exc…
Tobias-Fischer Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions vinca/distro.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner>/<repo>/<ref>/<path> 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/<name> 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/<NAMESPACE>/-/raw/<REV>/<PATH>
Expand Down
60 changes: 59 additions & 1 deletion vinca/pinning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
129 changes: 117 additions & 12 deletions vinca/templates/build_ament_cmake.sh.in
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,125 @@ 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 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
# 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
# fi
# 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.
#
# 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.
#
# 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.
#
# 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 \")">> $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
# 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.
# 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
# <pkg>::<pkg>__rosidl_generator_c(pp)). That makes every downstream
# find_package(<this package>) fail with "referenced, but are missing:
# <pkg>::<pkg>__rosidl_generator_c(pp)" -- reproducible regardless of
# whether the C or C++ (or both) override is set. Without any pre-find,
# rosidl_generate_interfaces() 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"
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 \
Expand All @@ -92,8 +197,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"
Expand Down
59 changes: 59 additions & 0 deletions vinca/test_github_raw_url.py
Original file line number Diff line number Diff line change
@@ -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 <owner>/<repo>/<ref>/<path> 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/<name> 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"
)
6 changes: 4 additions & 2 deletions vinca/test_snapshot_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
}
Expand Down