rosotacom-dev 2.5.dev33__py3-none-any.whl → 2.5.dev36__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rosotacom/cli.py CHANGED
@@ -1083,10 +1083,30 @@ def _mark_interactive_smoke_stopped(instance_dir: Path, target_type: str, target
1083
1083
 
1084
1084
 
1085
1085
  def _write_docker_log(container_name: str, instance: SessionInstance, identity: str) -> None:
1086
+ """Snapshot the peer container's own stdout into ``logs/<peer>/docker.log``.
1087
+
1088
+ In `attach` mode the container is `--rm` and already gone by the time this
1089
+ runs, so `docker logs` answers "No such container". That answer used to be
1090
+ written verbatim, which produced a 75-byte file that reads like the record
1091
+ of the run and says nothing -- it was the whole of `logs/<peer>/` besides
1092
+ `launcher.log` when a field session had to be diagnosed after the fact
1093
+ (#266). The catmux per-pane logs under `catmux/` are the actual record, so
1094
+ say that instead of pretending this file is one.
1095
+ """
1086
1096
  try:
1087
1097
  result = subprocess.run(["docker", "logs", container_name], text=True, capture_output=True, check=False)
1088
1098
  except Exception:
1089
1099
  return
1100
+ if result.returncode != 0:
1101
+ stderr = (result.stderr or "").strip()
1102
+ _append_log(
1103
+ _peer_docker_log(instance, identity),
1104
+ f"[rosotacom] docker logs {container_name}: {stderr or 'unavailable'}\n"
1105
+ "[rosotacom] The container is not (or no longer) present -- in attach mode it is\n"
1106
+ "[rosotacom] `--rm` and exits with the foreground command. What each pane inside\n"
1107
+ f"[rosotacom] it printed is under {_peer_docker_log(instance, identity).parent / 'catmux'}.",
1108
+ )
1109
+ return
1090
1110
  logs = (result.stdout or "") + (result.stderr or "")
1091
1111
  if logs:
1092
1112
  _append_log(_peer_docker_log(instance, identity), logs)
@@ -11,7 +11,6 @@
11
11
  "run_args": [
12
12
  "-e", "ROS_DOMAIN_ID=48",
13
13
  "-e", "BUILD_ROS2WS=1",
14
- "-e", "CHECK_ROS2WS_DEPENDENCIES=1",
15
14
  "--network", "host"
16
15
  ],
17
16
 
@@ -11,7 +11,6 @@
11
11
  "run_args": [
12
12
  "-e", "ROS_DOMAIN_ID=47",
13
13
  "-e", "BUILD_ROS2WS=1",
14
- "-e", "CHECK_ROS2WS_DEPENDENCIES=1",
15
14
  "--network", "host"
16
15
  ],
17
16
 
@@ -0,0 +1,37 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!--
3
+ Local-domain CycloneDDS configuration: default discovery, more participants.
4
+
5
+ Set it as `shared.rmw.local.cyclone.config` (or a peer's `local_config_template`)
6
+ on a machine where many processes share the communication peer's local domain.
7
+
8
+ Why: Cyclone gives each process one participant index per domain, and its default
9
+ MaxAutoParticipantIndex is 32 - 33 slots per domain per host. A control centre
10
+ spends them faster than that sounds. The processes that start last then lose the
11
+ race, and the error names the domain rather than the cause:
12
+
13
+ [46] heartbeat_: Failed to find a free participant index for domain 46
14
+ [ERROR] [rmw_cyclonedds_cpp]: rmw_create_node: failed to create domain, error Error
15
+
16
+ On 2026-08-13 that killed the centre's `heartbeat_echo` and one `topic_monitor`
17
+ two seconds after start. catmux left both panes at a shell prompt, so the session
18
+ ran for two hours with no heartbeat publisher: no RTT, no clock-offset estimate,
19
+ and every EchoHeartbeat the far side received carried `echo_valid=false`.
20
+
21
+ 119, not higher: Cyclone's unicast discovery ports are
22
+ 7400 + 250*domain + 10 + 2*index, so 119 is the last index whose ports stay
23
+ inside this domain's 250-port block. Index 120 would answer on the next
24
+ domain's ports.
25
+
26
+ Nothing else is set. This is the *local* domain -- intra-host discovery must keep
27
+ working, so unlike the OTA templates it pins no interface, disables no multicast,
28
+ and sets no peer list.
29
+ -->
30
+ <CycloneDDS xmlns="https://cdds.io/config">
31
+ <Domain Id="any">
32
+ <Discovery>
33
+ <ParticipantIndex>auto</ParticipantIndex>
34
+ <MaxAutoParticipantIndex>119</MaxAutoParticipantIndex>
35
+ </Discovery>
36
+ </Domain>
37
+ </CycloneDDS>
@@ -78,6 +78,19 @@ def parse_ip_addr_for_ip(text: str, ip: str) -> Optional[str]:
78
78
  return None
79
79
 
80
80
 
81
+ #: Interface names that carry intra-host traffic only. Sampling one of these and
82
+ #: calling the result "link bandwidth" is the failure of issue #267: the number
83
+ #: is real, it is just not the link. On a machine that also runs a `-a` recorder
84
+ #: it reads ~28 Gbit/s next to a 6 Mbit/s OTA link.
85
+ LOOPBACK_INTERFACES = frozenset({"lo", "lo0"})
86
+
87
+
88
+ def is_loopback_address(ip: str) -> bool:
89
+ """Is ``ip`` a loopback address (so a loopback interface would be correct)?"""
90
+ ip = (ip or "").strip()
91
+ return ip.startswith("127.") or ip in {"::1", "0:0:0:0:0:0:0:1"}
92
+
93
+
81
94
  def find_interface_for_ip(ip: str) -> Optional[str]:
82
95
  """The interface owning ``ip``, via `ip -o addr show` (None if not found)."""
83
96
  if not ip:
@@ -91,6 +104,40 @@ def find_interface_for_ip(ip: str) -> Optional[str]:
91
104
  return parse_ip_addr_for_ip(out, ip)
92
105
 
93
106
 
107
+ def resolve_link_interface(
108
+ explicit: str,
109
+ host_ip: str,
110
+ finder=find_interface_for_ip,
111
+ ) -> Tuple[str, str]:
112
+ """The OTA link interface and where it came from, or raise.
113
+
114
+ ``explicit`` wins when given; otherwise the interface is the one owning
115
+ ``host_ip`` (the peer's own OTA address). A loopback interface for a
116
+ non-loopback address is refused rather than reported: loopback carries this
117
+ host's own ROS traffic, which on a machine running an ``-a`` recorder is
118
+ three orders of magnitude above the link and looks like a working
119
+ measurement. That mistake is issue #267; it survived a field session and an
120
+ offline analysis before anyone doubted the number.
121
+ """
122
+ interface = (explicit or "").strip()
123
+ provenance = "given explicitly"
124
+ if not interface:
125
+ interface = (finder(host_ip) or "").strip()
126
+ provenance = f"resolved from {host_ip}"
127
+ if not interface:
128
+ raise ValueError(
129
+ f"no interface owns the OTA address '{host_ip}'; "
130
+ "pass ip_local (preferred) or interface"
131
+ )
132
+ if interface in LOOPBACK_INTERFACES and not is_loopback_address(host_ip):
133
+ raise ValueError(
134
+ f"refusing to report loopback interface '{interface}' as the link for OTA "
135
+ f"address '{host_ip}' ({provenance}). Loopback carries this host's own ROS "
136
+ "traffic, not the link."
137
+ )
138
+ return interface, provenance
139
+
140
+
94
141
  def read_iface_counters(iface: str, proc_path: str = PROC_NET_DEV) -> Optional[Tuple[int, int]]:
95
142
  """Return (rx_bytes, tx_bytes) for ``iface`` now, or None if unavailable."""
96
143
  row = read_iface_counter_row(iface, proc_path)
@@ -67,9 +67,10 @@ from rclpy.qos import DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPo
67
67
  from rclpy.serialization import serialize_message
68
68
  from rosidl_runtime_py.utilities import get_message
69
69
 
70
- from com_py.link_bytes import LinkByteSampler, find_interface_for_ip
70
+ from com_py.link_bytes import LinkByteSampler, resolve_link_interface
71
71
  from com_py.link_trace import LinkTraceRecorder
72
72
  from com_py.status_overview_core import (
73
+ STARTUP_GRACE_S,
73
74
  ClockOffsetEstimator,
74
75
  StageObservation,
75
76
  StatusAggregator,
@@ -334,6 +335,8 @@ class StatusOverview(Node):
334
335
  self.declare_parameter("link_trace_interval_s", 1.0)
335
336
  self.declare_parameter("link_trace_modem_command", "")
336
337
  self.declare_parameter("link_trace_modem_timeout_s", 2.0)
338
+ # Grace before the one-shot startup verdict (#266). 0 disables it.
339
+ self.declare_parameter("startup_grace_s", STARTUP_GRACE_S)
337
340
 
338
341
  spec_file = str(self.get_parameter("status_spec_file").value or "").strip()
339
342
  output_dir = str(self.get_parameter("output_dir").value or "").strip()
@@ -410,20 +413,23 @@ class StatusOverview(Node):
410
413
 
411
414
  ota_interface = str(self.get_parameter("ota_interface").value or "").strip()
412
415
  ota_local_ip = str(self.get_parameter("ota_local_ip").value or "").strip()
413
- if not ota_interface and ota_local_ip:
414
- ota_interface = find_interface_for_ip(ota_local_ip) or ""
415
- if not ota_interface:
416
+ link_sampler = None
417
+ if ota_interface or ota_local_ip:
418
+ # Same resolution and the same loopback refusal as topic_monitor: a
419
+ # link number nobody can tell apart from loopback traffic is worse
420
+ # than no link number, so this reports nothing rather than that.
421
+ try:
422
+ ota_interface, provenance = resolve_link_interface(ota_interface, ota_local_ip)
423
+ except ValueError as exc:
424
+ self.get_logger().warning(
425
+ f"status_overview: {exc}; link-overhead measurement disabled"
426
+ )
427
+ else:
428
+ link_sampler = LinkByteSampler(ota_interface)
416
429
  self.get_logger().info(
417
- f"status_overview: no interface found for OTA address '{ota_local_ip}'; "
418
- "link-overhead measurement disabled"
430
+ f"status_overview: link-overhead sampling on interface "
431
+ f"'{ota_interface}' ({provenance})"
419
432
  )
420
- link_sampler = None
421
- if ota_interface:
422
- link_sampler = LinkByteSampler(ota_interface)
423
- self.get_logger().info(
424
- f"status_overview: link-overhead sampling on interface '{ota_interface}'"
425
- + (f" (resolved from {ota_local_ip})" if ota_local_ip else "")
426
- )
427
433
 
428
434
  link_trace_recorder = None
429
435
  if bool(self.get_parameter("link_trace").value):
@@ -450,6 +456,7 @@ class StatusOverview(Node):
450
456
  link_sampler=link_sampler,
451
457
  clock_estimator=clock_estimator,
452
458
  link_trace_recorder=link_trace_recorder,
459
+ startup_grace_s=float(self.get_parameter("startup_grace_s").value),
453
460
  )
454
461
 
455
462
  self.create_timer(self.write_interval_s, self._on_write)
@@ -175,6 +175,128 @@ def stamp_delay(raw_delay_s: float, clock_offset_s: Optional[float] = None) -> O
175
175
  return None
176
176
 
177
177
 
178
+ #: How long a peer is given to bring its own publishers up before their absence
179
+ #: is called a defect. Node start, DDS discovery and the first `refresh_interval`
180
+ #: of the observers all happen inside it; 20 s is roughly four times what a
181
+ #: healthy bring-up needs.
182
+ STARTUP_GRACE_S = 20.0
183
+
184
+
185
+ #: Stage producers rosotacom starts itself, and can therefore promise. The
186
+ #: application that feeds a link is the user's, and a session that declares a
187
+ #: topic says nothing about whether its publisher is running right now -- a
188
+ #: smoke run legitimately exercises the peers without the data source.
189
+ ROSOTACOM_STAGE_PRODUCERS = frozenset({"heartbeat_echo", "preprocessing", "relay_out", "bridge_out"})
190
+
191
+
192
+ def outbound_startup_gaps(snapshot: Dict[str, Any]) -> List[Dict[str, Any]]:
193
+ """Where each outbound topic first has no publisher at all, and whose fault it is.
194
+
195
+ Only outbound topics: their stages are produced on this machine. Inbound
196
+ stages depend on the peer and the link, which the per-topic rollup already
197
+ diagnoses.
198
+
199
+ Only the *first* absent stage per topic, because the ones behind it are
200
+ absent for the trivial reason that nothing reached them, and reporting a
201
+ cascade as four findings buries the one that matters.
202
+
203
+ `owner` is what makes the finding actionable, and it takes three values that
204
+ look identical in the graph:
205
+
206
+ * ``rosotacom`` -- a stage this peer starts itself, whose input is arriving.
207
+ It had something to forward and forwarded nothing. This is the defect.
208
+ * ``application`` -- the native stage of a topic the user's application (or a
209
+ replay) publishes. Not this peer's promise.
210
+ * ``upstream`` -- a stage this peer starts whose input has not delivered a
211
+ message yet. Its publisher appears on discovery of the input, so there is
212
+ nothing wrong: `universal_ota_wrapper` has no `…/ota_stamped` publisher
213
+ until something publishes the topic it wraps.
214
+
215
+ This exists because of a failure that produced no error anywhere. On
216
+ 2026-08-13 the centre's `heartbeat_echo` and one `topic_monitor` died two
217
+ seconds after start -- Cyclone had no free participant index left on the
218
+ local domain -- and catmux left both panes sitting at a shell prompt. The
219
+ session ran for two hours with no heartbeat publisher, so the run has no RTT
220
+ and no clock-offset measurement, and the far side reported `status=LOST
221
+ reason=age hz=0.00` about a peer that was otherwise healthy. The heartbeat is
222
+ the one outbound topic rosotacom publishes itself, which is why the
223
+ generator marks its native stage `heartbeat_echo` rather than `application`.
224
+ """
225
+ gaps: List[Dict[str, Any]] = []
226
+ for topic in snapshot.get("topics", []):
227
+ if topic.get("direction") != "outbound":
228
+ continue
229
+ upstream: Optional[Dict[str, Any]] = None
230
+ for stage in topic.get("stages", []):
231
+ if stage.get("state") != ABSENT:
232
+ upstream = stage
233
+ continue
234
+ producer = stage.get("produced_by")
235
+ if producer not in ROSOTACOM_STAGE_PRODUCERS:
236
+ owner = "application"
237
+ reason = "the application that publishes this topic is not running"
238
+ elif upstream is not None and upstream.get("state") not in (FLOWING, STALE):
239
+ owner = "upstream"
240
+ reason = (
241
+ f"nothing has arrived on {upstream.get('topic')} "
242
+ f"(stage {upstream.get('stage')}), so there is nothing to publish yet"
243
+ )
244
+ else:
245
+ owner = "rosotacom"
246
+ reason = f"{producer} did not come up"
247
+ gaps.append(
248
+ {
249
+ "base": topic.get("base"),
250
+ "stage": stage.get("stage"),
251
+ "topic": stage.get("topic"),
252
+ "domain": stage.get("domain"),
253
+ "produced_by": producer,
254
+ "owner": owner,
255
+ "reason": reason,
256
+ }
257
+ )
258
+ break
259
+ return gaps
260
+
261
+
262
+ #: How far back a sequence number may jump and still be called reordering.
263
+ #: `universal_ota_wrapper` counts per topic from zero and increments by one, so
264
+ #: a receiver sees a backward jump only when the wire reordered a message or
265
+ #: when the publishing peer restarted. Reordering is bounded by the transport's
266
+ #: history depth (single digits for the QoS this stack uses); a restart lands
267
+ #: thousands of sequence numbers back. 64 sits between the two by two orders of
268
+ #: magnitude on both sides.
269
+ SEQUENCE_REORDER_TOLERANCE = 64
270
+
271
+
272
+ def is_sequence_epoch_reset(
273
+ seq: int,
274
+ expected_next_seq: Optional[int],
275
+ tolerance: int = SEQUENCE_REORDER_TOLERANCE,
276
+ ) -> bool:
277
+ """Does ``seq`` begin a new sequence epoch (the peer's wrapper restarted)?
278
+
279
+ Zero is unambiguous and was the only case handled before. It is also the
280
+ case that almost never arrives: DDS discovery takes long enough that the
281
+ first tens of messages of a fresh publisher are written before the reader
282
+ has matched. In the 2026-08-13 field instance the peer restarted four times
283
+ and the first arrival after each restart carried seq 37, 39, 39 and 41 --
284
+ never 0. Every one of them was therefore counted as `reordered`, and
285
+ because the baseline was never reset, so was every message after it: 26,453
286
+ of 55,437 transits on one 10 Hz topic, for the rest of the instance.
287
+
288
+ So a large backward jump counts as a restart too. It is corroborated by an
289
+ arrival gap (105 s, 32 s, 44 s and 195 s in that instance), but the gap is
290
+ not required: a fast restart is still a restart, and the jump alone already
291
+ separates the two causes by orders of magnitude.
292
+ """
293
+ if expected_next_seq is None or seq >= expected_next_seq:
294
+ return False
295
+ if seq == 0:
296
+ return True
297
+ return (expected_next_seq - seq) > tolerance
298
+
299
+
178
300
  # --- Link overhead (session-level) -----------------------------------------
179
301
 
180
302
  def _payload_kbps(stage: Optional[Dict[str, Any]]) -> float:
@@ -258,6 +380,8 @@ class StageObservation:
258
380
  expected_next_seq: Optional[int] = None
259
381
  last_seq: Optional[int] = None
260
382
  tracks_sequence: bool = False
383
+ epoch: int = 0
384
+ epoch_resets: int = 0
261
385
  total_expected: int = 0
262
386
  total_missing: int = 0
263
387
  total_reordered: int = 0
@@ -302,11 +426,15 @@ class StageObservation:
302
426
  if self.expected_next_seq is None:
303
427
  expected_delta = 1
304
428
  self.expected_next_seq = seq + 1
305
- elif seq == 0 and self.expected_next_seq > 1:
306
- # A publisher restart begins a new sequence epoch. Without
307
- # an explicit boot id, zero is the only unambiguous reset.
429
+ elif is_sequence_epoch_reset(seq, self.expected_next_seq):
430
+ # A publisher restart begins a new sequence epoch: the
431
+ # wrapper counts from zero again. See
432
+ # `is_sequence_epoch_reset` for why the first arrival after
433
+ # a restart is almost never seq 0, and what that cost.
434
+ self.epoch += 1
435
+ self.epoch_resets += 1
308
436
  expected_delta = 1
309
- self.expected_next_seq = 1
437
+ self.expected_next_seq = seq + 1
310
438
  elif seq >= self.expected_next_seq:
311
439
  missing = seq - self.expected_next_seq
312
440
  missing_start = self.expected_next_seq if missing else None
@@ -331,6 +459,10 @@ class StageObservation:
331
459
  "topic": transit.get("topic"),
332
460
  "direction": transit.get("direction"),
333
461
  "stage": transit.get("stage"),
462
+ # Which run of the peer's wrapper produced this sequence
463
+ # number. Without it the offline join collapses seq 0..N of
464
+ # every epoch after the first onto the first epoch's rows.
465
+ "epoch": self.epoch,
334
466
  }
335
467
  if missing_start is not None:
336
468
  for missing_seq in range(missing_start, int(seq)):
@@ -421,6 +553,8 @@ class StageObservation:
421
553
  total_missing = self.total_missing
422
554
  total_reordered = self.total_reordered
423
555
  total_max_burst = self.max_burst_missing
556
+ epoch = self.epoch
557
+ epoch_resets = self.epoch_resets
424
558
  hz = count / window_s if window_s > 0 else 0.0
425
559
  mean_size = (total_bytes / count) if count > 0 else 0.0
426
560
  age = (now_mono - last_recv_mono) if last_recv_mono is not None else None
@@ -441,6 +575,8 @@ class StageObservation:
441
575
  "loss_total_pct": _pct(total_missing, total_expected) if tracks_sequence else None,
442
576
  "reordered_total": total_reordered if tracks_sequence else None,
443
577
  "max_burst_missing_total": total_max_burst if tracks_sequence else None,
578
+ "epoch": epoch if tracks_sequence else None,
579
+ "epoch_resets": epoch_resets if tracks_sequence else None,
444
580
  }
