Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
179 changes: 179 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,30 @@ jobs:
nu scripts/test-pure-rust.nu check-hu
shell: bash

# docs/tools/hu.md publishes an aarch64-apple-darwin tarball, and the
# release workflow has a macOS leg — but no `v*` tag has been cut since
# that leg was written, so nothing has ever confirmed `hu` builds,
# packages and runs on macOS. This job is already on a macOS runner and
# already compiles the crate, so proving the rest costs a few minutes
# and turns a published artifact name into a tested claim.
#
# Release profile deliberately: the release leg builds --release with
# web-plugins, and a debug build proving nothing about that is how an
# untested promise survives looking tested.
- name: hu packages and runs on macOS
if: runner.os == 'macOS'
run: |
set -e
nu scripts/build-hu-release.nu --binary-only --no-sums --out mac-dist
ls -l mac-dist
HUHOME="$RUNNER_TEMP/huhome-mac"
mkdir -p "$HUHOME/.local/bin"
tar -xzf mac-dist/hu-*-aarch64-apple-darwin.tar.gz -C "$HUHOME/.local/bin" hu
got=$(HOME="$HUHOME" "$HUHOME/.local/bin/hu" --version)
echo "macOS build reports: $got"
case "$got" in "hu "*) ;; *) echo "FAIL: no version from the macOS binary"; exit 1 ;; esac
shell: bash

- name: Check all examples
run: |
if [ "$RUNNER_OS" == "Linux" ]; then
Expand Down Expand Up @@ -475,6 +499,161 @@ jobs:
nu scripts/test-pure-rust.nu clippy-tests
shell: bash

hu-aarch64-cross:
name: hu cross-compiles for aarch64 Linux
runs-on: ubuntu-latest
permissions:
contents: read
# docs/tools/hu.md publishes an aarch64 Linux tarball. That it builds at
# all was established once, by hand on an aarch64 runner — hu pulls in
# wasmtime, rusqlite's bundled C, ring's assembly and axum, any of which
# could stop cross-compiling. Nothing guarded it afterwards, so the
# release leg would have found out at tag time.
#
# Deliberately build-and-package only, no execution: qemu-user is
# expensive to provision (it killed a whole job while still realizing)
# and running the binary is a separate question from whether the release
# artifact can be produced.
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu

- name: Install cargo-zigbuild
uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild

- name: Install ziglang
run: pip install ziglang

- name: Install nushell
uses: hustcer/setup-nu@v3
with:
version: "0.113.1"

- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: ubuntu-latest-hu-aarch64-cross

- name: Cross-build and package
shell: bash
run: |
set -e
TGT=aarch64-unknown-linux-gnu
cargo zigbuild --release --bin hu -p hiroz-union \
--features web-plugins --no-default-features --target "$TGT"
BIN="target/$TGT/release/hu"
# A cross build that silently emitted x86_64 would otherwise look
# exactly like success.
file "$BIN"
file "$BIN" | grep -q "ARM aarch64" || {
echo "FAIL: not an aarch64 binary"; exit 1; }
nu scripts/build-hu-release.nu --binary-only --binary-from "$BIN" \
--target "$TGT" --no-sums --out a64-dist
test -s "a64-dist/$(ls a64-dist | head -1)" || {
echo "FAIL: nothing packaged"; exit 1; }
tar -tzf a64-dist/hu-*-$TGT.tar.gz

hu-docs-repro:
name: hu docs reproduce from a download (ubuntu-latest)
runs-on: ubuntu-latest
permissions:
contents: read
# Enforces the contract that every `hu` command in docs/tools/ is
# runnable by someone who only downloaded a release: it packages the
# artifacts, installs them into a scratch HOME with HU_PLUGIN_PATH unset,
# and executes the documented commands against that install.
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
target: wasm32-wasip2

- name: Install nushell and jq
run: |
sudo apt-get update && sudo apt-get install -y jq
cargo install nu --locked --version 0.113.1
# No `|| true`: a swallowed install failure resurfaces three steps
# later as `nu: command not found`, which names neither the cause
# nor the step that caused it.
nu --version

- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: ubuntu-latest-hu-docs-repro

