Skip to content

fix(test): keep test DDS domains out of the kernel ephemeral port range - #597

Open
bburda wants to merge 4 commits into
mainfrom
fix/test-domain-ports
Open

fix(test): keep test DDS domains out of the kernel ephemeral port range#597
bburda wants to merge 4 commits into
mainfrom
fix/test-domain-ports

Conversation

@bburda

@bburda bburda commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Pull Request

Summary

Test DDS domains were allocated across 1-232. RTPS gives a domain the UDP slice
[7400 + 250*d, 7400 + 250*d + 249], and the kernel hands out ephemeral ports from
net.ipv4.ip_local_port_range, 32768-60999 by default. That range maps back to domains 102 to
214, so most of the domains we used could be taken by any other process on the machine. When
that happened the node died at startup with failed to bind to ANY:<port>: address in use, and
the test only said Launch stopped before the active tests finished.

This moves the allocation into 1-100 and 215-231, the domains whose whole slice sits outside
that range. Domain 0 stays free for the developer shell. Domain 232 is dropped because its
slice runs past 65535.

That leaves 117 domains, fewer than the number of tests that need one, so a package pool now
wraps around and two of its tests can share a domain. medkit_set_test_domain also puts a
medkit_dds_domain_<id> RESOURCE_LOCK on the test, and CTest never runs two tests holding one
lock at the same time. The lock is not decoration: scripts/test.sh runs ctest -j $(nproc),
so tests inside one package do run concurrently.

The pools also moved into a single table in ROS2MedkitTestDomain.cmake. A package now names
itself (medkit_init_test_domains(PACKAGE ros2_medkit_gateway)) instead of repeating a range,
so the numbers cannot drift between the module and a CMakeLists.txt. Two remaining literals
went with it: graph_watchdog no longer hardcodes ROS_DOMAIN_ID=89, and the shared secondary
pool reaches Python through MEDKIT_SECONDARY_DOMAINS instead of a number in constants.py.

The renumbering alone would not stop this from coming back, because nothing ever checked the
allocation against the ephemeral range. Two checks now do:

  • At configure time, the module validates the whole table on every include, not only the
    pool being asked for: every slice outside the ephemeral range, pools pairwise disjoint,
    domain 0 rejected, no slice past 65535. The range is read from
    /proc/sys/net/ipv4/ip_local_port_range and then widened to at least the Linux default, so a
    host with a narrow range cannot accept an allocation that breaks on a stock machine.
  • At test time, test_dds_domain_allocation reads the generated CTest properties back and
    checks them against that machine's live kernel range. This matters because ci.yml builds and
    tests in separate jobs, so the machine that validated the table is not the machine that runs
    the tests. It also catches a hand-written ROS_DOMAIN_ID that never went through the table,
    and a resource lock dropped by a property overwrite.

New allocation:

package pool slots
ros2_medkit_sovd_service_interface 1-2 2
ros2_medkit_param_beacon 3-4 2
ros2_medkit_topic_beacon 5-6 2
ros2_medkit_graph_provider 7-8 2
ros2_medkit_fault_reporter 9-11 3
ros2_medkit_log_bridge 12-14 3
ros2_medkit_action_status_bridge 15-17 3
ros2_medkit_diagnostic_bridge 18-20 3
ros2_medkit_fault_manager 21-32 12
ros2_medkit_opcua 33-44 12
ros2_medkit_graph_watchdog 45-54 10
ros2_medkit_gateway 55-79 25
ros2_medkit_integration_tests 80-100, 215-228 35
secondary pool (shared) 229-231 3

Two things to know about the trade-off:

  • Reuse is weaker than a domain per test against orphans. Before, a node left behind by a killed
    test could never meet a later test. Now a domain comes back after the pool has been walked
    once (25 tests for the gateway, 35 for the integration tests). The lock covers concurrency,
    not a process that outlived its own test.
  • Running out of pool is no longer a configure error, it wraps. That is on purpose: the old
    error forced a re-carve of the whole table to add one test. The cost is that an oversubscribed
    pool serialises tests quietly instead of failing loudly.

Reserving the ports in CI with net.ipv4.ip_local_reserved_ports was the other option in the
issue. It was not taken. Every CI job runs in a job container, which is not privileged, so the
sysctl cannot be set there, and it would not help a developer machine either.