445
581
 
446
582
  def drain_transit_records(self) -> List[Dict[str, Any]]:
@@ -464,7 +600,9 @@ class StatusAggregator:
464
600
  *, liveness_window_s: float = 3.0, stale_after_s: float = 3.0,
465
601
  delay_good_ms: float = 100.0, delay_bad_ms: float = 200.0,
466
602
  link_sampler: Any = None, clock_estimator: Any = None,
467
- link_trace_recorder: Any = None):
603
+ link_trace_recorder: Any = None,
604
+ startup_grace_s: float = STARTUP_GRACE_S,
605
+ started_mono: Optional[float] = None):
468
606
  self._log = logger
469
607
  self._spec = spec
470
608
  self._output_dir = output_dir
@@ -484,6 +622,21 @@ class StatusAggregator:
484
622
  self._json_path = os.path.join(self._output_dir, "status.json")
485
623
  self._txt_path = os.path.join(self._output_dir, "status.txt")
486
624
  self._events_path = os.path.join(self._output_dir, "events.jsonl")
625
+ self._startup_path = os.path.join(self._output_dir, "startup_check.json")
626
+ # `pane_failures.log` is written by catmux_log_setup.sh one level up, in
627
+ # logs/<peer>/. The verdict quotes it because a pane that exited is the
628
+ # most common reason a promised publisher never appears, and the two
629
+ # facts are useless apart.
630
+ self._pane_failure_path = os.path.join(
631
+ os.path.dirname(os.path.abspath(self._output_dir)), "pane_failures.log"
632
+ )
633
+ self._startup_grace_s = startup_grace_s
634
+ self._started_mono = time.monotonic() if started_mono is None else started_mono
635
+ self._startup_check: Optional[Dict[str, Any]] = None
636
+ #: (when, topics) of the first evaluation that found a defect. A verdict
637
+ #: is only committed once the same defect survives a second look; see
638
+ #: `_maybe_run_startup_check`.
639
+ self._startup_pending: Optional[Tuple[float, set]] = None
487
640
 