# release.yml only ever runs on a `v*` tag, so no pull request exercises
# it. This is the one check those steps get before a tag is pushed, and
# it is deliberately pure string arithmetic — no runner, no network, no
# build — so it can run here. It encodes the tag-vs-asset-name split that
# a pre-release tag depends on, which is the defect that broke the first
# pre-release ever cut on the other channel.
- name: Release workflow version semantics
run: bash scripts/test-release-version-semantics.sh

- name: Installer refusal paths
run: bash scripts/test-install-hu.sh

- name: Package the release artifacts
run: nu scripts/build-hu-release.nu --out dist

- name: Install exactly as a user would, offline
run: |
set -e
HUHOME="$RUNNER_TEMP/huhome"
mkdir -p "$HUHOME"
HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" sh scripts/install-hu.sh --offline dist
echo "HUHOME=$HUHOME" >> "$GITHUB_ENV"

# The traffic source is built from the tree, NOT taken from the artifact.
# `hu` cannot generate its own traffic (`hu meter pub` needs message
# definitions no release ships), so without an external publisher the
# suite measures an empty graph and every `hu meter` command degrades to
# "did the process start". This example stands in for the deployment hu
# is documented to observe.
- name: Build the traffic fixture
run: cargo build --release --example z_pubsub -p hiroz

- name: Reproduce the documented commands
run: |
set -e
unset HU_PLUGIN_PATH
HOME="$HUHOME" "$HUHOME/.local/bin/hu" router > router.log 2>&1 &
ROUTER_PID=$!
sleep 5
# A router that failed to bind is otherwise silent, and surfaces as a
# dozen unrelated-looking measurement failures further down. Fail here
# instead, where the message names the cause.
kill -0 "$ROUTER_PID" 2>/dev/null || {
echo "FAIL: router died on startup"; tail -10 router.log; exit 1; }
# --require-traffic so this can never silently degrade back into an
# exit-status check if the publisher argument is dropped.
# Status must not pass through a pipe.
# `set +e` around the run is not optional: with `set -e` active the
# shell exits ON the failing command, so `cat repro.log` never runs
# and the failure is reported with no output at all. Captured rc is
# worthless if the capture is unreachable.
set +e
nu scripts/test-hu-docs-repro.nu \
--home "$HUHOME" \
--publisher "${CARGO_TARGET_DIR:-$PWD/target}/release/examples/z_pubsub" \
--require-traffic > repro.log 2>&1
rc=$?
set -e
cat repro.log
echo "--- router.log ---"; tail -20 router.log || true
exit $rc

wasm-plugin-tests:
name: WASM Plugin Tests (ubuntu-latest)
runs-on: ubuntu-latest
Expand Down
45 changes: 43 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -794,8 +794,49 @@ jobs:
|| { echo "FAIL: published install-hu.sh differs from the tagged source"; exit 1; }
echo "ok — the documented one-liner URL serves this tag's installer"

# If the published release cannot install itself, put it back in the drawer.
# A draft is invisible to everyone without push access and keeps its assets,
- name: Install nushell
uses: hustcer/setup-nu@v3
with:
version: "0.113.1"

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

# From the source checkout, not the artifact: `hu` cannot generate its own
# traffic, because no release ships message definitions (#309, G2). With
# only `hu router` the suite measures an empty graph and decays into an
# exit-status check -- which a truncated plugin passes.
- name: Build the traffic fixture from source
run: cargo build --release --example z_pubsub -p hiroz

- name: Reproduce the documented commands against the published release
run: |
set -eu
unset HU_PLUGIN_PATH || true
HOME="$HUHOME" "$HUHOME/.local/bin/hu" router > router.log 2>&1 &
ROUTER_PID=$!
sleep 5
# A router that died on startup shows up as a dozen unrelated
# measurement failures, so assert on it directly.
kill -0 "$ROUTER_PID" 2>/dev/null || {
echo "FAIL: router died on startup"; tail -20 router.log; exit 1; }
# Exit status must not pass through a pipe, and the log must be
# printed whichever way this goes.
set +e
nu scripts/test-hu-docs-repro.nu \
--home "$HUHOME" \
--publisher "$PWD/target/release/examples/z_pubsub" \
--require-traffic > repro.log 2>&1
rc=$?
set -e
cat repro.log
kill "$ROUTER_PID" 2>/dev/null || true
test "$rc" -eq 0 || {
echo "FAIL: the published release does not reproduce its own docs"; exit 1; }