Issue


Type

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

Testing

The failure was reproduced first, on Jazzy with rmw_cyclonedds_cpp and domain 183, whose
discovery port is 53150:

  • port free: node creation succeeds
  • a plain UDP socket holding 53150: ddsi_udp_create_conn: failed to bind to ANY:53150: address in use, then rmw_create_node: failed to create domain - the same text as in the issue

The port offsets a domain really binds were measured, not assumed, with three participants on
domain 97: CycloneDDS uses offsets 0, 1, 10, 11 and Fast-DDS uses 0, 10, 11, which matches the
RTPS scheme. The code treats the whole 250-port slice as owned, which is a superset of that and
needs no assumption about how many participants a test starts.

Each guard was then checked by breaking it on purpose and confirming it fails:

change result
gateway pool moved to 155-179 configure fails: claims domain 155, whose UDP slice 46150-46399 overlaps the kernel ephemeral port range 32768-60999
opcua pool widened to overlap fault_manager configure fails: domain 30 is allocated twice
a package listed twice in the table configure fails: appears 2 times in MEDKIT_DOMAIN_TABLE
a test given a hand-written ROS_DOMAIN_ID=183 test_dds_domain_allocation fails
RESOURCE_LOCK overwritten after medkit_set_test_domain test_dds_domain_allocation fails

The interlock itself was measured too, because the whole reuse scheme rests on it. Six tests, a
pool of two domains, ctest -j 6, each test sleeping one second and recording its start and end
time:

  • with the lock: the three tests on each domain ran one after another, and the two domains ran
    in parallel with each other
  • with the lock removed and the same reuse: all three tests on each domain ran at once

To verify: build the workspace and run colcon test. test_dds_domain_allocation is registered
in every package that uses domains, and prints the pool, the ephemeral range it read, and how
many tests it checked.


Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

@bburda bburda self-assigned this Aug 5, 2026
@bburda
bburda marked this pull request as ready for review August 6, 2026 11:50
Copilot AI lite review requested due to automatic review settings August 6, 2026 11:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adjusts the workspace’s test DDS domain allocation to avoid Linux’s ephemeral UDP port range (reducing “address in use” startup flakiness), centralizes per-package domain pools, and adds both configure-time and runtime validation to keep the scheme correct across build/test machines.

Changes:

  • Centralize per-package DDS domain pools in ROS2MedkitTestDomain.cmake, validate the full table against the kernel ephemeral range, and add a per-package runtime guard (test_dds_domain_allocation).
  • Update package CMakeLists.txt files to request domains by package name and register the allocation guard; fix places where RESOURCE_LOCK properties would otherwise overwrite the per-domain lock.
  • Plumb the shared secondary pool into Python via MEDKIT_SECONDARY_DOMAINS and document the new scheme.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/ros2_medkit_plugins/ros2_medkit_sovd_service_interface/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
src/ros2_medkit_plugins/ros2_medkit_graph_watchdog/CMakeLists.txt Use package pool + guard; avoid overwriting per-domain locks; remove hard-coded ROS_DOMAIN_ID for e2e.
src/ros2_medkit_plugins/ros2_medkit_graph_provider/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
src/ros2_medkit_log_bridge/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
src/ros2_medkit_integration_tests/ros2_medkit_test_utils/constants.py Read shared secondary domains from MEDKIT_SECONDARY_DOMAINS instead of hard-coded IDs.
src/ros2_medkit_integration_tests/CMakeLists.txt Use package pool + guard; assign per-test domains via medkit_set_test_domain; append secondary lock.
src/ros2_medkit_gateway/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
src/ros2_medkit_fault_reporter/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
src/ros2_medkit_fault_manager/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
src/ros2_medkit_discovery_plugins/ros2_medkit_topic_beacon/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
src/ros2_medkit_discovery_plugins/ros2_medkit_param_beacon/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
src/ros2_medkit_diagnostic_bridge/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
src/ros2_medkit_cmake/scripts/check_test_domains.py New runtime validator that inspects generated CTest properties and checks domain safety + locking.
src/ros2_medkit_cmake/README.md Document the new ROS2MedkitTestDomain workflow and constraints.
src/ros2_medkit_cmake/design/index.rst Add design notes for test-domain allocation, reuse, and validation approach.
src/ros2_medkit_cmake/CMakeLists.txt Install the runtime validator script alongside the CMake modules.
src/ros2_medkit_cmake/cmake/ROS2MedkitTestDomain.cmake Implement safe-band table, table validation, pool wrapping, secondary pool export, and allocation guard macro.
src/ros2_medkit_action_status_bridge/CMakeLists.txt Switch to package-based test domain init; add allocation guard test.
Suppressed comments (1)