488
641
  # -- observation lookup --
489
642
  def _obs_for(self, stage: Dict[str, Any]) -> Optional[StageObservation]:
@@ -516,6 +669,8 @@ class StatusAggregator:
516
669
  "reordered_total": None,
517
670
  "max_burst_missing": None,
518
671
  "max_burst_missing_total": None,
672
+ "epoch": None,
673
+ "epoch_resets": None,
519
674
  "age_s": None,
520
675
  "last_message_wall": None,
521
676
  "messages_total": 0,
@@ -551,6 +706,8 @@ class StatusAggregator:
551
706
  "reordered_total",
552
707
  "max_burst_missing",
553
708
  "max_burst_missing_total",
709
+ "epoch",
710
+ "epoch_resets",
554
711
  ):
555
712
  result[key] = m[key]
556
713
  if m["last_recv_wall"] is not None:
@@ -589,6 +746,8 @@ class StatusAggregator:
589
746
  "reordered_total",
590
747
  "max_burst_missing",
591
748
  "max_burst_missing_total",
749
+ "epoch",
750
+ "epoch_resets",
592
751
  "age_s",
593
752
  "last_message_wall",
594
753
  "messages_total",
@@ -838,6 +997,7 @@ class StatusAggregator:
838
997
  "summary": counts,
839
998
  "link": compute_link_overview(topics_out, link_sample),
840
999
  "clock_sync": self._clock_snapshot(now_mono),
1000
+ "startup_check": self._startup_check,
841
1001
  "topics": topics_out,
842
1002
  }
843
1003
 
@@ -915,6 +1075,20 @@ class StatusAggregator:
915
1075
  f"summary: OK={s.get('OK', 0)} PARTIAL={s.get('PARTIAL', 0)} "
916
1076
  f"STALLED={s.get('STALLED', 0)} ABSENT={s.get('ABSENT', 0)} (Phase 1: local observation)"
917
1077
  )
1078
+ # The STAT watcher pane shows this file, so a failed startup check has to
1079
+ # be readable here or it is not surfaced where anybody looks.
1080
+ check = snapshot.get("startup_check")
1081
+ if check and check.get("verdict") != "ok":
1082
+ gaps = check.get("missing_outbound_stages") or []
1083
+ panes = check.get("pane_failures") or []
1084
+ lines.append(
1085
+ f"STARTUP CHECK FAILED after {check.get('checked_after_s')}s: "
1086
+ f"{len(gaps)} outbound stage(s) without a publisher, {len(panes)} pane(s) exited"
1087
+ )
1088
+ for gap in gaps:
1089
+ lines.append(f" no publisher: {gap.get('topic')} ({gap.get('produced_by')})")
1090
+ for line in panes:
1091
+ lines.append(f" pane exited: {line}")
918
1092
  lines.append("")
919
1093
  for t in snapshot["topics"]:
920
1094
  arrow = "->" if t["direction"] == "outbound" else "<-"
@@ -962,6 +1136,101 @@ class StatusAggregator:
962
1136
  fp.write(text)
963
1137
  os.replace(tmp, path)
964
1138
 