# If the published release cannot install itself, or cannot reproduce its own
# documentation, put it back in the drawer. A draft is invisible to everyone
# without push access and keeps its assets,
# so the run can be diagnosed from exactly what shipped. The tag survives -- withdrawing a release does not
# delete it -- so the fix is a new tag rather than a rewritten one.
#
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,20 @@ jobs:
echo "Fixed rmw_zenoh_cpp config by removing invalid transport_optimization section"
fi

# `cache: false` is load-bearing. This action runs its own rust-cache with
# a key derived from the JOB ID, which is `interop_test` for every distro
# in the matrix -- so the four legs shared one target/. humble is the only
# 22.04 image, so it restored proc-macros the 24.04 legs had built and
# failed with "GLIBC_2.39 not found". The cache below is per-distro.
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false

- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: ${{ matrix.distro }}-interop
# -v2 discards entries saved while the shared cache above was in use.
shared-key: ${{ matrix.distro }}-interop-v2

- name: Install cargo-nextest
uses: taiki-e/install-action@v2
Expand Down
2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Before releasing, bump the version in all three places consistently:

The `hiroz-py` wheel depends on `hiroz-msgs-py>=<version>` — update that lower bound too when bumping.

**One version governs every Rust crate, and a check enforces it.** `hiroz`, `hiroz-protocol` and `hiroz-union` each used to carry a literal `version`, which meant `cargo publish --workspace` could leave a published crate behind at the old number while the tag said otherwise, and a `v0.2.0` tag could produce `hu` assets named `0.1.0`. They now inherit. Nothing yet enforces that automatically, so a reintroduced literal is caught by review, not by CI.
**One version governs every Rust crate, and a check enforces it.** `hiroz`, `hiroz-protocol` and `hiroz-union` each used to carry a literal `version`, which meant `cargo publish --workspace` could leave a published crate behind at the old number while the tag said otherwise, and a `v0.2.0` tag could produce `hu` assets named `0.1.0`. They now inherit, and `scripts/test-release-version-semantics.sh` fails if any crate under `crates/` reintroduces a literal. `ci.yml` runs it on every pull request.

`hu` ships from the workspace's own `v*` tags. It has neither an independent number nor, at present, an independent tag namespace.

Expand Down
2 changes: 2 additions & 0 deletions docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ The example you just ran does four things:
3. **Publishes** — `node.create_pub::<RosString>("/chatter")` sends CDR-serialized ROS 2 messages on a topic.
4. **Subscribes** — `node.create_sub::<RosString>("/chatter")` receives and deserializes them asynchronously.

The talker also calls `.with_type_description_service()` when building its node. That is what lets consumers with no compiled knowledge of the message type — `hu meter echo /chatter`, dynamic subscribers, bridges — fetch the schema and decode what it publishes. ROS 2's own client libraries serve that service by default; a hiroz node must opt in, so a node of your own needs the same call.