src/ros2_medkit_cmake/cmake/ROS2MedkitTestDomain.cmake:277

  • The comment says "Do not call add_launch_test directly", but the repo now legitimately calls add_launch_test directly in ros2_medkit_integration_tests and then calls medkit_set_test_domain to append ROS_DOMAIN_ID + lock. The module comment should reflect this supported usage to avoid confusion.
# This is the required way to add a launch test: it makes it impossible to add
# one without domain isolation (a launch test left on the default domain 0 sees
# every other node on the machine).
# Do not call add_launch_test directly.

message(FATAL_ERROR "medkit_reserve_test_domain called before medkit_init_test_domains")
endif()
list(GET _MEDKIT_DOMAIN_POOL ${_MEDKIT_DOMAIN_NEXT} ${DOMAIN_VAR})
set(${LOCK_VAR} "medkit_dds_domain_${${DOMAIN_VAR}}")
Comment on lines +136 to +141
# medkit_set_test_domain wraps around its pool and two tests can end up on the
# same domain. That is safe because the same call also puts the domain's
# RESOURCE_LOCK on the test, and CTest never runs two tests holding one lock at
# the same time - a stale participant from an earlier test cannot leak into a
# later one's graph discovery, because the earlier one has finished.
# Multi-gateway tests take their EXTRA domains from the shared secondary pool,
bburda added 2 commits August 6, 2026 13:59
RTPS gives a DDS domain the UDP slice [7400 + 250*d, 7400 + 250*d + 249], and both
CycloneDDS and Fast-DDS bind inside it without SO_REUSEPORT. The kernel hands out
ephemeral ports from net.ipv4.ip_local_port_range, 32768-60999 by default, which
maps back to domains 102 to 214. Most of the domains we handed to tests sat in that
band. When another process on the machine got one of those ports first, every node
on the domain failed to start with "failed to bind to ANY:<port>: address in use",
and the test reported "Launch stopped before the active tests finished", which says
nothing about the cause.

Re-carve the allocation into 1-100 and 215-231, the domains whose whole slice sits
outside that range. Domain 0 is left for the developer shell, and 232 is dropped
because its slice runs past 65535.

That is 117 domains for more test slots than that, so a package pool now wraps
around and two of its tests can share a domain. medkit_set_test_domain also puts a
medkit_dds_domain_<id> RESOURCE_LOCK on the test, so CTest never runs two of them at
the same time. The lock is load-bearing: scripts/test.sh runs ctest -j $(nproc), so
tests inside one package really do run concurrently.

Move the pools into one table in ROS2MedkitTestDomain.cmake. A package names itself
instead of repeating a range, so the allocation can no longer drift between the
module and a CMakeLists.txt. The last two literals go with it: graph_watchdog no
longer hardcodes ROS_DOMAIN_ID=89 in its launch tests, and the secondary pool
reaches Python through MEDKIT_SECONDARY_DOMAINS instead of a number in
constants.py.

Add two checks so the constraint is enforced instead of documented:

- at configure time the module validates the whole table, not just the pool being
  asked for: every slice outside the ephemeral range, pools pairwise disjoint,
  domain 0 rejected. The range is read from /proc/sys/net/ipv4/ip_local_port_range
  and widened to at least the Linux default, so a host with a narrow range cannot
  accept an allocation that breaks on a stock machine.
- test_dds_domain_allocation reads the generated CTest properties back and checks
  them against the live kernel range on the machine that runs the tests. That is a
  different job from the one that builds them, and it also catches a hand-written
  ROS_DOMAIN_ID and a resource lock dropped by a property overwrite.

Both properties are appended now, so three call sites that set RESOURCE_LOCK after
the domain assignment were changed to set_property(... APPEND ...).
The package registers no linter, so flake8 never saw this file and it
landed with its import groups out of order: a third-party import placed
after requests, and a stdlib import in its own trailing group.