1139
+ def _read_pane_failures(self) -> List[str]:
1140
+ try:
1141
+ with open(self._pane_failure_path, "r", encoding="utf-8", errors="replace") as fp:
1142
+ return [line.rstrip("\n") for line in fp if line.strip()]
1143
+ except OSError:
1144
+ return []
1145
+
1146
+ def _maybe_run_startup_check(
1147
+ self, snapshot: Dict[str, Any], now_mono: Optional[float]
1148
+ ) -> None:
1149
+ """Once, after the grace period: did this peer bring up what it promised?
1150
+
1151
+ Written as a verdict at a defined moment rather than left implicit in a
1152
+ table that is refreshed every two seconds. `status.json` already carried
1153
+ the evidence on 2026-08-13 -- 13 stages STALLED, `clock_sync: null` --
1154
+ and nobody read it, because nothing said "this is wrong now".
1155
+ """
1156
+ if self._startup_check is not None or self._startup_grace_s <= 0.0:
1157
+ return
1158
+ now = time.monotonic() if now_mono is None else now_mono
1159
+ if now - self._started_mono < self._startup_grace_s:
1160
+ return
1161
+
1162
+ gaps = outbound_startup_gaps(snapshot)
1163
+ mine = [gap for gap in gaps if gap["owner"] == "rosotacom"]
1164
+ theirs = [gap for gap in gaps if gap["owner"] != "rosotacom"]
1165
+ pane_failures = self._read_pane_failures()
1166
+
1167
+ # A stage can be late rather than dead. `relay_out` and the wrapper
1168
+ # create their publisher on *discovering* their input, on a refresh
1169
+ # interval, so an input that started publishing shortly before this
1170
+ # moment leaves a gap that is about to close on its own. Waiting a
1171
+ # second grace period and reporting only what is still missing
1172
+ # separates the two without weakening either: a node that exited never
1173
+ # recovers. CI found this the way it should be found -- the smoke that
1174
+ # starts its payload publisher last failed on a topic whose relay was
1175
+ # working seconds later.
1176
+ if mine and self._startup_pending is None:
1177
+ self._startup_pending = (now, {gap["topic"] for gap in mine})
1178
+ return
1179
+ if mine and self._startup_pending is not None:
1180
+ first_seen_at, first_seen = self._startup_pending
1181
+ if now - first_seen_at < self._startup_grace_s:
1182
+ return
1183
+ mine = [gap for gap in mine if gap["topic"] in first_seen]
1184
+ verdict = "ok" if not mine and not pane_failures else "incomplete"
1185
+ self._startup_check = {
1186
+ "verdict": verdict,
1187
+ "checked_after_s": round(now - self._started_mono, 1),
1188
+ "generated_at": snapshot.get("generated_at"),
1189
+ "peer": snapshot.get("peer"),
1190
+ "missing_outbound_stages": mine,
1191
+ "waiting_for_input": theirs,
1192
+ "pane_failures": pane_failures,
1193
+ }
1194
+ snapshot["startup_check"] = self._startup_check
1195
+ try:
1196
+ self._atomic_write(
1197
+ self._startup_path, json.dumps(self._startup_check, indent=2) + "\n"
1198
+ )
1199
+ except Exception as exc: # pragma: no cover - defensive
1200
+ if self._log is not None:
1201
+ self._log.warning(f"status_overview: failed to write startup check: {exc}")
1202
+ if self._log is None:
1203
+ return
1204
+ if theirs:
1205
+ # Not a defect and deliberately not an error: whether the data source
1206
+ # is publishing is the application's business, not this peer's, and a
1207
+ # stage with no input has nothing to publish.
1208
+ self._log.info(
1209
+ f"status_overview: startup check -- {len(theirs)} outbound topic(s) are "
1210
+ "waiting for input: "
1211
+ + ", ".join(gap["topic"] for gap in theirs[:8])
1212
+ + (" ..." if len(theirs) > 8 else "")
1213
+ )
1214
+ if verdict == "ok":
1215
+ self._log.info(
1216
+ "status_overview: startup check OK -- every stage this peer produces "
1217
+ f"for {snapshot.get('peer')} has a publisher"
1218
+ )
1219
+ return
1220
+ self._log.error(
1221
+ f"status_overview: STARTUP CHECK FAILED for peer {snapshot.get('peer')} "
1222
+ f"after {self._startup_check['checked_after_s']}s -- "
1223
+ f"{len(mine)} stage(s) this peer produces have no publisher, "
1224
+ f"{len(pane_failures)} pane(s) exited. See {self._startup_path}"
1225
+ )
1226
+ for gap in mine:
1227
+ self._log.error(
1228
+ f"status_overview: no publisher on {gap['topic']} "
1229
+ f"(stage {gap['stage']}, produced_by {gap['produced_by']})"
1230
+ )
1231
+ for line in pane_failures:
1232
+ self._log.error(f"status_overview: pane exited: {line}")
1233
+
965
1234
  def write(self, now_mono: Optional[float] = None) -> int:
966
1235
  """Build a snapshot, write artifacts, and return number of transition events."""
967
1236
  link_sample = None
@@ -972,6 +1241,7 @@ class StatusAggregator:
972
1241
  if self._log is not None:
973
1242
  self._log.warning(f"status_overview: link sampling failed: {exc}")
974
1243
  snapshot = self.build_snapshot(now_mono, link_sample=link_sample)
1244
+ self._maybe_run_startup_check(snapshot, now_mono)
975
1245
  if self._link_trace_recorder is not None:
976
1246
  try:
977
1247
  self._link_trace_recorder.maybe_write(snapshot, link_sample)
@@ -47,7 +47,7 @@ from rosidl_runtime_py.utilities import get_message
47
47
  from rclpy.serialization import serialize_message
48
48
  from std_msgs.msg import Float64
49
49
 
50
- from com_py.link_bytes import LinkByteSampler
50
+ from com_py.link_bytes import LinkByteSampler, resolve_link_interface
51
51
 
52
52
 
53
53
  def is_internal_transport_topic(topic_name: str) -> bool:
@@ -99,7 +99,9 @@ class TopicMonitor(Node):
99
99
  self.declare_parameter('to_adressant', '') # optional, e.g., 'shuttle_ella' for /to_shuttle_ella
100
100
  self.declare_parameter("ip_local", "")
101
101
  self.declare_parameter("ip_remote", "")
102
- self.declare_parameter("interface", "") # OTA link interface (e.g. the VPN tunnel)
102
+ # Explicit override for the OTA link interface. Normally left empty:
103
+ # it is resolved from ip_local below.
104
+ self.declare_parameter("interface", "")
103
105
  # Accepted for launch compatibility; the /proc/net/dev sampler needs no
104
106
  # capture window, so this tshark-era buffer is no longer used.
105
107
  self.declare_parameter("link_duration_buffer_s", 1.0)
@@ -109,9 +111,9 @@ class TopicMonitor(Node):
109
111
  self.sourcename = self.get_parameter('sourcename').value
110
112
  self.direction = self.get_parameter('direction').value
111
113
  self.to_adressant = self.get_parameter('to_adressant').value
112
- self.host_ip = self.get_parameter("ip_local").value
113
- self.peer_ip = self.get_parameter("ip_remote").value
114
- self.interface = self.get_parameter("interface").value
114
+ self.host_ip = str(self.get_parameter("ip_local").value or "").strip()
115
+ self.peer_ip = str(self.get_parameter("ip_remote").value or "").strip()
116
+ self.interface = str(self.get_parameter("interface").value or "")
115
117
 
116
118
  # Track actual elapsed time between prints (avoid timer drift issues)
117
119
  self._last_print_t = time.monotonic()
@@ -131,8 +133,22 @@ class TopicMonitor(Node):
131
133
  else:
132
134
  self.topic_prefix = f"/com/{self.direction}/{self.sourcename}/"
133
135
 
134
- if not self.interface:
135
- raise ValueError("Parameter 'interface' is required for link bandwidth measurement")
136
+ # The OTA link interface. `ip_local` is the peer's own OTA address, so
137
+ # the interface follows from it -- the same resolution status_overview
138
+ # does, in the same place, from the same helper. `interface` stays an
139
+ # explicit override for a setup the address cannot describe.
140
+ #
141
+ # It used to be resolved in the session template instead, by a shell
142
+ # line that expanded a catmux parameter with bash's substring-replace
143
+ # syntax. catmux substitutes `${name}` and nothing else, so the pattern
144
+ # stayed literal, bash expanded an unset variable to nothing, and the
145
+ # awk program collapsed to "first address line with a prefix length" --
146
+ # `lo`, on every machine. See docs/link-bandwidth.md.
147
+ try:
148
+ self.interface, provenance = resolve_link_interface(self.interface, self.host_ip)
149
+ except ValueError as exc:
150
+ raise ValueError(f"topic_monitor: {exc}") from exc
151
+ self.get_logger().info(f"OTA link interface '{self.interface}' ({provenance})")
136
152
 
137
153
  # Link bandwidth is measured from the kernel's own interface byte counters