The full example source (with CLI flags and multi-backend support) is at [`examples/z_pubsub.rs`](https://github.com/ZettaScaleLabs/hiroz/blob/main/crates/hiroz/examples/z_pubsub.rs) in the repository.

---
Expand Down
13 changes: 13 additions & 0 deletions docs/tools/hu-vs-ros2cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@

**`hu meter pub` resolves the message schema from `.msg` files on disk**, so — like `ros2 topic pub` — it can publish to an empty topic with no node present. The plugin host reads the type from a `.msg` under `HIROZ_MSG_PATH` (colon-separated package or prefix directories, e.g. an ament `.../share`); if the type isn't on disk it falls back to discovering it from a live publisher or subscriber on the topic. Only when neither is available does it report a clear "could not resolve a message schema" error rather than guessing.

The subscribing side (`hu meter echo`, `hu meter delay`) consults the same two sources in the **opposite** order: live discovery first, `HIROZ_MSG_PATH` only as a fallback. The orders differ because `pub` is told its type by the caller — disk alone is sufficient, and a live node need not exist at all — whereas `subscribe` is given only a topic, so it must consult the graph regardless, both to learn which `.msg` to load and to reproduce the publisher's exact type hash in its key expression.

---

## Measurement accuracy: `hu meter hz` vs. `ros2 topic hz`
Expand Down Expand Up @@ -108,6 +110,7 @@ Most `hu` commands emit newline-delimited JSON with `--json`. This makes them co

`ros2cli` outputs human-formatted text with no stable machine-readable format. Parsing `ros2 topic list` output requires string splitting on `/` and filtering out blank lines; parsing `ros2 topic info` requires column-counting. Both break across ROS 2 versions.

<!-- repro: skip needs sensor_msgs publishers and writes to /var/log -->
```bash
# Filter to sensor_msgs topics only
hu meter list topics --json | jq '.[] | select(.type | contains("sensor_msgs"))'
Expand All @@ -132,6 +135,7 @@ hu stream --json >> /var/log/ros-graph-events.jsonl

`hu monitor watch` subscribes to Zenoh liveliness tokens, which are the mechanism hiroz and `rmw_zenoh_cpp` use to announce entity existence. It prints a line the moment a node, topic, service, or action appears or disappears — with sub-millisecond latency after the transport propagates the change.

<!-- repro: timeout-quiet 5 -->
```bash
hu monitor watch
```
Expand Down Expand Up @@ -164,6 +168,7 @@ The standard way to read ROS 2 logs from the CLI is `ros2 topic echo /rosout`, w

`hu monitor log` decodes `/rosout` at the CDR layer and presents a clean stream:

<!-- repro: skip needs /rosout traffic from an rclcpp node -->
```bash
# Tail all log messages
hu monitor log
Expand All @@ -186,6 +191,7 @@ hu monitor log --count 50

`hu monitor log-level` works on Humble nodes because it calls the `GetLoggerLevels` / `SetLoggerLevels` services directly via Zenoh, without relying on a ros2cli verb that was only added in Jazzy.

<!-- repro: skip needs an rclcpp node exposing logger services -->
```bash
# Set the planner's root logger to DEBUG
hu monitor log-level /planner DEBUG
Expand All @@ -206,8 +212,10 @@ hu monitor log-level /planner
| Zenoh session setup | N/A | N/A | Host opens sessions declared in plugin's manifest; plugin never handles connection setup |
| Works in hermetic / offline envs | no — requires pip | no — requires Qt | yes — single binary copy |

<!-- repro: run -->
```bash
# Drop a .wasm file and it becomes a plugin
# repro: skip my-debug-tool.wasm is a placeholder for a plugin the reader supplies
cp ./my-debug-tool.wasm ~/.local/share/hu/plugins/
hu plugin list # shows all .wasm plugins found in search path
```
Expand Down Expand Up @@ -238,6 +246,7 @@ For existing rclcpp/rclpy codebases using DDS, ros2cli remains the right tool. F

### Rate and bandwidth

<!-- repro: skip side-by-side reference using placeholder names (/scan, /lidar_driver, /enable); the runnable equivalents are exercised from hu.md -->
```bash
# Publish rate
ros2 topic hz /scan
Expand All @@ -253,6 +262,7 @@ hu meter delay /scan

### Message inspection

<!-- repro: skip side-by-side syntax reference using placeholder names; `echo` is exercised against a live topic in hu.md, and `pub` cannot run from a download at all (it needs message definitions no release ships) -->
```bash
# Echo messages
ros2 topic echo /chatter
Expand All @@ -265,6 +275,7 @@ hu meter pub /enable --msg-type std_msgs/msg/Bool --yaml '{data: true}'

### Graph introspection

<!-- repro: skip side-by-side reference using placeholder names (/scan, /lidar_driver, /enable); the runnable equivalents are exercised from hu.md -->
```bash
# List topics
ros2 topic list
Expand All @@ -287,6 +298,7 @@ hu monitor graph

### Services and parameters

<!-- repro: skip needs a service server and a node exposing parameter services -->
```bash
# Call a service
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts '{a: 3, b: 7}'
Expand All @@ -307,6 +319,7 @@ hu meter param load /talker params.yaml

### Logging

<!-- repro: skip needs /rosout traffic from an rclcpp node -->
```bash
# Stream logs
ros2 topic echo /rosout
Expand Down
Loading
Loading