Only the order changes. The import set is identical and the AST outside
the import statements compares equal to the previous version.
@bburda
bburda force-pushed the fix/test-domain-ports branch from 2daf0b8 to 26ff82e Compare August 6, 2026 13:23
The package compiled with 37 warnings under -Wall -Wextra -Wconversion.
No gate fails on them, so they accumulated.

slugify() and short_hash_hex() iterated a std::string as
'for (unsigned char c : s)'. The element type is char, so every iteration
performed an implicit char -> unsigned char conversion and -Wsign-conversion
fired. The unsigned char is required: std::isalnum and std::toupper are
undefined for a negative char. The conversion is now explicit, matching
address_space_browser.cpp in this package.

readAccessLevel() and readUserAccessLevel() already return the underlying
uint8_t of the bitmask, so the static_cast<uint8_t> around them was a
no-op that tripped -Wuseless-cast. Dropping it also means a future widening
of that underlying type surfaces as -Wconversion instead of being cast away
silently.

The two test doubles aggregate-initialised LockAccessResult and LockError
with fewer initialisers than members, leaving denied_code and
existing_lock_id to -Wmissing-field-initializers. Both now list every
member, as the graph_provider, topic_beacon and param_beacon doubles do.
status_code is written as 409, the value it defaulted to.

The concurrency test dropped the nodiscard result of clear_fault(). That is
intentional - it races the shared pending-report buffer and asserts on no
single clear - so the discard is now explicit and says why.

slugify() and short_hash_hex() are on the fault_code path. Both were checked
against their previous versions over every single byte and every ordered
byte pair, 65792 inputs, with no difference in output.
"ros2_medkit_action_status_bridge:15-17"
"ros2_medkit_diagnostic_bridge:18-20"
"ros2_medkit_fault_manager:21-32"
"ros2_medkit_opcua:33-44"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_opcua_secured.test.py:71 still hardcodes ROS_DOMAIN_ID = '229', with a comment calling it the free slot in the old 220-229 range. After this move 229 is the first slot of the shared secondary pool that the multi-gateway tests take, so on a full run the secured test's gateway and a peer gateway can share a domain and see each other's /fault_manager/report_fault. test_dds_domain_allocation won't catch it because the domain is set inside the Python file, not as a CTest ENVIRONMENT entry. Handing it a domain via medkit_reserve_test_domain puts it back in the table.

for entry in env_entries:
name, _, value = entry.partition('=')
if name == 'ROS_DOMAIN_ID':
return int(value)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CTest applies ENVIRONMENT entries in order, so the last ROS_DOMAIN_ID wins, but this returns the first. If a test ever ends up with two entries the guard validates a domain the test does not actually run on. for entry in reversed(env_entries) fixes it.

The test launched fault_manager_node with no parameters, so it took the
production defaults: storage_type sqlite at /var/lib/ros2_medkit/faults.db.
Only a root process can create that directory, so outside a container the
node aborted at startup on an uncaught filesystem_error and never advertised
a service. Every other integration test goes through
create_fault_manager_node, whose storage_type defaults to memory for exactly
this reason. This one bypassed it.

The run then failed 249s later at 'alarm did not surface as a CONFIRMED
fault', which names the alarm path. The alarm path was fine - the plugin
logs 'AlarmCondition CONFIRMED: PLC_OVERPRESSURE' on the way through. There
was simply nothing to report to.

The readiness poll now decides the run. It used to fall through when the
service never appeared, leaving the real cause to be inferred from a
downstream deadline. It now fails immediately and prints the fault manager
log. Seeing the service is not sufficient on its own, because the domain is
shared and a name can outlive our process, so the check also requires the
process to still be alive after a settle. Against a deliberately unwritable
database path this reports 'exited with code 250' in 4s instead of the wrong
component in 249s, on three runs out of three.

Teardown also leaked. The nodes start through 'ros2 run', so the direct
child is the CLI wrapper and the node is a grandchild; signalling the
wrapper left the node reparented to init, still holding ROS_DOMAIN_ID 229,
which this file hardcodes. Five of them had accumulated from earlier runs.
Every process now starts in its own session and terminate() signals the
group, so a run leaves nothing behind.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Test DDS domains map to UDP ports inside the kernel ephemeral range

3 participants