138
154
  # (/proc/net/dev) via the shared link_bytes sampler -- no tshark, no sudo,
@@ -163,8 +179,14 @@ class TopicMonitor(Node):
163
179
  )
164
180
 
165
181
  self.get_logger().info(f"topic_monitor node started. Searching for topics with prefix: {self.topic_prefix}")
166
- self.get_logger().info(f"Publishing ROS topic bandwidth to: {self.ros_topic_bandwidth_topic}")
167
- self.get_logger().info(f"Publishing link bandwidth to: {self.link_bandwidth_topic}")
182
+ self.get_logger().info(
183
+ f"Publishing ROS topic bandwidth to: {self.ros_topic_bandwidth_topic} "
184
+ f"(serialized payload of {self.topic_prefix}*, Kibit/s)"
185
+ )
186
+ self.get_logger().info(
187
+ f"Publishing link bandwidth to: {self.link_bandwidth_topic} "
188
+ f"(wire bytes on '{self.interface}', {'tx' if self.direction == 'out' else 'rx'}, Kibit/s)"
189
+ )
168
190
 
169
191
  # Prime the link sampler so the first print reports a real delta.
170
192
  self._link_sampler.sample()
@@ -62,6 +62,9 @@ parameters:
62
62
  status_write_interval_s: 2.0
63
63
  status_liveness_window_s: 3.0
64
64
  status_stale_after_s: 3.0
65
+ # One-shot verdict N seconds after start: does every outbound stage this peer
66
+ # promises actually have a publisher, and did any pane exit? 0 disables it.
67
+ status_startup_grace_s: 20.0
65
68
  link_trace: false
66
69
  link_trace_interval_s: 1.0
67
70
  link_trace_modem_command: ""
@@ -330,12 +333,16 @@ windows:
330
333
  - name: TM
331
334
  if: topic_monitor
332
335
  splits:
336
+ # No `interface=` line: the node resolves the OTA interface from ip_local
337
+ # itself (com_py.link_bytes.find_interface_for_ip), exactly as the STAT
338
+ # window's status_overview does. The line that used to stand here expanded
339
+ # a catmux parameter with bash substring-replace syntax, which catmux does
340
+ # not substitute -- so it resolved `lo` on every machine and reported this
341
+ # host's own ROS traffic as link bandwidth (#267).
333
342
  - commands:
