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 .agents/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# Agent Guide: udp_bridge

Reliable-ish UDP transport of selected ROS 2 topics between hosts on
unreliable networks — the boat ↔ operator-station telemetry link (WiFi, mesh
radio, cellular, satellite). Topics are explicitly selected, rate-limited,
compressed (zlib), fragmented to fit UDP datagrams, and re-requested on loss
within a bounded resend window. Multiple named connections per remote give
path redundancy.

## Workflow

GitHub-origin repo (`rolker/udp_bridge`). The default branch is **`jazzy`**,
not `main` — branch protection and the `no-commit-to-branch` pre-commit hook
both target `jazzy`. Standard workspace worktree/PR workflow applies when
developed inside the `ros2_agent_workspace` (see the workspace `AGENTS.md`;
the thin root `AGENTS.md` here is the Copilot-review pointer).

CI (`.github/workflows/ci.yml`: colcon build + test on `ros:jazzy-ros-core`)
and pre-commit (`.pre-commit-config.yaml`) are configured. **Linter-labeled
tests are excluded in CI** (`--ctest-args -LE linter`, `--pytest-args -m "not
linter"`) until the pre-existing code gets license headers; re-enable by
dropping those exclusions.

## Package Inventory

| Package | Language | Build targets (from CMakeLists.txt) |
|---------|----------|-------------------------------------|
| `udp_bridge` | C++17 (ament_cmake) | `udp_bridge` library, `udp_bridge_node` executable, 13 gtest targets (`utest`, `test_utilities`, `test_defragmenter`, `test_qos_resolution`, `test_qos_matching_integration`, `test_connection_rate_limit`, `test_remote_node_resend`, `test_connection_cleanup`, `test_giveup_diagnostic`, `test_publish_queue`, `test_subscriber_registry`, `test_stale_packet_gate`) |
| `udp_bridge_interfaces` | rosidl | 13 messages + 3 services (`Subscribe`, `AddRemote`, `ListRemotes`) |

`udp_bridge` depends on `rclcpp`, `rclcpp_lifecycle`, `diagnostic_updater`,
`lifecycle_msgs`, `udp_bridge_interfaces`, and ZLIB. `std_msgs` is
test-only in CMake (REQUIRED under `BUILD_TESTING` for the QoS-matching
integration test) though listed as a full `<depend>` in `package.xml`.

## Repository Layout

```
udp_bridge/
├── include/udp_bridge/ # public headers (installed) — heavily commented,
│ # incl. locking + callback-group invariants
├── src/ # library sources + udp_bridge_node.cpp (main)
├── test/ # gtest suites (colcon test)
├── test/mininet/ # manual-run integration scripts — NOT wired into CI
├── launch/ # udp_bridge_launch.py + legacy ROS 1 test launches
├── config/example_params.yaml
└── doc/ # conceptual_overview.md, qos_design.md (read this)
udp_bridge_interfaces/ # msg/ + srv/ definitions
```

## Architecture Overview

`UDPBridge` is a **lifecycle node** (`rclcpp_lifecycle::LifecycleNode`): it
does nothing until transitioned Unconfigured → Inactive → Active.
`udp_bridge_launch.py` auto-configures and auto-activates; running the node
manually requires `ros2 lifecycle set ... configure` + `activate`.

`udp_bridge_node` runs a `MultiThreadedExecutor` (8 threads) with three
callback groups whose invariants are documented at the top of
`src/udp_bridge_node.cpp`:
Comment on lines +58 to +60

