コンテンツにスキップ

Architecture

How transport_viz is put together, for people who want to change it. The user-facing behaviour is described in how-it-works.md; this page is about the code.

Components

flowchart LR
    subgraph inputs["Fast DDS / ROS 2"]
        DDS["remote participants<br/>(discovery data)"]
        STATS["statistics topics<br/>(FASTDDS_STATISTICS on the nodes)"]
        GRAPH["ROS graph<br/>(rclcpp node)"]
        DINFO["ros_discovery_info<br/>(node names, read without SHM)"]
        SHM["/dev/shm"]
    end
    subgraph observe["observers (Fast DDS / ROS dependent)"]
        DO["DiscoveryObserver<br/>DomainParticipantListener"]
        SO["StatsObserver<br/>DataReaders on the same participant"]
        RR["RosGraphResolver<br/>GUID → node name"]
        RD["RosDiscoveryInfoObserver<br/>DataReader on the same participant"]
        SI["scan_shm()<br/>statvfs + flock probes"]
    end
    subgraph core["core (pure, unit-tested)"]
        SNAP["Snapshot<br/>endpoints · stats · shm"]
        DEC["decision.cpp<br/>summarize · decide · apply_stats · diff"]
    end
    subgraph render["renderers"]
        RT["render_table"]
        RJ["render_json"]
        RC["render_csv"]
    end
    DDS --> DO --> SNAP
    STATS --> SO --> SNAP
    GRAPH --> RR --> SNAP
    DINFO --> RD --> SNAP
    SHM --> SI --> SNAP
    SNAP --> DEC --> RT & RJ & RC
    RT --> TTY["terminal / --watch"]
    RJ --> CLI["ros2 transport (execs the binary)"]
    RJ --> WEB["transport_viz_web → web/index.html"]
    RC --> CSV["--csv: spreadsheets, pandas"]

Two packages:

  • fastdds_transport_viz (C++): everything above, the transport_viz binary, the verification nodes and transport_viz_web (installed from web/serve.py).
  • ros2transport (Python): the ros2 transport command. It mirrors the binary's options for ros2cli's argument parser and then execvs the binary, so output, colors, --watch terminal handling and exit codes are the binary's. TRANSPORT_VIZ_BINARY overrides the lookup (used by its tests with a fake binary).

Inside the C++ package the sources are split into two libraries:

Library Sources Depends on
fastdds_transport_viz_core model.cpp, decision.cpp, ros_names.cpp, rmw_check.cpp, shm_info.cpp nothing but the C++ standard library and POSIX
fastdds_transport_viz_lib discovery_observer.cpp, ros_discovery_info_observer.cpp, ros_graph_resolver.cpp, stats_observer.cpp, render_table.cpp, render_json.cpp, render_csv.cpp, parse_json.cpp core, rclcpp, Fast DDS, nlohmann_json, the vendored statistics types, the rosidl_typesupport_fastrtps_cpp type support of rmw_dds_common

The split is the point: the decision logic never sees a Fast DDS type, so test_decision builds endpoints by hand and checks verdicts, statistics overlays and diffs without a DDS runtime. model.hpp is the contract between the two halves.

One run, step by step