334
- - interface=$(ip -o addr | awk '/'${ip_local//./\\.}'\/[0-9]+/ {print $2; exit}')
335
- - ros2 run com_py topic_monitor --ros-args -p sourcename:=${remote_name} -p direction:=in -p to_adressant:='${tm_in_to_adressant}' -p ip_local:=${ip_local} -p ip_remote:=${ip_remote} -p interface:=${interface} -p link_duration_buffer_s:=${tm_link_duration_buffer_s}
343
+ - ros2 run com_py topic_monitor --ros-args -p sourcename:=${remote_name} -p direction:=in -p to_adressant:='${tm_in_to_adressant}' -p ip_local:=${ip_local} -p ip_remote:=${ip_remote} -p link_duration_buffer_s:=${tm_link_duration_buffer_s}
336
344
  - commands:
337
- - interface=$(ip -o addr | awk '/'${ip_local//./\\.}'\/[0-9]+/ {print $2; exit}')
338
- - ros2 run com_py topic_monitor --ros-args -p sourcename:=${local_name} -p direction:=out -p to_adressant:='${tm_out_to_adressant}' -p ip_local:=${ip_local} -p ip_remote:=${ip_remote} -p interface:=${interface} -p link_duration_buffer_s:=${tm_link_duration_buffer_s}
345
+ - ros2 run com_py topic_monitor --ros-args -p sourcename:=${local_name} -p direction:=out -p to_adressant:='${tm_out_to_adressant}' -p ip_local:=${ip_local} -p ip_remote:=${ip_remote} -p link_duration_buffer_s:=${tm_link_duration_buffer_s}
339
346
  - name: STAT
340
347
  if: status_overview
341
348
  layout: even-vertical
@@ -352,6 +359,7 @@ windows:
352
359
  -p write_interval_s:=${status_write_interval_s}
353
360
  -p liveness_window_s:=${status_liveness_window_s}
354
361
  -p stale_after_s:=${status_stale_after_s}
362
+ -p startup_grace_s:=${status_startup_grace_s}
355
363
  -p ota_local_ip:=${ip_local}
356
364
  -p link_trace:=${link_trace}
357
365
  -p link_trace_interval_s:=${link_trace_interval_s}
@@ -360,12 +368,15 @@ windows:
360
368
  - commands:
361
369
  - status_out_dir="$(dirname "${ROSOTACOM_CATMUX_LOG_DIR:-/tmp/rosotacom_logs/catmux}")/status"
362
370
  - status_txt="$status_out_dir/status.txt"
363
- - 'while true; do clear; echo "[INFO] watching status overview file: $status_txt"; if [ -f "$status_txt" ]; then cat "$status_txt"; else echo "[INFO] waiting for status_overview to write status.txt"; fi; sleep "${status_write_interval_s:-2.0}"; done'
371
+ - 'while true; do clear; echo "[INFO] watching status overview file: $status_txt"; if [ -f "$status_txt" ]; then cat "$status_txt"; else echo "[INFO] waiting for status_overview to write status.txt"; fi; sleep "${status_write_interval_s}"; done'
364
372
  - name: NET
365
373
  if: network_monitor
366
374
  splits:
367
375
  - commands:
368
- - interface=$(ip -o addr | awk '/'${ip_local//./\\.}'\/[0-9]+/ {print $2; exit}')
376
+ # Exact match on the address column, with the address passed in through
377
+ # awk -v: no shell pattern expansion, so nothing here depends on catmux
378
+ # substituting a form it does not know (#267).
379
+ - interface=$(ip -o -4 addr show | awk -v ip='${ip_local}' '{split($4, a, "/"); if (a[1] == ip) {print $2; exit}}')
369
380
  - sudo iftop -i "$interface" -f "host ${ip_local} and host ${ip_remote}"
370
381
  - name: METRIC_BAG
371
382
  if: metric_stage_bag
@@ -52,6 +52,50 @@ fi
52
52
  tmux pipe-pane -o -t "${TMUX_PANE}" \
53
53
  "${__rosotacom_pipe_filter} >> '${__rosotacom_pane_log_file//\'/\'\\\'\'}'"
54
54
 
55
+ # --- a pane whose command dies must say so -----------------------------------
56
+ #
57
+ # A pane log is only read by somebody who already suspects the pane. On
58
+ # 2026-08-13 the centre's `heartbeat_echo` and one `topic_monitor` exited two
59
+ # seconds after start (Cyclone had no free participant index left on domain 46),
60
+ # catmux left both panes at a shell prompt, and the session ran for two hours
61
+ # with no heartbeat publisher. Nothing anywhere said so: `docker ps` showed a
62
+ # healthy container, the other panes worked, and the far side blamed the link.
63
+ #
64
+ # So every non-zero exit of a pane command is appended to one file per peer,
65
+ # which the status overview's startup check reads and quotes. 130 (Ctrl-C) is
66
+ # excluded: that is an operator stopping a pane on purpose.
67
+ ROSOTACOM_PANE_FAILURE_LOG="$(dirname "${ROSOTACOM_CATMUX_LOG_DIR}")/pane_failures.log"
68
+ ROSOTACOM_PANE_LABEL="${__rosotacom_window_prefix}-${__rosotacom_window_safe}/${__rosotacom_pane}"
69
+ export ROSOTACOM_PANE_FAILURE_LOG ROSOTACOM_PANE_LABEL
70
+
71
+ __rosotacom_note_exit() {
72
+ local __status=$?
73
+ if [ "${__status}" -ne 0 ] && [ "${__status}" -ne 130 ]; then
74
+ local __cmd
75
+ __cmd="$(HISTTIMEFORMAT='' builtin history 1 2>/dev/null | sed 's/^[[:space:]]*[0-9]*[[:space:]]*//')"
76
+ printf '%s\tpane=%s\texit=%s\tcommand=%s\n' \
77
+ "$(date -Iseconds)" "${ROSOTACOM_PANE_LABEL}" "${__status}" "${__cmd}" \
78
+ >> "${ROSOTACOM_PANE_FAILURE_LOG}" 2>/dev/null
79
+ printf '\n[rosotacom] pane %s: command exited %s -- this pane is no longer doing its job.\n' \
80
+ "${ROSOTACOM_PANE_LABEL}" "${__status}" >&2
81
+ printf '[rosotacom] recorded in %s\n\n' "${ROSOTACOM_PANE_FAILURE_LOG}" >&2
82
+ fi
83
+ return "${__status}"
84
+ }
85
+
86
+ case "$-" in
87
+ *i*)
88
+ # Deliberately not exported: a child shell would inherit the hook without
89
+ # the function and complain at every prompt.
90
+ if [[ ";${PROMPT_COMMAND:-};" != *";__rosotacom_note_exit;"* ]]; then
91
+ PROMPT_COMMAND="__rosotacom_note_exit;${PROMPT_COMMAND:+${PROMPT_COMMAND}}"
92
+ fi
93
+ ;;
94
+ *)
95
+ echo "[rosotacom_catmux_log] non-interactive shell; pane-exit reporting disabled" >&2
96
+ ;;
97
+ esac
98
+
55
99
  {
56
100
  printf '\n--- rosotacom catmux pipe-pane started %s ---\n' "$(date -Iseconds)"
57
101
  printf ' window=%s (#%s) pane=%s\n' \
@@ -1413,6 +1413,12 @@ def _build_status_pipeline_spec(
1413
1413
  outbound_items.insert(0, (hb_entry, hb_pipe))
1414
1414
 
1415
1415
  for e, p in outbound_items:
1416
+ # Who publishes the native stage decides who is answerable when it is
1417
+ # missing. The heartbeat is the one outbound topic rosotacom publishes
1418
+ # itself (`heartbeat_echo`), so its absence is a defect here rather
1419
+ # than an application that has not started -- which is exactly the
1420
+ # 2026-08-13 failure the startup check exists for.
1421
+ native_produced_by = "heartbeat_echo" if e.index == -1 else "application"
1416
1422
  base = e.base
1417
1423
  final = p.get("final", base)
1418
1424
  forward_final = f"/to_{remote_name}{final}" if use_target_prefix else final
@@ -1441,7 +1447,7 @@ def _build_status_pipeline_spec(
1441
1447
  "topic": native_topic,
1442
1448
  "type": native_type,
1443
1449
  "domain": "local",
1444
- "produced_by": "application",
1450
+ "produced_by": native_produced_by,
1445
1451
  },
1446
1452
  ]
1447
1453
  if final != base and not is_g2l:
rosotacom/transit.py CHANGED
@@ -32,15 +32,34 @@ def load_transit_records(paths: Iterable[Path]) -> list[dict[str, Any]]:
32
32
  return records
33
33
 
34
34
 
35
+ def record_epoch(record: dict[str, Any]) -> int:
36
+ """Which run of the publishing peer's wrapper produced this record.
37
+
38
+ A peer restart resets ``seq`` to zero mid-instance, so ``(source, topic,
39
+ seq)`` is not unique over an instance that outlived one. Records written
40
+ before the field learned this carry no ``epoch``; they are all epoch 0,
41
+ which is what a single-epoch instance produces anyway.
42
+ """
43
+ value = record.get("epoch")
44
+ return 0 if value is None else int(value)
45
+
46
+
35
47
  def join_transit_records(records: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
36
- """Join duplicate observations by ``(topic, seq)`` and keep the richest row."""
37
- joined: dict[tuple[str, str, int], dict[str, Any]] = {}
48
+ """Join duplicate observations by ``(source, topic, epoch, seq)``.
49
+
50
+ Keeps the richest row per key. ``epoch`` is part of the key because it is
51
+ what makes the key unique: on 2026-08-13 the vehicle restarted four times
52
+ inside one centre instance, and joining on ``(source, topic, seq)`` alone
53
+ silently dropped 123,253 of 304,876 raw transit lines -- 40% of the run,
54
+ concentrated in exactly the mission window the analysis was about.
55
+ """
56
+ joined: dict[tuple[str, str, int, int], dict[str, Any]] = {}
38
57
  status_rank = {"lost": 0, "reordered": 1, "delivered": 2}
39
58
  for record in records:
40
59
  topic = str(record.get("topic") or "")
41
60
  source = str(record.get("source") or "")
42
61
  seq = int(record["seq"])
43
- key = (source, topic, seq)
62
+ key = (source, topic, record_epoch(record), seq)
44
63
  current = joined.get(key)
45
64
  if current is None:
46
65
  joined[key] = dict(record)
@@ -67,19 +86,26 @@ def filter_transit_records_by_publish_window(
67
86
  ) -> list[dict[str, Any]]:
68
87
  """Keep records whose publish time falls inside a measurement window.
69
88
 
70
- Lost records do not carry timestamps. For each source/topic/target stream,
71
- keep lost sequence records only when they are bounded by delivered/reordered
72
- records inside the window. This excludes startup and teardown sequence gaps
73
- without hiding losses that happened during the measured interval.
89
+ Lost records do not carry timestamps. For each source/target/topic/epoch
90
+ stream, keep lost sequence records only when they are bounded by
91
+ delivered/reordered records inside the window. This excludes startup and
92
+ teardown sequence gaps without hiding losses that happened during the
93
+ measured interval.
94
+
95
+ The epoch belongs in the grouping key for the same reason it belongs in the
96
+ join key: sequence numbers only order messages within one run of the
97
+ publishing wrapper, so a post-restart seq 40 must not bound a pre-restart
98
+ seq 40's loss.
74
99
  """
75
100
  if end_s < start_s:
76
101
  raise ValueError("end_s must be >= start_s")
77
- grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
102
+ grouped: dict[tuple[str, str, str, int], list[dict[str, Any]]] = {}
78
103
  for record in join_transit_records(records):
79
104
  key = (
80
105
  str(record.get("source") or ""),
81
106
  str(record.get("target") or ""),
82
107
  str(record.get("topic") or ""),
108
+ record_epoch(record),
83
109
  )
84
110
  grouped.setdefault(key, []).append(record)
85
111
 
@@ -111,6 +137,7 @@ def filter_transit_records_by_publish_window(
111
137
  str(record.get("source") or ""),
112
138
  str(record.get("target") or ""),
113
139
  str(record.get("topic") or ""),
140
+ record_epoch(record),
114
141
  int(record["seq"]),
115
142
  ),
116
143
  )
@@ -173,15 +200,24 @@ def _latency_trend_ms(
173
200
 
174
201
 
175
202
  def _nominal_send_period_ms(topic_records: list[dict[str, Any]], *, time_field: str = "t_wrap") -> float | None:
176
- """Median publish spacing per sequence step — the send cadence of the stream."""
177
- stamped = sorted(
178
- (int(record["seq"]), float(record[time_field]))
179
- for record in topic_records
180
- if record.get(time_field) is not None
181
- )
182
- periods = [
183
- (t1 - t0) / (s1 - s0) for (s0, t0), (s1, t1) in zip(stamped, stamped[1:], strict=False) if s1 > s0 and t1 >= t0
184
- ]
203
+ """Median publish spacing per sequence step — the send cadence of the stream.
204
+
205
+ Per epoch: a sequence step only means "one message" inside one run of the
206
+ publishing wrapper. Across a restart the step is a reset, and dividing the
207
+ wall-clock gap by it produces a period that describes nothing.
208
+ """
209
+ periods: list[float] = []
210
+ for epoch in {record_epoch(record) for record in topic_records}:
211
+ stamped = sorted(
212
+ (int(record["seq"]), float(record[time_field]))
213
+ for record in topic_records
214
+ if record.get(time_field) is not None and record_epoch(record) == epoch
215
+ )
216
+ periods.extend(
217
+ (t1 - t0) / (s1 - s0)
218
+ for (s0, t0), (s1, t1) in zip(stamped, stamped[1:], strict=False)
219
+ if s1 > s0 and t1 >= t0
220
+ )
185
221
  if not periods:
186
222
  return None
187
223
  ordered = sorted(periods)
@@ -239,6 +275,10 @@ def summarize_transit_records(records: Iterable[dict[str, Any]]) -> dict[str, An
239
275
  "expected": len(topic_records),
240
276
  "delivered": delivered,
241
277
  "lost": lost,
278
+ # >1 means the publishing peer restarted inside this instance. Kept
279
+ # in the summary because it changes how every rate-like number
280
+ # below should be read, and because it is otherwise invisible.
281
+ "epochs": len({record_epoch(record) for record in topic_records}),
242
282
  "loss_pct": round(100.0 * lost / len(topic_records), 3) if topic_records else 0.0,
243
283
  "reordered": reordered,
244
284
  "ota_hop_ms": _distribution_ms(ota),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rosotacom-dev
3
- Version: 2.5.dev33
3
+ Version: 2.5.dev36
4
4
  Summary: Host CLI for ROS OTA communication sessions.
5
5
  Author: develNor
6
6
  License-Expression: BSD-3-Clause
@@ -311,6 +311,25 @@ rosotacom start 1_heartbeat --identity b \
311
311
 
312
312
  `rosotacom` reads the static session input and creates generated files under `session-instances/<date>/<session>_<timestamp>_<id>/config/`, including per-peer plugin/session specs, topic lists, optional QoS, and optional `domain_bridge.yaml`. Catmux pane output is logged under the same instance in `logs/<peer>/catmux/`.
313
313
 
314
+ What else a run leaves under `logs/<peer>/`, and which file to open first when a
315
+ peer looked healthy but did nothing:
316
+
317
+ | File | Written by | Read it for |
318
+ |---|---|---|
319
+ | `catmux/<NN>-<WINDOW>/<pane>.log` | `catmux_log_setup.sh` (`tmux pipe-pane`) | everything a node printed, including the traceback it died with |
320
+ | `pane_failures.log` | the same script's prompt hook | one line per non-zero pane exit — **the file that answers "is any pane dead?"** |
321
+ | `status/startup_check.json` | `status_overview`, once, after `status_startup_grace_s` and one confirming look a grace later | the verdict: which outbound stages this peer produces and never published, plus the pane failures above |
322
+ | `status/status.{json,txt}` and `status/events.jsonl` | `status_overview`, continuously | live per-stage state and the transit records |
323
+ | `scenario/<component>.log` | `rosotacom start <scenario>` | each scenario application's own output |
324
+ | `launcher.log`, `docker.log` | `rosotacom start` | the exact command, and the container's own stdout where it has one |
325
+
326
+ A pane whose command exits drops back to a shell prompt and keeps looking alive
327
+ — `docker ps` shows a healthy container and the other panes keep working. That
328
+ is how a control centre ran for two hours on 2026-08-13 with no heartbeat
329
+ publisher, because Cyclone had no free participant index left on the local
330
+ domain and `heartbeat_echo` died two seconds after start. The last two rows of
331
+ that table exist so the next one announces itself.
332
+
314
333
  ### Complete use cases with scenarios
315
334
 
316
335
  A session intentionally describes only the communication contract. When a use
@@ -577,9 +596,15 @@ The running session writes, under
577
596
  - `status.json` — machine-readable snapshot (source of truth) for tools/agents,
578
597
  refreshed on a short interval and on every state transition,
579
598
  - `status.txt` — a human-rendered table, and
580
- - `events.jsonl` — state transitions plus per-`(topic, seq)` transit records for
581
- wrapped topics (delivered/lost/reordered, section latency, size, inter-arrival,
582
- and jitter).
599
+ - `events.jsonl` — state transitions plus per-`(topic, epoch, seq)` transit
600
+ records for wrapped topics (delivered/lost/reordered, section latency, size,
601
+ inter-arrival, and jitter). `epoch` counts the restarts of the sending peer's
602
+ wrapper: sequence numbers start at zero again after one, so it is what keeps
603
+ the numbering unique over an instance that outlived a restart.
604
+
605
+ Which of the bandwidth numbers a session publishes answers which question —
606
+ per-stream payload, whole-interface wire traffic, and the overhead ratio
607
+ between them — is in [link bandwidth](docs/link-bandwidth.md).
583
608
 
584
609
  For fixed-interval network-condition samples alongside the same session, enable
585
610
  the [link trace recorder](docs/link-trace.md). It writes
@@ -6,7 +6,7 @@ rosotacom/benched_set.py,sha256=lBA1Rj04dbr2e-lIvYTWoL55_LdUpCAC-y7eIvJZ8lc,2721
6
6
  rosotacom/benchmark.py,sha256=pd8GMr_ENRIO4orKBAXDNPFVrgDtSjiqKZHW9icqGeg,76184
7
7
  rosotacom/benchmark_delta.py,sha256=qXzriBkbXOeJdlLKM4ziov1F-JfwlseYHE48S9hjFsQ,10005
8
8
  rosotacom/bundle_check.py,sha256=l_3BSuYk6juXQmsNU6jkxRbH8ayH2GkiW9ONKMQ1oJY,18270
9
- rosotacom/cli.py,sha256=PAVho_WTTqrdULGj2aLuXthrXO4lSCO36CTQ2HKC_UQ,398761
9
+ rosotacom/cli.py,sha256=Z22DGj14_EJ6sgqNRPliOrKTeHyOkeAtHWpSrv2YEOI,399930
10
10
  rosotacom/cli_benchmark.py,sha256=vbGAxO7haTQx5RL7p150nlfKMaAFlm8chZ7VN4cQPC4,349881
11
11
  rosotacom/deployment.py,sha256=XVhB8JjxhAsglSEEPtiVSeV6fCG0rgGmDRulh14oIYQ,7318
12
12
  rosotacom/ffmpeg_packet.py,sha256=zB1BHgY1kexr5sETVMkm4bkVnRytj9k2t0uOYiHUZ6M,4267
@@ -20,16 +20,16 @@ rosotacom/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  rosotacom/status_eval.py,sha256=V9KVho5DEBYCD_iG1X2Yg6qd6ZEzopdKSY5QJ6K1sP4,23115
21
21
  rosotacom/stream_stats.py,sha256=ey09g4B7y0E-NnsOBW8Ng9kqyMbo2xuabNut9op6uM4,23744
22
22
  rosotacom/trace_profiles.py,sha256=KNM1RExHDXkppWZdqbSPXTjwJ8f7io8IU48HnKzmHKY,20567
23
- rosotacom/transit.py,sha256=0BqDO3nfYJ1PMXRz8b3084sDOrCjhO5zwdqC_-qU6bo,10531
23
+ rosotacom/transit.py,sha256=3eM9EC8N9JAHFqEHBRqqubQDR5FOQmrQXREWfDeaNSo,12501
24
24
  rosotacom/video_quality.py,sha256=9NqWA3MgHDvjGmJoHHXQCqb17cyIkHYTrkwNxBoVqMA,33845
25
25
  rosotacom/resources/benched-set.yaml,sha256=wbWsoPHRQ5ji_pjM-rPe0dOSXv5kfD31FJoTn9YGsO0,14978
26
- rosotacom/resources/ros2docker.json.example,sha256=ARbtWmVNWCcAMcFhaHIPBocefDlc9Zx8gHS47uD5D_Q,2582
26
+ rosotacom/resources/ros2docker.json.example,sha256=DwJYoqN6M0-Don4nRHtM2h96k1oEmi7UrEfZuEjfrz0,2541
27
27
  rosotacom/resources/examples/.gitignore,sha256=9FFIV42CR379XRSDOC9UdXsfJP8cGACLjHxiU9TJG6E,19
28
28
  rosotacom/resources/examples/README.md,sha256=RclnxBhlKcl9nRp2PvllVy6akKMZ82cOLv29et-SPyQ,5889
29
29
  rosotacom/resources/examples/__init__.py,sha256=IKLiqdO5jeBX3rMkWyb_Uc3SrQZp5vkJtqoTD_NZ90E,52
30
30
  rosotacom/resources/examples/deployment.example.yaml,sha256=E3HMJhv-Ft5mA6PC8d7yVZz1uwG3OQhCD_MBnhxtvAg,450
31
31
  rosotacom/resources/examples/profiles.yaml,sha256=KhmbIvO7GAWEQEm8QmIr_VCP14eioh_nSohxFf9gouw,3375
32
- rosotacom/resources/examples/ros2docker.json,sha256=jX9YYAD1plb9oJsjtRcJuPA5t542x6iRZ6eInUG5IQE,2540
32
+ rosotacom/resources/examples/ros2docker.json,sha256=Gd_g1P_OA-_7-nM8-KmTTX2cRUyoNCpaPj9MCgxld3M,2499
33
33
  rosotacom/resources/examples/rosotacom.yaml,sha256=lpFnRNJv9kjS3Pd6dJAG1PXF-UP-YbSOK8dNcUe68hY,351
34
34
  rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml,sha256=esIzUWq2anN89egMyZcHDCs5eIuVyruSU0eZPtfo2-c,445
35
35
  rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml,sha256=rxDLMqCyX-WR0Q4moGgmDhxpO4ffvf9pSSihJhXAsNQ,313
@@ -104,6 +104,7 @@ rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml,s
104
104
  rosotacom/resources/ws/.vscode/c_cpp_properties.json,sha256=tnO19diLYiK_OoyLXyQqADJkXgfeswy95r4qMCTBeT8,422
105
105
  rosotacom/resources/ws/.vscode/settings.json,sha256=xNEQ9qGmUhxkLXYDJj4QVt4l3BtgiwMECuS16qnpdt0,201
106
106
  rosotacom/resources/ws/ota_configs/create_zenoh_json5.py,sha256=um2xU1SfNVo_FvlS8iYeAauu67I1N1Z8x-Ckw7KUAAk,2356
107
+ rosotacom/resources/ws/ota_configs/cyclonedds_local_participants.xml.template,sha256=_PAy5sj9_pk2If7xQ_iHF2Oi1tC6HaZe3dDyl8fRSoo,1708
107
108
  rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template,sha256=YspSB4xbk2C_CODoowBQUkqsehdGYEd9I0YCjdGN1dc,197
108
109
  rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template,sha256=Tb8l9FDEhmfuGp53hTcEqmS6SckdEHjDQ8zQdWPUI6A,1165
109
110
  rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template,sha256=j_BF3E9Dub1l-lVreZxmUa0plGRssCWoNkaIy_6jFVQ,1972
@@ -145,7 +146,7 @@ rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py,sha256=F
145
146
  rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py,sha256=8nc456sfoL7BdDTikpYJmF3lty0DjV1XdJUTvawTWkc,14382
146
147
  rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py,sha256=KtzrNw2rYQ_0TAxO4k73bCnAy7J2WnBHVxgYFxVNELg,2902
147
148
  rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py,sha256=jkt8C-voAAqZfI6qNIKvTD8-jzQyEoHmZyrLyR1aqhs,8019
148
- rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py,sha256=uDl_XvPHZISEt72fSOML3fgONUXyVKQZh6VHvsNen-Q,6977
149
+ rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py,sha256=SB-M4_EIihb6lS6hlaV1b4SuunXje9y6RBxYOpMQB0Q,8985
149
150
  rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py,sha256=7zwRYQcbsGwd9jLWozWIQ6u_mI-HeMPRxp6BiPVY6zw,7055
150
151
  rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py,sha256=BCuq_njJ_sXgdSGJ1J9FtH6sxqvELTU0UdXAw13XqhA,18724
151
152
  rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py,sha256=m6lMb51q5pUYTyHVWiss8vPlsbYRpKMDwHG9gG_fY4E,9088
@@ -159,10 +160,10 @@ rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py,sha256=S8LjtZs_WBdnvL5oN
159
160
  rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py,sha256=prpmQf_cXp3KMYw3FlBZfNhq0Rrz56YUjWOunVF6xL8,9259
160
161
  rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py,sha256=su_oWQBeDeLHdCy08YzzsASPgYoWID-syqqVkrjSg4g,2768
161
162
  rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py,sha256=VXR0EN8EdExxLSb8UbY4mTv94khLZi6weqJpQtRMbd8,2600
162
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py,sha256=C9WK6bhW-8rKPmqcmtC3Wl8kTluEiS4lR-sGwg9Dh-k,22245
163
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py,sha256=evMMHhuOggQ_W28z12aJLH0KAayxfQJJmeYC13bUthU,45407
163
+ rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py,sha256=uEG2Rr8Ck7DyaYQn9AtZA3AhQJ3-mAsGQyrc9WzkMKI,22669
164
+ rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py,sha256=i7_U52zML9CPY0oBg6toGHhMETSer3ObqAJThCDzFA8,58877
164
165
  rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py,sha256=eWq0D-pVDadj186e2ZC4kZpxTBkXMzi24BfBj3NjGXc,3777
165
- rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py,sha256=tS_7gLtEVwyPBE2kk_khFBvJSiHaUPSK8w2OtJRSJRc,15019
166
+ rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py,sha256=5iNoHU3SiiDwzsOj7tudh5d801ELVxaHiGKmTCP3JFw,16320
166
167
  rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py,sha256=WA34WSuY4jpzqsJ9S3HgETTWP5tZHGsslF7FAqE8oik,7092
167
168
  rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py,sha256=gp2qb55dEuF_7LsqDSdnBw0lO-zJb9ZyuhlkGKxxqzs,6788
168
169
  rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py,sha256=b_teazIo8ymguKq-f9Xpwe3Q4uRFBhCqb1qB5EC0NP0,25730
@@ -179,19 +180,19 @@ rosotacom/resources/ws/ros2src/com_py/resource/com_py,sha256=47DEQpj8HBSa-_TImW-
179
180
  rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py,sha256=6nG7vGJHtcFNF27QnznuJ01WPMmy7DVYKOpFHO_zF1c,962
180
181
  rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py,sha256=N6gToFB_DOUaLQtXMS0swHigMHo8qFWA6oAbDS-XVdI,884
181
182
  rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py,sha256=6LiNSe4paYextABp-BAZtfoJsaob_umh-q5Fu1z1Pto,803
182
- rosotacom/resources/ws/session/content/base/session_plugin_base.yaml,sha256=2Cu37dvvYvYrpMxz2MY0k4nn6YMQiJi6_gzt99xiIZ4,39002
183
+ rosotacom/resources/ws/session/content/base/session_plugin_base.yaml,sha256=fcs7G5jlOkTUT3lSOxvoWBkuMokJ3xLr5n15KiYY32U,39717
183
184
  rosotacom/resources/ws/session/content/network/up_connectivity.py,sha256=-a6E97Uw159DfRXxYA0MBWRUPA6mQhuAA7pSjASM9qU,13593
184
185
  rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py,sha256=UVRtGdbHYayXM0OFkXs2BV2lW0CsY0m_NTlbPO1J9oE,8682
185
186
  rosotacom/resources/ws/session/creation/anonymize_bag.py,sha256=jxOTKFjmde-pyoVWE9-9icpSjf4nh97-q2Eqx-BDUl8,6154
186
- rosotacom/resources/ws/session/creation/catmux_log_setup.sh,sha256=d6slqefxfCUhBlGWw3fkC50RbntuQR93e8h54hTww7c,3135
187
+ rosotacom/resources/ws/session/creation/catmux_log_setup.sh,sha256=VyBAFqmhQcIziSnDCRDNUsT5QpbLzKWU-DqRzaj_WQs,5243
187
188
  rosotacom/resources/ws/session/creation/create_session_yaml.py,sha256=8cfzkUxK7IebIBC36Vu7lcWtogM8fG0zveBCUGeiVdU,5727
188
- rosotacom/resources/ws/session/creation/generate_session_files.py,sha256=Iw5spNwJrO7FLRGMk2pxnj5CBVkmkuQKLOulF-jUzQs,141798
189
+ rosotacom/resources/ws/session/creation/generate_session_files.py,sha256=biJheNo1NnERwYCcuZw0ktGYofBR9l2zeF6BVpiCuMM,142276
189
190
  rosotacom/resources/ws/session/creation/run_session.py,sha256=G2hJhrmLp11gm3JZ6_C-zZcqu_reXlE_wmobB7BiCfU,16849
190
191
  rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py,sha256=Ub_5f-ttUUc-rAPrZmg_Op34s8Z4C91hTyEn8fgyt-E,2633
191
192
  rosotacom/resources/ws/session/creation/strip_ansi.py,sha256=vNjpemcq4xHr0VG4x0bZqvgwEBEfOgN-b4PzbrM5nfM,851
192
- rosotacom_dev-2.5.dev33.dist-info/licenses/LICENSE.txt,sha256=FDB3-fao3lqZ94wsJHKAH7U88kAnfvZhZC8Hm5rN__g,1459
193
- rosotacom_dev-2.5.dev33.dist-info/METADATA,sha256=aNZPP_sF9HgAcaTI9RFmiBnUfN9k5NiI2vd8ERmvimI,52217
194
- rosotacom_dev-2.5.dev33.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
195
- rosotacom_dev-2.5.dev33.dist-info/entry_points.txt,sha256=-Hl9WKAfkLjMHUonDTxvz86EgSfZf6oyatRMxypS5iA,147
196
- rosotacom_dev-2.5.dev33.dist-info/top_level.txt,sha256=BZrtQcIVDWn_pQZR3Mlssqy5SaRXHjOa6B-1D9Fy4Ao,10
197
- rosotacom_dev-2.5.dev33.dist-info/RECORD,,
193
+ rosotacom_dev-2.5.dev36.dist-info/licenses/LICENSE.txt,sha256=FDB3-fao3lqZ94wsJHKAH7U88kAnfvZhZC8Hm5rN__g,1459
194
+ rosotacom_dev-2.5.dev36.dist-info/METADATA,sha256=Yzy1-og2TcINwvCW7w0NSf7QNFtqtqGbto5dkqZjgdA,54130
195
+ rosotacom_dev-2.5.dev36.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
196
+ rosotacom_dev-2.5.dev36.dist-info/entry_points.txt,sha256=-Hl9WKAfkLjMHUonDTxvz86EgSfZf6oyatRMxypS5iA,147
197
+ rosotacom_dev-2.5.dev36.dist-info/top_level.txt,sha256=BZrtQcIVDWn_pQZR3Mlssqy5SaRXHjOa6B-1D9Fy4Ao,10
198
+ rosotacom_dev-2.5.dev36.dist-info/RECORD,,