Skip to main content

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}