sequenceDiagram
    participant M as main()
    participant N as rclcpp node<br/>/_transport_viz_<pid>
    participant D as DiscoveryObserver<br/>(own DomainParticipant)
    participant I as RosDiscoveryInfoObserver
    participant S as StatsObserver
    participant C as collect()
    participant R as renderer
    M->>N: rclcpp::init (domain, SUPER_CLIENT if ROS_DISCOVERY_SERVER)
    M->>D: create participant + listener
    M->>I: add a ros_discovery_info reader to D's participant (Humble: to its own participant without SHM)
    opt --stats
        M->>S: add statistics readers to D's participant, start the drain thread
    end
    par drain thread (--stats)
        loop every 50 ms until the observer is destroyed
            S-->>S: drain() the statistics readers
        end
    and main loop
        loop every 50 ms until --quiet, the settle rule (--stats) or the --timeout cap
            D-->>D: on_data_writer/reader_discovery → Endpoint
            M->>I: poll() (node names)
        end
    end
    M->>C: collect()
    C->>D: snapshot() endpoints
    C->>N: RosGraphResolver::refresh(), node_for_guid()
    C->>I: poll(), node_for_guid() for the names the graph does not know
    C->>S: snapshot() StatsData
    C->>C: filter own endpoints, --topic/--all/--node, scan_shm()
    C->>C: summarize() → decide() per pair → apply_stats()
    M->>R: render_table(), render_json() or render_csv()
    opt --watch
        M->>M: repeat collect() every --interval, diff() against the last frame
    end
  1. Startup (main.cpp). Options are parsed (exit 2 on a usage error). The domain comes from --domain or ROS_DOMAIN_ID. With ROS_DISCOVERY_SERVER set and ROS_SUPER_CLIENT unset, the tool sets ROS_SUPER_CLIENT=TRUE for both of its participants, because a plain client only learns about the endpoints it matches.
  2. Two participants. An rclcpp node (/_transport_viz_<pid>) exists only for the graph API, which maps endpoint GUIDs to node names; a second, raw Fast DDS DomainParticipant owned by DiscoveryObserver receives discovery callbacks and, with --stats, hosts the statistics readers. rclcpp's participant cannot be used for both: rmw_fastrtps_cpp owns its listener, and the tool must not register the statistics topics with rmw. Both participants are filtered out of the results by node name and by GUID prefix. RosDiscoveryInfoObserver adds a ros_discovery_info reader to the raw participant that announces no SHM locator: rclcpp's participant does not receive that topic from a same-host node in another IPC namespace, which writes it into its own /dev/shm (#112). On Fast DDS 2.6 (Humble) the reader gets a third participant of its own, created like the raw one without the SHM transport: a participant with SHM receives only the SHM locator of a same-host endpoint there (FTV_SAME_HOST_LOCATORS_FILTERED), so the nodes' writers could not reach the reader.
  3. Observation loop. DiscoveryObserver records every remote writer and reader as an Endpoint (GUID, host id, locators, QoS, data-sharing settings) under a mutex. StatsObserver drains its statistics readers on a thread of its own every 50 ms (kStatsDrainIntervalMs, #141), not in the main loop: the cumulative counters and PHYSICAL_DATA use reliable, transient-local KEEP_LAST 1 readers, because the tool needs the first and the last value of every counter to report a window delta, while HISTORY_LATENCY is a best-effort, volatile reader of depth 100 (kStatsLatencyHistoryDepth), because its samples are counted per pair for the HZ column (#143). Without --stats the loop stops early after --quiet seconds without discovery events. With it quiet is not enough: a one-shot run stops on the settle rule (stats_settled(), not before 5 s: every matched RTPS_SENT writer heard from and the measured instances towards discovered readers unchanged for max(--quiet, 3) s, #168), and --timeout is only the cap; --watch --stats draws its first frame once discovery is quiet and 5 s have passed. A silent window is not proof that discovery finished: on a large system it also happens between two batches of announcements. collect() therefore compares the endpoint gids the live participants announce in ros_discovery_info with the raw DiscoveryObserver::snapshot() (before any view filter) and records the result in Snapshot::discovery (#133); an incomplete view gets one warning line on stderr naming the numbers and a longer --quiet or --timeout. complete stays unset when no live participant announced anything, and a participant that has left is skipped: the ros_discovery_info table never evicts it.
  4. collect() builds the Snapshot: node names from the resolver (or, for the endpoints it cannot name, from the own ros_discovery_info reader), host names and process ids from PHYSICAL_DATA, the tool's own endpoints removed, --all / --topic / --node applied, the shared-memory scan attached, then the core: summarize() groups endpoints by DDS topic and pairs writers with readers, decide() predicts each pair, apply_stats() overlays the measurements.
  5. Rendering is a pure function of the Snapshot: render_table() (plain or ANSI colored, optionally truncated to the terminal width, with watch decorations) and render_json() (pretty or JSON Lines), and render_csv() (one row per pair, the header left out after the first --watch frame).
  6. --watch repeats collect() and rendering every --interval seconds. diff() compares the PairState (transport, confidence, measured transports, the predicted and the measured locators, warnings) of every pair with the previous frame; the table marks additions and changes for three frames and keeps removed pairs as dimmed ghost rows. On a terminal the frame is painted into the alternate screen buffer and single keys toggle options.
  7. transport_viz diff skips steps 1-4: parse_json() (the inverse of render_json()) rebuilds a Snapshot from each saved document, the view filters of a one-shot run are applied to both, and diff_snapshots() matches their pairs by GUID or by node names (KeyMode). The result is rendered like a watch frame (decorations_for() turns the Changes into marks and ghost rows, render_json() emits the changes object with key and before), and the exit status says whether anything changed.

Data model (model.hpp)

classDiagram
    class Snapshot {
        domain, observed_at, observation_seconds
        local_host_id
        vector~Endpoint~ endpoints
        vector~Participant~ participants
        vector~TopicSummary~ topics
        StatsData stats
        ShmInfo shm
        DiscoveryStatus discovery (complete, stopped_on, events, endpoints)
        Changes changes (added, removed, changed, key, before)
    }
    class Endpoint {
        guid, participant_guid_prefix, host_id
        dds_topic, dds_type, ros_topic, ros_type
        node_name, host_name, process
        unicast, multicast locators
        EndpointQos qos
        datasharing_history_bytes, datasharing_segment_visibility
        buffer_parent_guid, buffer_companion_guids
    }
    class Participant {
        guid_prefix, host_id, host_name, own
        shm_ports, shm_visibility
        discovery_protocol, name, vendor
        metatraffic_locators, discovery_server
    }
    class TopicSummary {
        display_topic, display_type
        writers, readers
        vector~Pair~ pairs
        unmatched_reasons
        TopicKind kind
        group, GroupDirection direction
    }
    class Pair {
        writer, reader → Endpoint
        Verdict verdict
        Measurement measured
    }
    class Verdict {
        Transport transport
        Confidence confidence
        reasons, warnings
    }
    class Measurement {
        transports
        packets, bytes in the window
        packets_total, bytes_total
        delivered
        data_submessages
    }
    Snapshot "1" *-- "*" Endpoint
    Snapshot "1" *-- "*" Participant
    Snapshot "1" *-- "*" TopicSummary
    TopicSummary "1" *-- "*" Pair
    Pair --> Endpoint : writer / reader
    Pair *-- Verdict
    Pair *-- Measurement
  • Endpoint is what discovery announces, plus what the resolver and PHYSICAL_DATA add. host_id is the first 4 bytes of the GUID prefix, which is what Fast DDS itself compares to decide "same host".
  • TopicSummary::pairs hold pointers into Snapshot::endpoints, so a copied snapshot must rebuild its topics (the watch loop does this for ghost rows).
  • TopicSummary::kind / group / direction say which service or action a DDS topic belongs to and which way it travels (#84). A service or an action is not a model entity: topics[] keeps one entry per DDS topic and the grouping happens at render time in display_rows(), so the diff's node-keyed identities (built from per-TopicSummary endpoint ordinals) are untouched.
  • Verdict::reasons and warnings are machine-readable codes. Every code has an English description (explain()) and a remedy or an explicit none (remedy()) in one table in decision.cpp, which --explain / --advise, --list-codes, ros2 transport codes, the JSON reason_code_descriptions / reason_code_remedies objects and the web viewer all use. StatsData keeps the raw statistics (per-locator traffic samples with first and last cumulative values, delivery proofs, DATA_COUNT per writer, host info per participant) and what never arrived (samples_lost, of which samples_lost_latency is the best-effort HISTORY_LATENCY part that raises no warning, samples_lost_at_start, samples_rejected and a document-level warnings list carrying stats-samples-lost, #134 and #141, and pairs_delivered / pairs_delivered_unmeasured, the lost measurements that warning is about, #147); ShmInfo is the shared-memory scan.

The decision

decide(writer, reader) in decision.cpp is a fixed sequence of rules on two endpoints and produces one Verdict; how-it-works.md lists the rules in user terms. Roughly: no common transport kind → NONE; different host ids → the common network locator kind (UDPv4, UDPv6, TCP) with SHM locators ignored across hosts; same host → data-sharing if both announce it for intersecting domain ids (likely, because discovery cannot prove zero-copy), else SHM if both announce SHM locators, else the common network kind. A data-sharing or SHM pair is NONE with shm-ipc-namespace-split when what collect() puts on the endpoints shows the two participants in different IPC namespaces: the SHM ports and their visibility (participant_shm_ports / participant_shm_visibility) or, for data-sharing, whether the writer's history and the reader's notification segment are in the tool's /dev/shm (datasharing_segment_visibility). Warnings cover the suspicious combinations (same host id but no common IP address, SHM announced by only one side, ...).

apply_stats(topics, stats) overlays the measurements: it matches RTPS_SENT traffic from the writer's participant to the reader's locators, reports the locator kinds that carried packets, upgrades likely to certain when the measurement agrees, flags measured-transport-mismatch when it does not, and decides the data-sharing questions from HISTORY_LATENCY and DATA_COUNT. qos-incompatible and shm-ipc-namespace-split pairs stay NONE; only a proven delivery adds a warning. It is pure too: test_decision feeds it hand-made StatsData.

Fast DDS 2.14 and 3.x

Jazzy ships Fast DDS 2.14 (fastrtps CMake package, fastrtps namespace), Lyrical and Rolling ship 3.x (fastdds package, different discovery callback signatures and info types). fastdds_compat.hpp hides the difference: it detects the version with __has_include(<fastdds/config.hpp>), defines FTV_FASTDDS_3, the ftv_rtps namespace alias, retcode_ok() and disc_* accessors for the discovery info fields; the observer selects the callback overrides with #if FTV_FASTDDS_3. CMakeLists.txt finds fastdds first and falls back to fastrtps, and package.xml uses condition="$ROS_DISTRO == humble or $ROS_DISTRO == jazzy" for the fastrtps dependency (and the negation for fastdds).

The statistics topics need their generated type support, which the ROS distributions do not ship as headers. third_party/fastdds_statistics_types/ (2.14.6), .../fastdds_statistics_types_v26/ (2.6.12, fastcdr 1.0 serialization) and .../fastdds_statistics_types_v3/ (3.2.4) are vendored copies (Apache-2.0); CMake picks one by version and builds it into fastdds_transport_viz_stats_types. fastdds_compat.hpp also defines FTV_HAS_STATISTICS (false for Humble's binary, built without the module) and FTV_SAME_HOST_LOCATORS_FILTERED (Fast DDS < 2.10 hides the network locators of same-host peers), which main.cpp and the observer use.

Web viewer and live mode

web/index.html + web/app.js (plain JavaScript, vendored d3) render a --json document: buildModel() in web/model.js turns it into nodes, hosts and bundled edges, filters apply --node/--topic semantics client-side, and the panel shows the reason codes with the descriptions carried in the document. diffDocuments() in model.js is a port of diff_snapshots() (same changes object, tested against the binary's output on the shared fixtures) for comparing two documents in the browser. model.js and scene.js (the scene of the current filters, the layout, the edge curves and label midpoints) hold every function without DOM or d3 dependencies and are unit-tested under Node (web/test/). schema/transport_viz.schema.json is the contract; test_json_schema validates the shipped samples and test_json_schema_live.py the live output against it. The tool may add keys freely; a breaking change bumps schema_version.

web/serve.py (transport_viz_web) runs transport_viz --watch --json as a subprocess, keeps the latest document, serves the static files, /latest.json and a Server-Sent Events stream at /events; the page reconnects to it in ?live=1 mode. /metrics turns the latest document into the Prometheus text format when it is scraped. --record FILE also writes every document to FILE as JSON Lines, which the viewer replays with a timeline (web/replay.js, #82). Standard library only, so it installs with the package.

Repository layout

src/ros2transport/
  ros2transport/api/               binary lookup, option mirroring, exec
  ros2transport/command/, verb/    ros2cli entry points (transport; list, codes, diff)
  test/                            pytest with a fake binary (test_cli.py), flake8/pep257,
                                   live launch test (test_list_live.py)
src/fastdds_transport_viz/
  include/fastdds_transport_viz/   model.hpp, decision.hpp, discovery_observer.hpp,
                                   ros_discovery_info_observer.hpp, ros_graph_resolver.hpp,
                                   stats_observer.hpp, shm_info.hpp, render.hpp,
                                   parse_json.hpp (--json document -> Snapshot, for diff),
                                   rmw_check.hpp (accept rmw_fastrtps_cpp / _dynamic_cpp only),
                                   ros_names.hpp, fastdds_compat.hpp, fastdds_util.hpp
  src/                             implementation + main.cpp (also the `transport_viz diff`
                                   subcommand: parse_json.cpp, diff_snapshots() in decision.cpp)
  src/test_nodes/                  verification nodes (bounded_pub/sub, unbounded_pub/sub, large_array_pub/sub,
                                   scale_load, rate_load)
  config/                          statistics.xml.in, datasharing_auto.xml, datasharing_auto_stats.xml.in,
                                   multicast_user_stats.xml.in (multicast user endpoints + the
                                   statistics writers, for the #130 rig),
                                   unicast_discovery.xml (the .in templates are installed without the
                                   suffix, generated for the Fast DDS of the build; the statistics
                                   writer profiles live once in statistics_writers.xml.in, pasted
                                   into every template as FTV_STATS_WRITER_PROFILES)
  third_party/fastdds_statistics_types/     vendored generated statistics types (Fast DDS 2.14)
  third_party/fastdds_statistics_types_v26/ same for Fast DDS 2.6 (Humble, fastcdr 1.0)
  third_party/fastdds_statistics_types_v3/  same for Fast DDS 3.x
  test/                            gtest (decision, render, render_json, render_csv, shm_info,
                                   rmw_check, observers with real participants), pytest (CLI
                                   arguments and diff, json schema, installed statistics
                                   profiles, web serve, scale_measure, multicast stamping
                                   report, launch helpers), fixtures/ (JSON documents for the
                                   schema and diff tests), launch/ (launch tests, _common.py,
                                   large_shm_stats.xml.in -> share/<pkg>/test/large_shm_stats.xml
                                   when BUILD_TESTING)
web/                               static viewer (index.html, app.js, model.js, scene.js,
                                   replay.js (recording timeline, #82), style.css, vendor/d3),
                                   serve.py (transport_viz_web; --record FILE tees the JSON Lines
                                   for replay), sample/, test/ (Node unit tests of model, scene
                                   and replay; browser.test.js / live.test.js drive headless
                                   Chrome over CDP, cdp.js)
schema/                            JSON Schema for --json output
scripts/                           integration_test.sh (Docker scenarios), render_examples.sh +
                                   ansi2svg.py (colored output examples), coverage.sh (gcovr line
                                   coverage), scale_test.sh + scale_measure.py + scale_viewer.js
                                   (scale verification, #74), multicast_stamping_test.sh +
                                   multicast_stamping_report.py + whitelist_profile.py (the #130
                                   multicast stamping rig), build_docs.py (this site, #243)
docker/, compose.yaml              development / verification containers
docs/, zensical.toml               this site (English source, *.ja.md translations)

Extension points

  • A new reason or warning code: push it in decision.cpp where the situation is detected, add its description and its remedy (or std::nullopt when there is nothing to change) to the explanations() table (explain() returns "(no description)" otherwise), and cover it in test_decision.cpp. Nothing else needs to change: renderers, JSON, --explain / --advise, the CLI and the web viewer read the table.
  • A new statistics topic: add a Reader to StatsObserver (create_reader reuses a topic Fast DDS already created on the tool's participant: with FASTDDS_STATISTICS set in the tool's own environment, Fast DDS creates the statistics topics itself and a second create_topic() would fail), drain it in drain() into a new StatsData field, consume the field in apply_stats(), extend required_env_value() and the config/statistics_writers.xml.in profile (one data_writer profile per topic alias lifts the 10-instance limit), and document the topic in statistics.md.
  • A new output format: a function std::string render_x(const Snapshot &, const RenderOptions &) next to the three existing renderers (table, JSON and CSV), plus an option in main.cpp and in ros2transport/api/__init__.py (the option list is mirrored there by hand; its pytest runs the command against a fake binary that records the arguments).
  • A new table column: render_table.cpp builds header and rows as vectors of cells; add the cell in the topic row, the pair row and the ghost row, and mirror it in web/app.js (COLUMNS) if the web table should show it too.
  • Another verification scenario: a launch test in test/launch/ (helpers in _common.py) for one-host cases, or a service in compose.yaml plus an assert branch in scripts/integration_test.sh for multi-container ones; record the outcome in the verification table of verification-log.md.