dora_message/daemon_to_coordinator.rs
1use std::collections::BTreeMap;
2
3pub use crate::common::{
4 DataMessage, LogLevel, LogMessage, NodeError, NodeErrorCause, NodeExitStatus, Timestamped,
5};
6use crate::{
7 BuildId, DataflowId, common::DaemonId, current_crate_version, id::NodeId, metadata::Metadata,
8 versions_compatible,
9};
10
11/// Per-dataflow status reported by a daemon after (re-)registration.
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct DataflowStatusEntry {
14 pub dataflow_id: uuid::Uuid,
15 pub running_nodes: Vec<NodeId>,
16}
17
18#[allow(clippy::large_enum_variant)]
19#[derive(Debug, serde::Serialize, serde::Deserialize)]
20#[non_exhaustive]
21pub enum CoordinatorRequest {
22 Register(DaemonRegisterRequest),
23 Event {
24 daemon_id: DaemonId,
25 event: DaemonEvent,
26 },
27 /// Resolve a machine id to a registered daemon (cross-machine pools).
28 ResolveMachine {
29 machine_id: String,
30 },
31}
32
33#[derive(Debug, serde::Serialize, serde::Deserialize)]
34pub struct DaemonRegisterRequest {
35 dora_version: semver::Version,
36 pub machine_id: Option<String>,
37 #[serde(default)]
38 pub labels: BTreeMap<String, String>,
39 /// Whether this daemon understands hub-sourced git nodes — the `subdir`
40 /// and `hub` provenance fields on a `GitSource` (spec §10.2, P2.10).
41 ///
42 /// `#[serde(default)]` makes this `false` for a daemon built before the
43 /// field existed, so the coordinator can refuse to route a hub node to it
44 /// with a clear error. This is the capability signal the `dora_version`
45 /// gate cannot provide: during the `1.0.0-rc` window a pre-hub daemon and a
46 /// hub-aware coordinator report the *same* version, so version alone can't
47 /// distinguish them — an explicit flag can.
48 #[serde(default)]
49 supports_hub_sources: bool,
50
51 /// Layout version of [`Metadata`], which every `InterDaemonEvent::Output`
52 /// carries between daemons over zenoh.
53 ///
54 /// The coordinator is the only chokepoint that sees every daemon: there is
55 /// no daemon-to-daemon connection to handshake on, because that path is
56 /// zenoh pub/sub. Gating registration therefore gates the daemon-to-daemon
57 /// wire transitively — any two daemons routing a dataflow have both passed
58 /// this check, so they agree on the `Metadata` layout.
59 ///
60 /// `dora_version` alone does not cover this: #2366 dropped a `Metadata`
61 /// field without changing the version, and the resulting mid-stream desync
62 /// was #2742. The node-to-daemon path already gates this
63 /// ([`crate::node_to_daemon::NodeRegisterRequest::check_version`]); this is
64 /// the same gate one hop out.
65 ///
66 /// `#[serde(default)]` makes a pre-field daemon report 0 and fail the check
67 /// with a legible message. That works because this frame is JSON over the
68 /// coordinator WebSocket — a non-self-describing encoding would fail while
69 /// decoding the frame that carries the field, never reaching the check.
70 #[serde(default)]
71 metadata_version: u16,
72
73 /// The zenoh endpoint this daemon is about to bind, so the coordinator can
74 /// hand it to daemons that register after this one.
75 ///
76 /// Carried *in the registration* rather than reported once the session is
77 /// open, and that timing is the whole point. The coordinator handles
78 /// registrations one at a time on its event loop, so an endpoint that
79 /// arrives with the registration is already on record before the next
80 /// daemon's reply is built — two daemons starting simultaneously are
81 /// ordered by that loop and the later one always learns about the earlier.
82 /// Reporting after the session opened instead left a window (register →
83 /// listener bound) in which both daemons could register, each be handed a
84 /// list without the other, and stay partitioned: zenoh reads
85 /// `connect/endpoints` once at session open, so neither could act on the
86 /// other's later report.
87 ///
88 /// The port is reserved before registering, so this is the endpoint the
89 /// daemon *will* bind rather than one it has bound. A bind that then fails
90 /// verification is withdrawn with
91 /// [`DaemonEvent::zenoh_listen_endpoint`]`(None)` (both advertisement fields
92 /// are `None`), so the coordinator stops handing out a dead listener.
93 ///
94 /// `None` when the reservation failed or the listener is loopback. Keep
95 /// loopback out of this field: 1.0.x coordinators forward it to every host.
96 #[serde(default)]
97 pub zenoh_listen_endpoint: Option<String>,
98 /// Loopback listener, advertised separately so 1.0.x coordinators ignore
99 /// it. New coordinators hand it only to peers on the coordinator's host.
100 /// Missing means no loopback advertisement, as in 1.0.x.
101 #[serde(default)]
102 pub zenoh_loopback_listen_endpoint: Option<String>,
103}
104
105impl DaemonRegisterRequest {
106 pub fn new(machine_id: Option<String>, labels: BTreeMap<String, String>) -> Self {
107 Self::with_zenoh_endpoint(machine_id, labels, None)
108 }
109
110 /// [`Self::new`] plus the zenoh endpoint this daemon will bind; see
111 /// [`Self::zenoh_listen_endpoint`] and [`Self::zenoh_loopback_listen_endpoint`].
112 /// Loopback listeners are placed only in the latter field.
113 pub fn with_zenoh_endpoint(
114 machine_id: Option<String>,
115 labels: BTreeMap<String, String>,
116 zenoh_listen_endpoint: Option<String>,
117 ) -> Self {
118 let (zenoh_listen_endpoint, zenoh_loopback_listen_endpoint) =
119 split_zenoh_listen_endpoint(zenoh_listen_endpoint);
120 Self {
121 dora_version: current_crate_version(),
122 machine_id,
123 labels,
124 // a daemon built from this crate understands hub git sources
125 supports_hub_sources: true,
126 metadata_version: Metadata::CURRENT_VERSION,
127 zenoh_listen_endpoint,
128 zenoh_loopback_listen_endpoint,
129 }
130 }
131
132 /// Whether the registering daemon can build/spawn hub-sourced git nodes
133 /// (carrying `subdir` / `hub` provenance).
134 pub fn supports_hub_sources(&self) -> bool {
135 self.supports_hub_sources
136 }
137
138 pub fn check_version(&self) -> Result<(), String> {
139 let crate_version = current_crate_version();
140 let specified_version = &self.dora_version;
141
142 if versions_compatible(&crate_version, specified_version)? {
143 // Even when semver matches, the payload layout can differ within a
144 // release series (#2366). Reject here so the failure is a legible
145 // registration error rather than a mid-stream desync between
146 // daemons routing the same dataflow (#2742).
147 if self.metadata_version != Metadata::CURRENT_VERSION {
148 return Err(format!(
149 "message wire-format mismatch: this daemon speaks metadata format v{} \
150 but the coordinator speaks v{}. The daemon and coordinator were built \
151 from dora revisions with incompatible message formats; rebuild both \
152 from the same revision.",
153 self.metadata_version,
154 Metadata::CURRENT_VERSION
155 ));
156 }
157 Ok(())
158 } else {
159 // Direction-aware remediation: `versions_compatible` rejects both
160 // older and newer daemons, so the fix differs. Upgrade whichever
161 // side is older.
162 let remedy = if *specified_version < crate_version {
163 format!(
164 "upgrade the daemon to match the coordinator (e.g. \
165 `cargo install dora-cli --version {crate_version}`) — an older daemon \
166 also lacks newer wire features such as hub `subdir`/`hub:` node sources"
167 )
168 } else {
169 format!(
170 "upgrade the coordinator to dora v{specified_version} (or run an older \
171 daemon) so both sides match"
172 )
173 };
174 Err(format!(
175 "version mismatch: this daemon runs dora v{specified_version} but the \
176 coordinator expects v{crate_version} — these dora versions are incompatible. \
177 {remedy}.",
178 ))
179 }
180 }
181}
182
183#[cfg(test)]
184mod register_version_tests {
185 use super::*;
186
187 #[test]
188 fn current_version_is_compatible() {
189 assert!(
190 DaemonRegisterRequest::new(None, Default::default())
191 .check_version()
192 .is_ok()
193 );
194 }
195
196 fn request_with_version(dora_version: semver::Version) -> DaemonRegisterRequest {
197 DaemonRegisterRequest {
198 dora_version,
199 machine_id: None,
200 labels: Default::default(),
201 supports_hub_sources: true,
202 metadata_version: Metadata::CURRENT_VERSION,
203 zenoh_listen_endpoint: None,
204 zenoh_loopback_listen_endpoint: None,
205 }
206 }
207
208 #[test]
209 fn same_version_daemon_with_a_different_metadata_layout_is_rejected() {
210 // The gap this closes: semver alone let #2366 through, where a
211 // `Metadata` field was dropped without a version bump. Two daemons
212 // routing one dataflow exchange `InterDaemonEvent::Output` over zenoh,
213 // which carries `Metadata` — and zenoh pub/sub has no connection to
214 // handshake on, so the coordinator is the only place this can be
215 // caught. A same-version daemon with a stale layout must be rejected
216 // here rather than desyncing mid-stream (#2742).
217 let mut req = DaemonRegisterRequest::new(None, Default::default());
218 req.metadata_version = Metadata::CURRENT_VERSION.wrapping_add(1);
219
220 let err = req
221 .check_version()
222 .expect_err("a metadata layout mismatch must be rejected");
223 assert!(err.contains("wire-format mismatch"), "{err}");
224 assert!(
225 err.contains("rebuild both"),
226 "the error should say how to fix it: {err}"
227 );
228 }
229
230 #[test]
231 fn a_daemon_predating_the_field_is_rejected_with_a_legible_error() {
232 // The frame is JSON over the coordinator WebSocket, so a daemon built
233 // before `metadata_version` existed simply omits it and `serde(default)`
234 // yields 0. That must fail the gate with a real message rather than
235 // being silently treated as compatible.
236 let json = serde_json::to_string(&DaemonRegisterRequest::new(None, Default::default()))
237 .expect("serialize");
238 let stripped: serde_json::Value = {
239 let mut v: serde_json::Value = serde_json::from_str(&json).unwrap();
240 v.as_object_mut().unwrap().remove("metadata_version");
241 v
242 };
243 let old: DaemonRegisterRequest =
244 serde_json::from_value(stripped).expect("a pre-field daemon must still deserialize");
245 assert_eq!(old.metadata_version, 0);
246 assert!(
247 old.check_version().is_err(),
248 "a pre-field daemon must not be treated as compatible"
249 );
250 }
251
252 #[test]
253 fn incompatible_daemon_gets_direction_aware_upgrade_advice() {
254 // `versions_compatible` rejects both older and newer daemons, so the
255 // remediation must name the right side. (Cross-version only — a
256 // *same-version* pre-hub daemon passes this gate, which is why hub
257 // capability is signalled explicitly via `supports_hub_sources`.)
258 let current = current_crate_version();
259
260 // A NEWER daemon than the coordinator → upgrade the *coordinator*.
261 let err = request_with_version(semver::Version::new(current.major + 1, 0, 0))
262 .check_version()
263 .expect_err("newer-major daemon must be rejected");
264 assert!(err.contains("version mismatch"), "{err}");
265 assert!(
266 err.contains("upgrade the coordinator"),
267 "newer daemon should advise upgrading the coordinator: {err}"
268 );
269
270 // An OLDER daemon than the coordinator → upgrade the *daemon*.
271 let err = request_with_version(semver::Version::new(0, 1, 0))
272 .check_version()
273 .expect_err("older daemon must be rejected");
274 assert!(
275 err.contains("upgrade the daemon"),
276 "older daemon should advise upgrading the daemon: {err}"
277 );
278 }
279
280 #[test]
281 fn hub_capability_is_advertised_by_current_daemons_and_defaults_off() {
282 // A daemon built from this crate advertises hub support.
283 assert!(DaemonRegisterRequest::new(None, Default::default()).supports_hub_sources());
284
285 // A daemon built before the field existed sends a request without it;
286 // `#[serde(default)]` must decode that as "no hub support" so the
287 // coordinator refuses to route hub nodes to it. This is the same-version
288 // gap the version check cannot catch.
289 let legacy = r#"{"dora_version":"1.0.0-rc1","machine_id":null,"labels":{}}"#;
290 let decoded: DaemonRegisterRequest = serde_json::from_str(legacy).unwrap();
291 assert!(!decoded.supports_hub_sources());
292 }
293}
294
295#[derive(Debug, serde::Serialize, serde::Deserialize)]
296#[non_exhaustive]
297pub enum DaemonEvent {
298 BuildResult {
299 build_id: BuildId,
300 result: Result<(), String>,
301 },
302 SpawnResult {
303 dataflow_id: DataflowId,
304 result: Result<(), String>,
305 },
306 AllNodesReady {
307 dataflow_id: DataflowId,
308 exited_before_subscribe: Vec<NodeId>,
309 },
310 AllNodesFinished {
311 dataflow_id: DataflowId,
312 result: DataflowDaemonResult,
313 },
314 Heartbeat {
315 #[serde(default)]
316 ft_stats: Option<FaultToleranceSnapshot>,
317 },
318 /// The zenoh endpoint this daemon actually bound and is reachable at,
319 /// sent once its session is open.
320 ///
321 /// The coordinator records it and hands it to daemons that register later
322 /// (see `RegisterResult::Ok::peer_zenoh_endpoints`), which is what lets a
323 /// multi-machine deployment wire itself without every daemon being told
324 /// every other daemon's address.
325 ///
326 /// Confirms or withdraws the endpoint this daemon advertised in its
327 /// registration, once its zenoh session is open and the listener has been
328 /// verified against `info().locators()`.
329 ///
330 /// `Some` in either `endpoint` or `loopback_endpoint` confirms (and would
331 /// correct a differing advertisement). Both fields `None` withdraw, which
332 /// is what a daemon whose listener did not bind must send so the coordinator
333 /// stops handing out a dead endpoint. Construct through
334 /// [`DaemonEvent::zenoh_listen_endpoint`] to select the appropriate field.
335 ///
336 /// The registration carries the endpoint in the first place — see
337 /// [`DaemonRegisterRequest::zenoh_listen_endpoint`] for why it cannot wait
338 /// until here. This is the correction, not the announcement.
339 #[non_exhaustive]
340 ZenohListenEndpoint {
341 /// Non-loopback listener; `None` withdraws the previous advertisement
342 /// on a 1.0.x coordinator, which ignores `loopback_endpoint`.
343 endpoint: Option<String>,
344 /// Loopback listener, understood only by coordinators that filter it
345 /// by host. Both fields `None` withdraw the advertisement.
346 #[serde(default)]
347 loopback_endpoint: Option<String>,
348 },
349 /// Sent by the daemon after registration to report its current state.
350 /// Enables coordinator-daemon reconciliation on reconnect.
351 StatusReport {
352 running_dataflows: Vec<DataflowStatusEntry>,
353 },
354 Log(LogMessage),
355 Exit,
356 NodeMetrics {
357 dataflow_id: DataflowId,
358 metrics: BTreeMap<NodeId, NodeMetrics>,
359 #[serde(default)]
360 network: Option<NetworkMetrics>,
361 },
362 /// Topic debug payload destined for one or more active CLI subscriptions.
363 ///
364 /// Daemon and coordinator are co-deployed from the same build, so this
365 /// multi-subscriber shape is safe to evolve within the repository.
366 TopicDebugData {
367 dataflow_id: DataflowId,
368 subscription_ids: Vec<uuid::Uuid>,
369 payload: Vec<u8>,
370 },
371 /// Daemon acknowledges state catch-up through a given sequence number.
372 StateCatchUpAck {
373 dataflow_id: DataflowId,
374 ack_sequence: u64,
375 },
376 /// Sent by the daemon when a node has exited and the daemon will NOT
377 /// restart it (e.g. `dora node stop`, a node exiting under
378 /// `restart_policy: Never`, or a final-failure cascade). The
379 /// coordinator uses this to invalidate its cached `node_metrics`
380 /// entry so `dora node list` reflects the actual state instead of
381 /// the last-reported "Running" snapshot. Without this signal the
382 /// daemon's metrics-snapshot loop simply stops including the dead
383 /// node and the coordinator's cache is frozen at the last
384 /// pre-exit values forever.
385 NodeStopped {
386 dataflow_id: DataflowId,
387 node_id: NodeId,
388 /// `true` if the daemon called `disable_restart()` before the
389 /// exit (i.e. the `stop_single_node` / `restart_single_node`
390 /// path triggered by `dora node stop`/`restart`). `false` for
391 /// a final-failure exit under `restart_policy: Never` or a
392 /// `max_restarts` exhaustion. The coordinator uses this to
393 /// pick `NodeStatus::Stopped` vs `NodeStatus::Failed`, so a
394 /// crash is not silently reported as a clean teardown (which
395 /// would hide it from `dora doctor`).
396 #[serde(default)]
397 clean_stop: bool,
398 },
399}
400
401/// Health status of a node
402#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
403#[serde(rename_all = "kebab-case")]
404pub enum NodeStatus {
405 #[default]
406 Running,
407 Restarting,
408 /// One or more inputs have timed out (circuit breaker open)
409 Degraded,
410 Failed,
411 /// Node was cleanly stopped (e.g. via `dora node stop`) and the
412 /// process has exited. Distinguishes a deliberate teardown from a
413 /// crash failure. Coordinator-side entries with this status are
414 /// removed after `NODE_STOPPED_GRACE_PERIOD` so `dora node list`
415 /// eventually stops showing zombies.
416 Stopped,
417}
418
419impl std::fmt::Display for NodeStatus {
420 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421 match self {
422 NodeStatus::Running => write!(f, "Running"),
423 NodeStatus::Restarting => write!(f, "Restarting"),
424 NodeStatus::Degraded => write!(f, "Degraded"),
425 NodeStatus::Failed => write!(f, "Failed"),
426 NodeStatus::Stopped => write!(f, "Stopped"),
427 }
428 }
429}
430
431/// Snapshot of daemon-level fault tolerance counters
432#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
433pub struct FaultToleranceSnapshot {
434 pub restarts: u64,
435 pub health_check_kills: u64,
436 pub input_timeouts: u64,
437 pub circuit_breaker_recoveries: u64,
438}
439
440/// Resource metrics for a node process
441#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
442pub struct NodeMetrics {
443 /// Process ID
444 pub pid: u32,
445 /// CPU usage percentage (0-100 per core)
446 pub cpu_usage: f32,
447 /// Memory usage in bytes
448 pub memory_bytes: u64,
449 /// Disk read bytes per second (if available)
450 pub disk_read_bytes: Option<u64>,
451 /// Disk write bytes per second (if available)
452 pub disk_write_bytes: Option<u64>,
453 /// Number of times this node has been restarted
454 #[serde(default)]
455 pub restart_count: u32,
456 /// Input IDs that have timed out (circuit breaker open)
457 #[serde(default)]
458 pub broken_inputs: Vec<String>,
459 /// Current health status
460 #[serde(default)]
461 pub status: NodeStatus,
462 /// Number of pending messages in the node's input queue
463 #[serde(default)]
464 pub pending_messages: u64,
465}
466
467/// Per-dataflow network I/O counters for cross-daemon Zenoh traffic.
468#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
469pub struct NetworkMetrics {
470 pub bytes_sent: u64,
471 pub bytes_received: u64,
472 pub messages_sent: u64,
473 pub messages_received: u64,
474 #[serde(default)]
475 pub publish_failures: u64,
476}
477
478#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
479pub struct DataflowDaemonResult {
480 pub timestamp: uhlc::Timestamp,
481 pub node_results: BTreeMap<NodeId, Result<(), NodeError>>,
482}
483
484impl DataflowDaemonResult {
485 pub fn is_ok(&self) -> bool {
486 self.node_results.values().all(|r| r.is_ok())
487 }
488}
489
490#[derive(Debug, serde::Deserialize, serde::Serialize)]
491pub enum DaemonCoordinatorReply {
492 TriggerBuildResult(Result<(), String>),
493 TriggerSpawnResult(Result<(), String>),
494 ReloadResult(Result<(), String>),
495 StopResult(Result<(), String>),
496 DestroyResult {
497 result: Result<(), String>,
498 #[serde(skip)]
499 notify: Option<tokio::sync::oneshot::Sender<()>>,
500 },
501 Logs(Result<Vec<u8>, String>),
502 /// Reply for `DaemonCoordinatorEvent::AddNode`. Previously the daemon
503 /// returned `None` and the coordinator accepted any successful TCP
504 /// response as proof that AddNode applied, even a `SetParamResult` or
505 /// other unrelated reply — committing state for a node the daemon
506 /// may have rejected (#1682). This variant lets the coordinator
507 /// pattern-match a specific reply and forward daemon errors to the
508 /// CLI instead of corrupting the dataflow state. Rescue of #1757.
509 AddNodeResult(Result<(), String>),
510 RestartNodeResult(Result<(), String>),
511 StopNodeResult(Result<(), String>),
512 RemoveNodeResult(Result<(), String>),
513 /// Reply for `DaemonCoordinatorEvent::ReplaceNode`. Same
514 /// specific-reply contract as `AddNodeResult` (#1682): the
515 /// coordinator only commits its descriptor update after matching
516 /// this exact variant.
517 ReplaceNodeResult(Result<(), String>),
518 /// Reply for `DaemonCoordinatorEvent::AddMapping`. Previously the daemon
519 /// returned `None`, which the coordinator's WS layer skipped instead
520 /// of forwarding as a reply, causing `send_and_receive` to time out
521 /// after 30s with `daemon dispatch failed: timeout waiting for daemon
522 /// WS reply`. Same bug class as #1682's AddNode silent-reply hole;
523 /// applied to mappings here.
524 AddMappingResult(Result<(), String>),
525 /// Reply for `DaemonCoordinatorEvent::RemoveMapping`. See
526 /// `AddMappingResult` doc for the silent-reply bug class.
527 RemoveMappingResult(Result<(), String>),
528 SetParamResult(Result<(), String>),
529 DeleteParamResult(Result<(), String>),
530 StartTopicDebugStreamResult(Result<(), String>),
531 StopTopicDebugStreamResult(Result<(), String>),
532}
533
534impl DaemonEvent {
535 /// Confirm or withdraw a listener while keeping loopback addresses out of
536 /// the field understood by 1.0.x coordinators. Used on every reconnect too.
537 pub fn zenoh_listen_endpoint(endpoint: Option<String>) -> Self {
538 let (endpoint, loopback_endpoint) = split_zenoh_listen_endpoint(endpoint);
539 Self::ZenohListenEndpoint {
540 endpoint,
541 loopback_endpoint,
542 }
543 }
544}
545
546fn split_zenoh_listen_endpoint(endpoint: Option<String>) -> (Option<String>, Option<String>) {
547 let is_loopback = endpoint
548 .as_deref()
549 .is_some_and(crate::zenoh::zenoh_endpoint_is_loopback);
550 if is_loopback {
551 (None, endpoint)
552 } else {
553 (endpoint, None)
554 }
555}
556
557#[cfg(test)]
558mod zenoh_wire_compatibility_tests {
559 use super::*;
560
561 // The endpoint-bearing fields understood by a 1.0.x coordinator. Serde
562 // ignores other named fields, as the released control-plane types do.
563 #[derive(serde::Deserialize)]
564 struct LegacyRegistration {
565 zenoh_listen_endpoint: Option<String>,
566 }
567
568 #[derive(serde::Deserialize)]
569 enum LegacyEvent {
570 ZenohListenEndpoint { endpoint: Option<String> },
571 }
572
573 #[test]
574 fn loopback_reports_are_invisible_to_legacy_coordinators() {
575 for endpoint in [
576 "tcp/127.0.0.1:45001",
577 "tcp/127.0.0.2:45001",
578 "tcp/localhost:45001",
579 "tcp/127.0.0.1:45001?prio=high#iface=lo",
580 "tcp/127.0.0.1:45001#iface=lo",
581 "tcp/[::1]:45001?prio=high#iface=lo",
582 "udp/127.1.2.3:45001",
583 "tcp/[::1]:45001",
584 "tcp/[::ffff:127.0.0.1]:45001",
585 ] {
586 let request = DaemonRegisterRequest::with_zenoh_endpoint(
587 None,
588 Default::default(),
589 Some(endpoint.into()),
590 );
591 let json = serde_json::to_string(&request).unwrap();
592 let legacy: LegacyRegistration = serde_json::from_str(&json).unwrap();
593 assert_eq!(legacy.zenoh_listen_endpoint, None, "{endpoint}");
594 let decoded: DaemonRegisterRequest = serde_json::from_str(&json).unwrap();
595 assert_eq!(
596 decoded.zenoh_loopback_listen_endpoint.as_deref(),
597 Some(endpoint)
598 );
599
600 // The confirmation and every reconnect use the same constructor.
601 let event = DaemonEvent::zenoh_listen_endpoint(Some(endpoint.into()));
602 let json = serde_json::to_string(&event).unwrap();
603 let LegacyEvent::ZenohListenEndpoint { endpoint: legacy } =
604 serde_json::from_str(&json).unwrap();
605 assert_eq!(legacy, None, "{endpoint}");
606 let decoded: DaemonEvent = serde_json::from_str(&json).unwrap();
607 assert!(matches!(decoded, DaemonEvent::ZenohListenEndpoint {
608 endpoint: None, loopback_endpoint: Some(ep),
609 } if ep == endpoint));
610 }
611 }
612
613 #[test]
614 fn non_loopback_reports_and_withdrawals_keep_the_legacy_behavior() {
615 for endpoint in [
616 None,
617 Some("tcp/10.0.2.100:45001"),
618 Some("tcp/[2001:db8::1]:45001"),
619 ] {
620 let request = DaemonRegisterRequest::with_zenoh_endpoint(
621 None,
622 Default::default(),
623 endpoint.map(str::to_owned),
624 );
625 let json = serde_json::to_string(&request).unwrap();
626 let legacy: LegacyRegistration = serde_json::from_str(&json).unwrap();
627 assert_eq!(legacy.zenoh_listen_endpoint.as_deref(), endpoint);
628 let decoded: DaemonRegisterRequest = serde_json::from_str(&json).unwrap();
629 assert_eq!(decoded.zenoh_listen_endpoint.as_deref(), endpoint);
630 assert!(decoded.zenoh_loopback_listen_endpoint.is_none());
631
632 let event = DaemonEvent::zenoh_listen_endpoint(endpoint.map(str::to_owned));
633 let json = serde_json::to_string(&event).unwrap();
634 let LegacyEvent::ZenohListenEndpoint { endpoint: legacy } =
635 serde_json::from_str(&json).unwrap();
636 assert_eq!(legacy.as_deref(), endpoint);
637 let decoded: DaemonEvent = serde_json::from_str(&json).unwrap();
638 assert!(matches!(decoded, DaemonEvent::ZenohListenEndpoint {
639 endpoint: ep, loopback_endpoint: None,
640 } if ep.as_deref() == endpoint));
641 }
642 }
643
644 #[test]
645 fn legacy_reports_default_to_no_loopback_advertisement() {
646 // These frames have precisely the endpoint fields sent by 1.0.x.
647 for endpoint in [
648 None,
649 Some("tcp/10.0.2.100:45001"),
650 Some("tcp/localhost:45001#iface=lo"),
651 ] {
652 let json = serde_json::json!({
653 "dora_version": "1.0.1",
654 "machine_id": null,
655 "labels": {},
656 "supports_hub_sources": true,
657 "metadata_version": Metadata::CURRENT_VERSION,
658 "zenoh_listen_endpoint": endpoint,
659 });
660 let request: DaemonRegisterRequest = serde_json::from_value(json).unwrap();
661 assert_eq!(request.zenoh_listen_endpoint.as_deref(), endpoint);
662 assert!(request.zenoh_loopback_listen_endpoint.is_none());
663
664 let json = serde_json::json!({"ZenohListenEndpoint": {"endpoint": endpoint}});
665 let event: DaemonEvent = serde_json::from_value(json).unwrap();
666 assert!(matches!(event, DaemonEvent::ZenohListenEndpoint {
667 endpoint: ep, loopback_endpoint: None,
668 } if ep.as_deref() == endpoint));
669 }
670 }
671}