- `socket_drain_group_` (mutually exclusive) — the hot path: a 10 ms
`spin_once` timer drains the UDP socket and decodes packets. Must never be
starved or the 500 KB kernel SO_RCVBUF fills and packets drop silently.
- `republish_group_` (reentrant) — forwarding-subscription callbacks that
serialize local messages and send them over UDP.
- `periodic_group_` (mutually exclusive) — stats (1 s), bridge info (2 s),
subscription update (1 s), diagnostics (1 s), and all six service handlers.
- A separate **publish worker thread** (`PublishQueue`, issue #10) does the
rmw-touching republish of received data, so a stalled destination
publisher can't wedge the socket drain. Bounded by
`publish_queue_max_bytes`; drops oldest under back-pressure.

Wire format: `Packet`/`WrappedPacket` (packet.h, wrapped_packet.h), zlib
compression on send (`src/packet.cpp`), fragmentation above
`maximum_packet_size`, reassembly in `Defragmenter`. Missing packets are
re-requested for up to `kSentPacketTTL` (5 s, `resend_constants.h`); after
that the receiver gives up and the loss is permanent — the wire is
best-effort with loss reduction, never RELIABLE (see `doc/qos_design.md`).

## Key Parameters (verified in `on_configure`, src/udp_bridge.cpp)

| Parameter | Default | Notes |
|---|---|---|
| `name` | node name | Bridge name as seen by remote bridges (unique per network) |
| `port` | `4200` | UDP listen port; clamped 0–65535 |
| `maximum_packet_size` | `65500` | Clamped 256–65500 |
| `drop_stale_packets` | `true` | Gate dropping late out-of-order resends per destination topic |
| `resend_giveup_warn_rate_per_s` / `..._error_rate_per_s` | `5.0` / `50.0` | Diagnostic thresholds; live-tunable via `ros2 param set` |
| `publish_queue_max_bytes` | 64 MiB | Clamped 1 MiB–2 GiB |
| `remotes_list` | `[]` | Then per-remote `remotes.<r>.connections_list`, per-connection `host`, `port`, `return_host`, `return_port`, `maximum_bytes_per_second` (0 → default **50000** B/s, `Connection::default_rate_limit`), `topics_list`, and per-topic `source` (default: topic label), `destination` (default: source), `queue_size` (10), `period` (0.0), `reliability`, `durability`, `history_depth` (0, clamped ≤ 10000) |

See `config/example_params.yaml` for the nested structure.

## Topics & Services (verified in src/udp_bridge.cpp, src/remote_node.cpp)

Names are built as `<node_name>/...` (relative, so they resolve under the
node's namespace). With the default node name: `udp_bridge/bridge_info`
(`BridgeInfo`, latched transient_local keep-last-1), `udp_bridge/topic_statistics`
(`TopicStatisticsArray`, depth 10), and per-remote latched
`udp_bridge/remotes/<remote>/bridge_info` + `.../topic_statistics`.
Diagnostics go to `/diagnostics` via `diagnostic_updater` at ~1 Hz
(per-connection status, per-remote resend give-ups, publish-queue health).
Forwarded topics use dynamically created generic publishers/subscriptions.

Services (all `udp_bridge_interfaces/srv`, on `<node_name>/...`):

| Service | Type | Direction |
|---|---|---|
| `remote_subscribe` | `Subscribe` | Ask the **remote** to forward its `source_topic` to us |
| `remote_advertise` | `Subscribe` | Forward a **local** `source_topic` to the remote |
| `remove_subscribe` / `remove_advertise` | `Subscribe` | Mirrors of the above (idempotent removal) |
| `add_remote` | `AddRemote` | Add/update a named remote + connection at runtime |
| `list_remotes` | `ListRemotes` | Enumerate known remotes/connections |

## Key Files to Read First

1. `udp_bridge/src/udp_bridge_node.cpp` — callback-group + threading invariants (top comment)
2. `udp_bridge/src/udp_bridge.cpp` — locking convention (top comment) + `on_configure`
3. `udp_bridge/doc/qos_design.md` — QoS policy and the rmw_zenoh_cpp RELIABLE workaround
4. `udp_bridge/include/udp_bridge/resend_constants.h` — resend-protocol timing contract
5. `udp_bridge/config/example_params.yaml` — parameter structure

## Build & Test

```bash
# From the layer workspace directory (e.g., layers/main/core_ws/)
colcon build --symlink-install --packages-select udp_bridge_interfaces udp_bridge
# setup.bash must be sourced in the same shell
colcon test --packages-select udp_bridge udp_bridge_interfaces && colcon test-result --verbose
```

The gtest suite runs in plain `colcon test`. The scripts under
`udp_bridge/test/mininet/` are **manual-run only** — not registered in
CMakeLists at all (nothing gates them; they simply aren't test targets):
mininet needs root + netns and the multilink scaffold is still ROS 1
(`roscore`/`rosmon`) — see `test/mininet/README.md`. Only `recv_q_trace.py`
there works today.

## Common Pitfalls

- **`UDP_BRIDGE_BUILD_TESTING` ODR discipline** (CMakeLists.txt comments):
test-only members gated by this macro (e.g.
`Connection::resend_call_count_for_test_`) change `sizeof(Connection)`.
Every in-package target that compiles against those headers — the library,
`udp_bridge_node`, and each test — must define the macro identically, or
layouts mismatch and tests segfault. Register new tests via the
`add_udp_bridge_gtest()` helper; never hand-roll `ament_add_gtest` here.
The macro is package-namespaced (not plain `BUILD_TESTING`) so downstream
consumers of the installed headers can't trip the same hazard.
- **Lifecycle:** a plain `ros2 run` leaves the node Unconfigured and silent.
Parameters are read in `on_configure` (via the reentrancy-safe
`declareIfMissing` — bare `declare_parameter` breaks cleanup→configure).
- **Destination-publisher reliability is RELIABLE, not BEST_AVAILABLE**, as
a temporary rmw_zenoh_cpp 0.2.9 workaround (`qos_resolution.h`,
`doc/qos_design.md`). Design intent is BEST_AVAILABLE; per-topic
`reliability:` config overrides.
- **Rate fields are bytes per second, not bits**; the per-connection default
when `maximum_bytes_per_second` is 0 is **50000 B/s** — the
`example_params.yaml` comment claiming 500000 is wrong (that number is the
socket buffer size).
- **`remotes.<label>.name` is not a real parameter** — the remote's name is
the `remotes_list` label itself. The old `udp_bridge/README.md` and the
example YAML comment claiming otherwise are stale.
- **Connection config is one complete per-connection block**: partial
overrides silently break return-path routing (boat replies to the wrong
host, data flow drops to zero).
- **Threading:** never add blocking work to the socket-drain path; respect
the lock-briefly-copy-out convention documented at the top of
`udp_bridge.cpp` and the group assignments in `udp_bridge_node.cpp`.
- **Appending fields to `Remote.msg`/`MessageInternal.msg` changes the ROS 2
type hash** — mixed-version bridges need a coordinated redeploy (see the
schema-evolution note in `Remote.msg`).

## Instructions for Use

Read the threading/locking comments and `doc/qos_design.md` before touching
the send, receive, or QoS paths. Verify any documentation claim against the
source before relying on it.
79 changes: 79 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# CI for udp_bridge.
# Based on the workspace template (ros2_agent_workspace .agent/templates/ci_workflow.yml).

name: CI

on:
push:
branches: [jazzy]
pull_request:
branches: [jazzy]

jobs:
build-and-test:
runs-on: ubuntu-24.04
permissions:
contents: read
container:
image: ros:jazzy-ros-core

steps:
- name: Update apt
run: apt-get update

- name: Install ros dev tools
run: DEBIAN_FRONTEND=noninteractive apt-get -y install ros-dev-tools

- name: Checkout
uses: actions/checkout@v6

- name: Init rosdep
run: |
if [ ! -f /etc/ros/rosdep/sources.list.d/20-default.list ]; then
rosdep init
fi

- name: Update rosdep
run: rosdep update

- name: Create workspace
run: mkdir -p ../ws/src && ln -s "$GITHUB_WORKSPACE" ../ws/src/udp_bridge

# All dependencies resolve via rosdep — no source-sibling clones needed.

- name: Install ROS dependencies
working-directory: ../ws
run: >
DEBIAN_FRONTEND=noninteractive
rosdep install --from-paths src --ignore-src -r -y

- name: Build
working-directory: ../ws
run: >
bash -c 'source /opt/ros/jazzy/setup.bash &&
colcon build --packages-up-to
udp_bridge udp_bridge_interfaces
--cmake-args -DBUILD_TESTING=ON'

- name: Test
working-directory: ../ws
# Linter tests (label "linter") are excluded until the pre-existing
# code is brought up to standard: copyright headers are tracked in
# rolker/udp_bridge#40; lint reformat rides with it. Re-enable by
# dropping the --ctest-args/--pytest-args exclusions.
run: >
bash -c 'source /opt/ros/jazzy/setup.bash &&
source install/setup.bash &&
colcon test --packages-select
udp_bridge udp_bridge_interfaces
--event-handlers console_direct+
--return-code-on-test-failure
--ctest-args -LE linter
--pytest-args -m "not linter"'

- name: Test results
working-directory: ../ws
run: >
bash -c 'source /opt/ros/jazzy/setup.bash &&
colcon test-result --verbose'
if: always()
48 changes: 48 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Pre-commit configuration for udp_bridge.
# Based on the workspace template (ros2_agent_workspace
# .agent/templates/pre-commit-config.yaml); see ADR-0004/0005 there for the
# enforcement hierarchy rationale.
#
# Install: pip install pre-commit && pre-commit install
# Run manually: pre-commit run --all-files

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
args: ['--unsafe'] # allow ROS YAML tags
Comment on lines +15 to +16
- id: check-xml
- id: check-merge-conflict
- id: check-executables-have-shebangs
- id: mixed-line-ending
- id: check-added-large-files
args: ['--maxkb=500']

- repo: https://github.com/cheshirekow/cmake-format-precommit
rev: v0.6.13
hooks:
- id: cmake-lint
args: ['--disabled-codes', 'C0103,C0113,C0301']
# C0103: invalid-name (ROS packages use unconventional names)
# C0113: missing-command (ament macros not recognized)
# C0301: line-too-long

# Python here is dev tooling (doc/conf.py, launch file, mininet test
# scripts), not shipped modules; black/flake8 stay off so the onboarding
# change doesn't reformat pre-existing scripts. Enable them if the repo
# grows first-class Python code.

- repo: https://github.com/adrienverge/yamllint
rev: v1.35.1
hooks:
- id: yamllint
args: ['-d', '{extends: default, rules: {line-length: {max: 120}, document-start: disable, truthy: disable}}']

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: no-commit-to-branch
args: ['--branch', 'jazzy']
7 changes: 3 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
# AGENTS.md — udp_bridge

Instructions for AI agents working in this repository — including **GitHub
Copilot code review**, which reads this file when reviewing PRs. There is no
`.agents/README.md` deep guide yet (the workspace convention for project
agent guides — distinct from the `.agent/work-plans/` directory); start from
the top-level `README.md`.
Copilot code review**, which reads this file when reviewing PRs. Coding
agents: the deep guide (packages, layout, pitfalls) is
[`.agents/README.md`](.agents/README.md); read it before making changes.

## Workspace Rules

Expand Down
5 changes: 3 additions & 2 deletions udp_bridge/src/udp_bridge_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@
//
// periodic_group_ (MutuallyExclusive)
// Owns: stats_report_timer_, bridge_info_timer_, diagnostic_timer_,
// subscription_update_timer_, and the four service handlers
// (subscribe / advertise / add_remote / list_remotes). Admin-style
// subscription_update_timer_, and the six service handlers
// (subscribe / advertise / remove_subscribe / remove_advertise /
// add_remote / list_remotes). Admin-style
// work that must not race the hot path but can be safely serialized
// against itself.
//
Expand Down