Skip to main content

dora_core/
topics.rs

1use std::net::{IpAddr, Ipv4Addr, SocketAddr};
2
3pub const LOCALHOST: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
4pub const DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT: u16 = 53291;
5/// Env var to override the daemon's local listener port for dynamic nodes.
6pub const DORA_DAEMON_LOCAL_LISTEN_PORT_ENV: &str = "DORA_DAEMON_LOCAL_LISTEN_PORT";
7pub const DORA_COORDINATOR_PORT_WS_DEFAULT: u16 = 6013;
8
9/// Comma-separated zenoh endpoints a spawned node should connect to, injected by
10/// the daemon: the daemon's own listener plus the listeners of the nodes this one
11/// consumes from (see [`DORA_ZENOH_LISTEN_ENV`]).
12///
13/// The daemon's listener is loopback for a single-machine deployment, but may be
14/// a routable address when the daemon is part of a cluster (see
15/// [`zenoh_bind_address_for`]). Either way it is on this node's own host, so the
16/// node can always reach it.
17///
18/// Lets nodes bootstrap zenoh peer discovery without multicast (dev containers,
19/// locked-down hosts, many CI runners), and — since zenoh 1.9 removed peer
20/// relaying — establishes the node↔node links the dataflow needs *explicitly*
21/// rather than leaving them to gossip's best-effort autoconnect.
22pub const DORA_ZENOH_CONNECT_ENV: &str = "DORA_ZENOH_CONNECT";
23
24/// Loopback zenoh endpoint a spawned node should listen on, injected by the
25/// daemon so this node's consumers can dial it directly (they receive it via
26/// their [`DORA_ZENOH_CONNECT_ENV`]).
27///
28/// Peers in zenoh 1.9 do not relay for each other, so a producer and consumer
29/// that never form a direct link simply cannot exchange data — no amount of
30/// waiting fixes it. Assigning each node a known listener makes those links
31/// deterministic instead of racy.
32///
33/// **Stays single-valued.** A node that also needs a routable listener gets it
34/// via [`DORA_ZENOH_LISTEN_EXTRA_ENV`] rather than as a second entry here,
35/// because a node binary built before that variable existed pushes this value
36/// into `listen/endpoints` verbatim as *one* locator. A comma-separated value
37/// would therefore be rejected wholesale by such a node, costing it the
38/// loopback listener too and partitioning it from its same-machine consumers.
39/// Keeping the old variable's shape means an older node degrades to
40/// loopback-only — same-machine links keep working, cross-machine ones fall
41/// back to the daemon path — instead of losing every link (dora-rs/dora#2742).
42pub const DORA_ZENOH_LISTEN_ENV: &str = "DORA_ZENOH_LISTEN";
43
44/// Additional zenoh endpoints a spawned node should listen on, beyond the
45/// loopback one in [`DORA_ZENOH_LISTEN_ENV`]. Comma-separated; injected by the
46/// daemon only for a node that has a consumer under another daemon.
47///
48/// Split out from [`DORA_ZENOH_LISTEN_ENV`] for forward compatibility — see the
49/// note there. A node that does not know this variable simply ignores it.
50pub const DORA_ZENOH_LISTEN_EXTRA_ENV: &str = "DORA_ZENOH_LISTEN_EXTRA";
51
52/// Opt out of zenoh multicast scouting for this process, regardless of whether
53/// explicit connect endpoints replaced it.
54///
55/// Set to `off`, `0`, `false`, or `no` to disable. Any other value (including
56/// unset) leaves the default behaviour, where multicast is dropped only once
57/// [`DORA_ZENOH_CONNECT_ENV`] gives the session something to dial instead.
58///
59/// Exists for networks where the scouting socket itself is the problem: a busy
60/// DDS/ROS2 multicast graph can keep zenoh from binding its scouting group,
61/// which fails `zenoh::open` outright. Disabling scouting sidesteps that bind
62/// entirely — but it removes a discovery mechanism, so a session that has no
63/// connect endpoints *and* no multicast can reach nobody. Set it only where
64/// every link is established explicitly (the daemon injects
65/// [`DORA_ZENOH_CONNECT_ENV`] into the nodes it spawns, so those are covered).
66///
67/// The daemon sets this on the nodes it spawns when started with
68/// `--zenoh-no-multicast`, so a single flag covers the whole process tree.
69pub const DORA_ZENOH_MULTICAST_ENV: &str = "DORA_ZENOH_MULTICAST";
70
71/// Pid of the process whose death must end this node, injected **only** by a
72/// daemon that runs in-process with whoever started it: `Daemon::run_dataflow`
73/// and its callers — `dora run`, `dora daemon --run-dataflow`, and embedders
74/// that drive one dataflow to completion.
75///
76/// There, the one process is coordinator, daemon and node-parent at once, so
77/// its death is the end of the dataflow by definition. Every teardown path
78/// dora has is cooperative and so cannot survive `SIGKILL`, which is neither
79/// catchable nor blockable: no CLI- or daemon-side code runs after it. Nodes
80/// are deliberately spawned as process-group leaders (so a terminal `Ctrl-C`
81/// cannot kill them out from under the daemon), which also means an orphan
82/// keeps running with `ppid 1` in a group of its own — unreachable by both
83/// inherited signal delivery and a group-kill of the parent. Handing the node
84/// the pid lets it notice on its own (dora-rs/dora#2856).
85///
86/// Deliberately NOT set on the `dora up` + `dora start` path: there the parent
87/// is a long-lived daemon whose lifetime is decoupled from its nodes on
88/// purpose — a node survives a coordinator drop, a reconnect, and a watchdog
89/// disconnect while keeping its pid (dora-rs/dora#2029). Tying node lifetime
90/// to that parent would break exactly the property `daemon-reconnect-e2e`
91/// asserts.
92pub const DORA_RUN_PARENT_PID_ENV: &str = "DORA_RUN_PARENT_PID";
93
94/// Zenoh's own config-file override, honored by
95/// [`open_zenoh_session_with_listen`].
96///
97/// Takes precedence over every `DORA_ZENOH_*` variable: when it is set the
98/// session is built entirely from the named file, so the connect/listen plan
99/// and the multicast decision are never read. That makes it a full bypass of
100/// the daemon's node wiring, which is why the daemon refuses it from a
101/// descriptor's `env:` (#2944) while still honoring it from its own
102/// environment — the documented way to point a whole deployment at a custom
103/// zenoh config.
104#[cfg(feature = "zenoh")]
105pub const ZENOH_CONFIG_PATH_ENV: &str = zenoh::Config::DEFAULT_CONFIG_PATH_ENV;
106
107/// Whether a session may discover peers by multicast scouting.
108///
109/// Spelled as an enum rather than a bool because the concept flips polarity at
110/// every hop it crosses — `--zenoh-no-multicast`, `DORA_ZENOH_MULTICAST=off`,
111/// `scouting/multicast/enabled=false` — and an inverted bool would not fail a
112/// test, it would silently partition the dataflow.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum MulticastScouting {
115    /// Scout unless explicit connect endpoints replace it. The default
116    /// everywhere; [`DORA_ZENOH_MULTICAST_ENV`] can still turn it off.
117    #[default]
118    Allowed,
119    /// The caller establishes every link explicitly and wants no scouting.
120    Disabled,
121}
122
123/// Whether [`DORA_ZENOH_MULTICAST_ENV`] asks for multicast scouting to be off.
124#[cfg(feature = "zenoh")]
125fn multicast_disabled_by_env() -> bool {
126    multicast_disabled_by_value(std::env::var(DORA_ZENOH_MULTICAST_ENV).ok().as_deref())
127}
128
129/// The effective decision for a process that also has its own request.
130///
131/// [`open_zenoh_session_with_listen`] ORs the caller's request with
132/// [`DORA_ZENOH_MULTICAST_ENV`], so a process that has to *forward* its
133/// decision — the daemon, to the nodes it spawns — must OR them the same way.
134/// Forwarding only its own flag drops the environment half, leaving nodes
135/// scouting by multicast in exactly the environments where the variable was
136/// set to stop them.
137#[cfg(feature = "zenoh")]
138pub fn multicast_disabled(requested_off: bool) -> bool {
139    requested_off || multicast_disabled_by_env()
140}
141
142/// Parse a [`DORA_ZENOH_MULTICAST_ENV`] value (`None` when the var is unset).
143///
144/// Split from [`multicast_disabled_by_env`] so it is testable without mutating
145/// the process environment, which is `unsafe` in edition 2024 and racy against
146/// other tests in the same binary.
147#[cfg(feature = "zenoh")]
148fn multicast_disabled_by_value(value: Option<&str>) -> bool {
149    matches!(
150        value.map(|v| v.trim().to_ascii_lowercase()).as_deref(),
151        Some("off" | "0" | "false" | "no")
152    )
153}
154
155/// Split a comma-separated endpoint list env var, ignoring empty entries.
156#[cfg(feature = "zenoh")]
157fn split_endpoints(value: &str) -> impl Iterator<Item = String> + '_ {
158    value
159        .split(',')
160        .map(str::trim)
161        .filter(|s| !s.is_empty())
162        .map(String::from)
163}
164
165/// Assemble a node's listen endpoints from the two env vars the daemon sets.
166///
167/// Split across two variables rather than one comma-separated value for
168/// forward compatibility — see [`DORA_ZENOH_LISTEN_EXTRA_ENV`]. Both are run
169/// through [`split_endpoints`] so a hand-set list in either keeps working, and
170/// so the loopback entry stays first: `listen/endpoints` order is what decides
171/// which locator a same-machine consumer picks, and loopback is the one whose
172/// transport can carry shared memory.
173#[cfg(feature = "zenoh")]
174fn listen_endpoints_from_env(listen: Option<&str>, extra: Option<&str>) -> Vec<String> {
175    listen
176        .into_iter()
177        .chain(extra)
178        .flat_map(split_endpoints)
179        .collect()
180}
181
182/// Path to a JSON5 file whose contents are layered on top of the zenoh config
183/// dora computes.
184///
185/// The additive counterpart to [`ZENOH_CONFIG_PATH_ENV`], which replaces the
186/// whole config — including the per-node connect/listen plan the daemon builds,
187/// so a router named there ends up relaying even same-machine traffic. An
188/// overlay keeps that plan and adds to it, which is what "point this deployment
189/// at my routers" actually means. Setting both is refused rather than merged.
190///
191/// Inherited by spawned nodes (like [`ZENOH_CONFIG_PATH_ENV`]) so one variable
192/// covers a whole process tree, and refused from a descriptor's `env:` for the
193/// same reason: it is deployment wiring, not per-node configuration.
194#[cfg(feature = "zenoh")]
195pub const DORA_ZENOH_CONFIG_OVERLAY_ENV: &str = "DORA_ZENOH_CONFIG_OVERLAY";
196
197/// Operator-supplied zenoh settings to layer onto dora's computed config.
198///
199/// Split into the two endpoint lists, which **merge** with what dora computed,
200/// and everything else, which **replaces** it. Merging is what makes the
201/// overlay additive where it matters: naming a router under
202/// `connect.endpoints` adds a path to it without deleting the direct node links
203/// dora planned, which is exactly the difference from a wholesale
204/// [`ZENOH_CONFIG_PATH_ENV`].
205#[cfg(feature = "zenoh")]
206#[derive(Debug, Default, Clone)]
207pub struct ZenohOverlay {
208    /// Extra peers to dial, appended to dora's `connect/endpoints`.
209    pub connect_endpoints: Vec<String>,
210    /// Extra addresses to bind, appended to dora's `listen/endpoints`.
211    pub listen_endpoints: Vec<String>,
212    /// Every other setting, applied after dora's own inserts.
213    rest: serde_json::Map<String, serde_json::Value>,
214}
215
216#[cfg(feature = "zenoh")]
217impl ZenohOverlay {
218    /// Read the overlay named by [`DORA_ZENOH_CONFIG_OVERLAY_ENV`], if any.
219    ///
220    /// A named file that cannot be read or parsed is a hard error: the operator
221    /// asked for these settings, and a session silently opened without them is
222    /// the kind of "runs but talks to nobody" outcome that takes hours to
223    /// diagnose.
224    pub fn from_env() -> eyre::Result<Option<Self>> {
225        use eyre::Context;
226
227        let Some(path) = std::env::var_os(DORA_ZENOH_CONFIG_OVERLAY_ENV) else {
228            return Ok(None);
229        };
230        let path = std::path::PathBuf::from(path);
231        let contents = std::fs::read_to_string(&path).wrap_err_with(|| {
232            format!(
233                "failed to read the zenoh config overlay at `{}` named by {}",
234                path.display(),
235                DORA_ZENOH_CONFIG_OVERLAY_ENV
236            )
237        })?;
238        Self::parse(&contents)
239            .wrap_err_with(|| format!("invalid zenoh config overlay at `{}`", path.display()))
240            .map(Some)
241    }
242
243    /// Parse an overlay from JSON5 — zenoh's own config dialect, so an operator
244    /// can paste a fragment of a zenoh config file, comments and all.
245    pub fn parse(json5_str: &str) -> eyre::Result<Self> {
246        use eyre::{Context, eyre};
247
248        let value: serde_json::Value = json5::from_str(json5_str)
249            .map_err(|err| eyre!("{err}"))
250            .wrap_err(
251                "overlay must be a JSON5 object of zenoh config keys, e.g. \
252                 `{ connect: { endpoints: [\"tcp/10.0.0.1:7447\"] } }`",
253            )?;
254        let serde_json::Value::Object(mut object) = value else {
255            eyre::bail!("overlay must be a JSON5 object, not a bare value");
256        };
257
258        let mut overlay = Self::default();
259        for (key, endpoints) in [
260            ("connect", &mut overlay.connect_endpoints),
261            ("listen", &mut overlay.listen_endpoints),
262        ] {
263            let Some(serde_json::Value::Object(section)) = object.get_mut(key) else {
264                continue;
265            };
266            // Taken out of `rest` so the merged list survives: re-inserting the
267            // section wholesale afterwards would overwrite it.
268            let Some(value) = section.remove("endpoints") else {
269                continue;
270            };
271            let serde_json::Value::Array(list) = value else {
272                eyre::bail!("`{key}.endpoints` must be an array of endpoint strings");
273            };
274            for entry in list {
275                match entry {
276                    serde_json::Value::String(endpoint) => endpoints.push(endpoint),
277                    other => eyre::bail!(
278                        "`{key}.endpoints` must contain endpoint strings, found `{other}`"
279                    ),
280                }
281            }
282            // An emptied section would otherwise be re-inserted as `{}`.
283            if section.is_empty() {
284                object.remove(key);
285            }
286        }
287        overlay.rest = object;
288        Ok(overlay)
289    }
290
291    /// Apply everything except the endpoint lists, which the caller has already
292    /// merged into its own.
293    ///
294    /// Each setting is inserted at its deepest path, so an overlay touching one
295    /// subkey leaves its siblings alone. A path zenoh rejects is retried one
296    /// level up, because some sections only accept a whole-object write —
297    /// `insert_json5("gateway/south", ..)` is refused where
298    /// `insert_json5("gateway", ..)` is accepted (dora-rs/dora#2721).
299    fn apply(&self, config: &mut zenoh::Config) -> eyre::Result<()> {
300        for (key, value) in &self.rest {
301            insert_overlay_value(config, key, value)?;
302        }
303        Ok(())
304    }
305}
306
307/// Insert one overlay value, descending into objects and falling back to a
308/// whole-object write when a subpath is rejected. See [`ZenohOverlay::apply`].
309#[cfg(feature = "zenoh")]
310fn insert_overlay_value(
311    config: &mut zenoh::Config,
312    path: &str,
313    value: &serde_json::Value,
314) -> eyre::Result<()> {
315    if let serde_json::Value::Object(fields) = value
316        && !fields.is_empty()
317    {
318        let mut written = true;
319        for (field, child) in fields {
320            if insert_overlay_value(config, &format!("{path}/{field}"), child).is_err() {
321                written = false;
322                break;
323            }
324        }
325        if written {
326            return Ok(());
327        }
328        // Falling through re-writes the fields that did land, with the same
329        // values — `value` still contains them — so a partial descent is not
330        // left half-applied.
331    }
332    config
333        .insert_json5(path, &value.to_string())
334        .map_err(|err| eyre::eyre!("failed to apply zenoh config overlay key `{path}`: {err}"))
335}
336
337#[cfg(feature = "zenoh")]
338pub async fn open_zenoh_session(coordinator_addr: Option<IpAddr>) -> eyre::Result<zenoh::Session> {
339    // Nodes and the coordinator have no in-process way to know, so
340    // [`DORA_ZENOH_MULTICAST_ENV`] (honored inside) is their only channel.
341    let (session, _) = open_zenoh_session_with_listen(ZenohSessionParams {
342        coordinator_addr,
343        ..Default::default()
344    })
345    .await?;
346    Ok(session)
347}
348
349/// How a dora process wants its zenoh session wired.
350///
351/// A struct rather than a parameter list because every field is optional and
352/// most callers set one of them: the positional form made
353/// `open_zenoh_session_with_listen(None, None, None, Allowed)` a common sight,
354/// where a misplaced `None` is a silent partition rather than a type error.
355/// [`Default`] gives "a plain peer with whatever the environment says", which
356/// is what nodes and the coordinator want.
357#[cfg(feature = "zenoh")]
358#[derive(Debug, Default, Clone, Copy)]
359pub struct ZenohSessionParams<'a> {
360    /// Coordinator to reach through a zenoh router/peer pair. Unused by every
361    /// in-tree caller today; see the `coordinator_addr` branch below.
362    pub coordinator_addr: Option<IpAddr>,
363    /// Endpoint this session listens on and advertises to its peers, e.g.
364    /// `tcp/127.0.0.1:43217` for a single-machine daemon or `tcp/10.0.2.100:5456`
365    /// for one in a cluster. Verified against `info().locators()` after open;
366    /// see the return value.
367    pub listen_endpoint: Option<&'a str>,
368    /// Shared rendezvous endpoint for daemon-to-daemon discovery when multicast
369    /// isn't available: added to *both* listen and connect endpoints, so the
370    /// first daemon to bind it becomes the gossip hub and the rest fall through
371    /// to connect-only.
372    pub inter_daemon_peer: Option<&'a str>,
373    /// Peers this session dials, in addition to whatever
374    /// [`DORA_ZENOH_CONNECT_ENV`] carries. This is how a deployment wires its
375    /// daemons into an explicit mesh instead of relying on gossip through a
376    /// single rendezvous: every daemon dials every other one, which is the
377    /// clique zenoh 1.9 requires of a peer region.
378    ///
379    /// Operator-supplied, and therefore *authoritative*: naming them is a
380    /// statement that this deployment does not rely on scouting, so they
381    /// replace multicast (see the `#1856` guard below).
382    pub connect_endpoints: &'a [String],
383    /// Peers this session dials that dora *discovered* rather than the operator
384    /// naming them — today, the endpoints the coordinator handed back at
385    /// registration.
386    ///
387    /// Dialed exactly like [`Self::connect_endpoints`], but deliberately
388    /// excluded from the decision to turn multicast scouting off. A discovered
389    /// list can be incomplete or stale in ways an operator-supplied one cannot:
390    /// a peer that has not yet reported its endpoint is missing from it, and a
391    /// peer that died moments ago is still in it until the coordinator notices.
392    /// Letting such a list disable scouting would make a partial answer *worse*
393    /// than no answer — it would strip the fallback that was working — so
394    /// discovery here is strictly additive: it adds links, and multicast stays
395    /// available to cover whatever it missed.
396    pub discovered_connect_endpoints: &'a [String],
397    /// Whether this session may scout by multicast. A request, not a command —
398    /// see the `#1856` guard below.
399    pub multicast: MulticastScouting,
400}
401
402/// Builds the zenoh `connect/endpoints` JSON5 for a coordinator peer.
403///
404/// The peer address is formatted through a [`SocketAddr`] so that IPv6
405/// addresses are bracketed (`tcp/[::1]:5456`), matching zenoh's TCP locator
406/// grammar. Interpolating a bare [`IpAddr`] instead would emit `tcp/::1:5456`
407/// for IPv6 — a malformed locator where the port colon is indistinguishable
408/// from the address colons, which `insert_json5` rejects (#3041). This is the
409/// same bracketing [`reserve_zenoh_endpoint`] already relies on.
410#[cfg(feature = "zenoh")]
411fn coordinator_connect_endpoints(addr: IpAddr) -> String {
412    let peer = SocketAddr::new(addr, 5456);
413    format!(r#"{{ router: ["tcp/[::]:7447"], peer: ["tcp/{peer}"] }}"#)
414}
415
416/// Like [`open_zenoh_session`], but takes the full [`ZenohSessionParams`]: a
417/// listen endpoint to bind and advertise (e.g. `tcp/127.0.0.1:43217`, or a
418/// routable address such as `tcp/10.0.2.100:5456` for a daemon in a cluster),
419/// extra peers to dial, and the multicast request. The daemon uses this so
420/// spawned nodes can connect via `DORA_ZENOH_CONNECT` without multicast
421/// scouting, and so that other daemons can dial it.
422///
423/// `connect_endpoints` are dialed in addition to whatever
424/// [`DORA_ZENOH_CONNECT_ENV`] carries, deduplicated against it. They are
425/// dial-only, which is what distinguishes an explicit mesh (each daemon
426/// listens on its own endpoint and dials its peers') from the rendezvous
427/// below (every daemon both listens on and dials the *same* endpoint).
428///
429/// `inter_daemon_peer` is an optional shared endpoint used as the
430/// rendezvous for daemon-to-daemon discovery when multicast isn't
431/// available. When set, it is added to both `listen/endpoints` and
432/// `connect/endpoints`: the first daemon to bind it serves as the
433/// gossip hub, and the rest fall through to connect-only via
434/// `listen/exit_on_failure: false`. Multicast scouting is disabled in
435/// that mode since we have explicit endpoints. This complements the
436/// per-spawned-node `DORA_ZENOH_CONNECT` fallback from #1778, which
437/// only covers daemon↔node, leaving daemon↔daemon dependent on
438/// multicast — broken in dev containers and many CI environments.
439///
440/// Returns `(session, effective_listen_endpoint)`. The second element is
441/// `Some(ep)` only when `listen_endpoint` was requested, zenoh accepted the
442/// `listen/endpoints` insert, and `session.info().locators()` confirms that
443/// it actually bound. It is `None` if `listen_endpoint` was `None`, the insert
444/// failed, the open path used the `ZENOH_CONFIG_PATH`-from-file branch, or the
445/// configured listener did not bind. Callers must inject the returned endpoint
446/// into peers (e.g. via `DORA_ZENOH_CONNECT`) instead of the value they passed
447/// in, so peers never receive a stale endpoint the listener did not actually
448/// bind (#1856, #1858). The `inter_daemon_peer` is intentionally NOT
449/// part of the returned endpoint — it is cluster-wide configuration
450/// shared by the caller (e.g. `dora cluster up`), not per-daemon
451/// state to advertise back to nodes.
452///
453/// `multicast` lets a caller that establishes every link explicitly opt out of
454/// scouting — see [`DORA_ZENOH_MULTICAST_ENV`], honored in addition to this
455/// argument. It is a request, not a command: it is ignored unless this session
456/// ends up reachable some other way (see the `#1856` guard below).
457#[cfg(feature = "zenoh")]
458pub async fn open_zenoh_session_with_listen(
459    params: ZenohSessionParams<'_>,
460) -> eyre::Result<(zenoh::Session, Option<String>)> {
461    use eyre::{Context, eyre};
462    use tracing::warn;
463
464    let ZenohSessionParams {
465        coordinator_addr,
466        listen_endpoint,
467        inter_daemon_peer,
468        connect_endpoints,
469        discovered_connect_endpoints,
470        multicast,
471    } = params;
472
473    // Source-of-truth for the listener: stays `None` unless we actually
474    // accepted `listen/endpoints` into the config below. Callers use this
475    // (not their requested endpoint) to advertise the listener to peers.
476    let mut effective_listen_endpoint: Option<String> = None;
477
478    let overlay = ZenohOverlay::from_env()?;
479
480    let zenoh_session = match std::env::var(zenoh::Config::DEFAULT_CONFIG_PATH_ENV) {
481        Ok(path) => {
482            if overlay.is_some() {
483                // Merging the two would be guesswork: the file replaces the
484                // config dora computed, so there is no dora-side endpoint list
485                // left for the overlay's to append to, and "append to whatever
486                // the file happened to set" is not a rule anyone could predict.
487                eyre::bail!(
488                    "both {} and {} are set. {} replaces the whole zenoh \
489                     configuration, while {} layers onto the one dora computes — \
490                     keep one. Prefer the overlay: it adds your endpoints \
491                     without discarding the direct node-to-node links the daemon \
492                     plans for this dataflow.",
493                    zenoh::Config::DEFAULT_CONFIG_PATH_ENV,
494                    DORA_ZENOH_CONFIG_OVERLAY_ENV,
495                    zenoh::Config::DEFAULT_CONFIG_PATH_ENV,
496                    DORA_ZENOH_CONFIG_OVERLAY_ENV,
497                );
498            }
499            let zenoh_config = zenoh::Config::from_file(&path)
500                .map_err(|e| eyre!(e))
501                .wrap_err_with(|| format!("failed to read zenoh config from {path}"))?;
502            zenoh::open(zenoh_config)
503                .await
504                .map_err(|e| eyre!(e))
505                .context("failed to open zenoh session")?
506        }
507        Err(std::env::VarError::NotPresent) => {
508            let mut zenoh_config = zenoh::Config::default();
509            // NOTE: we used to set `routing/peer: { mode: "linkstate" }` here so
510            // that peers would relay for each other (e.g. two daemons on separate
511            // networks reaching each other through a public one). In zenoh 1.8 that
512            // worked: its `linkstate_peer` hat derived
513            // `peer_full_linkstate = routing.peer.mode == "linkstate"`. Zenoh 1.9
514            // dropped that hat; its `peer` hat hardcodes `full_linkstate: false`
515            // (release notes, under Bug fixes: "Disable `full_linkstate` in
516            // `peer::Hat::Network`"), so peers no longer relay. The setting became a
517            // silent no-op — `insert_json5` still returns `Ok`, so our own error
518            // branch never fired, and only zenoh's deprecation log hinted at it.
519            // Deleted rather than ported: there is no peer-side equivalent in 1.9.
520            //
521            // Consequences, and why this is not a regression here:
522            //   * Same-machine nodes are all loopback-addressable, so the links
523            //     the dataflow needs are established explicitly via
524            //     `connect/endpoints` below (see `DORA_ZENOH_CONNECT`) instead of
525            //     being left to gossip's best-effort autoconnect.
526            //   * Multi-machine/NAT setups, which is what linkstate was meant to
527            //     serve, supply their own config via `ZENOH_CONFIG_PATH` (handled
528            //     in the branch above) and can put a real router in the path.
529            //
530            // NOTE: we used to set `transport/unicast/lowlatency: true` here (and
531            // `qos/enabled: false` with it, since the low-latency transport is
532            // negotiated without QoS) to skip zenoh's batching/priority queues.
533            // Both are gone, because low-latency cannot fragment: a message has to
534            // fit one batch, and `batch_size` is capped at 64 KiB
535            // (`pub type BatchSize = u16`, so 65535 is the max, not just the
536            // default). Shared memory hid that — an SHM payload travels as a
537            // ~16-byte descriptor and never fragments — but SHM is per-host, so it
538            // cannot negotiate between machines. A >64 KiB message to another host
539            // therefore had *no* working path: the sender writes it with a 4-byte
540            // length prefix and no size check, `put()` returns `Ok`, and the peer
541            // rejects the frame ("Batch len is invalid") — silent loss, with the
542            // publisher believing it succeeded.
543            //
544            // Dropping both is not a latency regression — measured against the old
545            // config (release, `examples/benchmark`), p50 is neutral-to-better
546            // (64 B 65->55 µs, 512 B 66->58 µs, 16 KB 73->63 µs; only 8 B is ~7 µs
547            // worse) and throughput is up (4 KB +51%, 16 KB +41%), since bypassing
548            // batching cost a syscall per message.
549            //
550            // The two settings must be removed *together*: dropping `lowlatency`
551            // while leaving `qos/enabled: false` did cost ~25 µs p50 on small
552            // messages. Restoring QoS recovers it, because the publishers'
553            // `Priority::RealTime` finally takes effect — it was silently inert
554            // while QoS was off. Publishers also still set `express(true)`, which is
555            // what actually carries small-message latency here.
556            //
557            // We rely on zenoh's SHM transport (`transport/shared_memory/enabled`)
558            // being enabled, which is its default — do NOT set it to `false`: the
559            // API keeps working, but SHM buffers silently get serialized as plain
560            // bytes onto the wire (i.e. copied) instead of sent as a ~16-byte
561            // descriptor.
562
563            // Build the connect-endpoint list from three sources:
564            //   1. DORA_ZENOH_CONNECT env var — daemon-bootstrapped local
565            //      discovery for spawned nodes (#1778).
566            //   2. `connect_endpoints` — peers this process was told to dial,
567            //      i.e. the explicit daemon↔daemon mesh (`--zenoh-connect`).
568            //      Unlike (3) this is dial-only: a mesh member listens on its
569            //      own advertised endpoint, not on its peers'.
570            //   3. `inter_daemon_peer` — shared rendezvous for daemon-to-
571            //      daemon discovery (extends #1778 to the daemon↔daemon
572            //      hop). One daemon binds it as a listener, others connect
573            //      and gossip-discover their peers via it.
574            //   4. `discovered_connect_endpoints` — peers the coordinator
575            //      reported. Dialed like the rest, but see below: because a
576            //      discovered list can be incomplete or stale, it alone does
577            //      not disable scouting.
578            // Setting any of the *operator-supplied* ones disables multicast
579            // scouting, so we don't end up with mixed discovery modes.
580            let mut connect_eps: Vec<String> = Vec::new();
581            if let Ok(eps) = std::env::var(DORA_ZENOH_CONNECT_ENV) {
582                connect_eps.extend(split_endpoints(&eps));
583            }
584            connect_eps.extend(connect_endpoints.iter().cloned());
585            // Everything appended from here on is dialed but does not, on its
586            // own, justify dropping multicast — see `discovered_connect_endpoints`.
587            let authoritative_connect_eps = connect_eps.len();
588            // Discovered endpoints are checked here as well as by whoever handed
589            // them over. The coordinator validates what daemons report to it, so
590            // this is not the only guard — but it is the one that sits where the
591            // value is *used*, which keeps the property true for any future
592            // source of discovered endpoints and for a coordinator that is
593            // itself wrong. Operator-supplied endpoints are deliberately not
594            // filtered: someone who typed an endpoint on the command line is
595            // owed a zenoh error about it, not silence.
596            connect_eps.extend(discovered_connect_endpoints.iter().filter_map(|ep| {
597                match validate_zenoh_endpoint(ep) {
598                    Ok(()) => Some(ep.clone()),
599                    Err(err) => {
600                        warn!("ignoring discovered zenoh endpoint: {err}");
601                        None
602                    }
603                }
604            }));
605            if let Some(peer) = inter_daemon_peer {
606                connect_eps.push(peer.to_string());
607            }
608            if let Some(overlay) = &overlay {
609                connect_eps.extend(overlay.connect_endpoints.iter().cloned());
610            }
611            // A duplicate dial is not fatal, but it is a wasted connection
612            // attempt per duplicate and, when the peer is unreachable, a
613            // second retry loop against it — which is exactly what keeps the
614            // net runtime busy (#2776). Callers can legitimately overlap:
615            // `--zenoh-connect` may name the same endpoint the environment
616            // already carries. `retain` over a seen-set rather than `dedup`,
617            // which only collapses *adjacent* equals and would leave the
618            // env/param/rendezvous interleaving untouched.
619            let mut seen_connect = std::collections::HashSet::new();
620            // Counted before dedup: `retain` only ever removes later duplicates
621            // of an earlier entry, and the authoritative entries come first, so
622            // "were there any" is unaffected by it.
623            let has_authoritative_connect = authoritative_connect_eps > 0
624                || inter_daemon_peer.is_some()
625                || overlay
626                    .as_ref()
627                    .is_some_and(|o| !o.connect_endpoints.is_empty());
628            connect_eps.retain(|ep| seen_connect.insert(ep.clone()));
629            let mut connect_inserted = false;
630            if !connect_eps.is_empty() {
631                // Serialized, not interpolated. These strings are no longer all
632                // operator-supplied: the coordinator hands over endpoints that
633                // other daemons reported, so a `"` in one would otherwise end
634                // the JSON5 string and either break the whole array — costing
635                // this session every connect endpoint, legitimate ones included
636                // — or append endpoints of someone else's choosing to the dial
637                // list. `endpoint_array_json` escapes; `validate_zenoh_endpoint`
638                // rejects such a value on ingest. Both, deliberately.
639                let json = endpoint_array_json(&connect_eps);
640                match zenoh_config.insert_json5("connect/endpoints", &json) {
641                    Ok(()) => connect_inserted = true,
642                    Err(err) => {
643                        warn!(
644                            "failed to set zenoh connect/endpoints to {json} ({err}); leaving multicast scouting enabled as fallback"
645                        );
646                    }
647                }
648            }
649            // Track whether listen/endpoints was accepted into THIS config.
650            // We don't promote it to `effective_listen_endpoint` until the
651            // configured open succeeds — the fallback default-config path
652            // below has no listener and must not advertise one (#1856).
653            // We only track the caller's own `listen_endpoint` (the per-daemon
654            // listener that gets advertised to spawned nodes), NOT
655            // `inter_daemon_peer` which is cluster-wide config — daemons that
656            // bind it act as the rendezvous, but advertising it back to nodes
657            // would be wrong (nodes would try to reach it through what may be a
658            // remote address, defeating the loopback shortcut) — and not the
659            // env-supplied node listeners either, which the daemon planned and
660            // already knows.
661            let mut listen_inserted_into_configured: Option<String> = None;
662            // Any accepted listener, including the cluster-wide rendezvous that
663            // is deliberately absent from `listen_inserted_into_configured`.
664            // Reachability, not advertisability, is what the #1856 guard needs.
665            let mut listen_configured = false;
666
667            // Build the listen-endpoint list (loopback for spawned nodes +
668            // optional inter-daemon rendezvous). With multiple entries,
669            // zenoh binds whichever ones it can; `listen/exit_on_failure:
670            // false` (set below when any listener is configured) lets the
671            // daemon proceed even if some don't bind — e.g. the second
672            // daemon to start on the same host with the same rendezvous
673            // port falls through to connect-only.
674            // A spawned node gets its listeners from the daemon via
675            // `DORA_ZENOH_LISTEN` (the daemon itself passes `listen_endpoint`
676            // directly). Without a known listener a node cannot be dialled, and
677            // since zenoh 1.9 peers do not relay, a consumer that cannot dial its
678            // producer never receives its data at all.
679            //
680            // A node with a consumer on another machine listens both on
681            // loopback (for its same-machine consumers, whose transport can
682            // then carry shared memory) and on a routable address (for the
683            // remote one). The two arrive in *separate* variables so an older
684            // node binary, which treats `DORA_ZENOH_LISTEN` as a single
685            // locator, still gets a valid one — see the doc on
686            // `DORA_ZENOH_LISTEN_EXTRA_ENV`. `split_endpoints` is applied to
687            // both so a hand-set list in either keeps working.
688            let env_listen_endpoints = listen_endpoints_from_env(
689                std::env::var(DORA_ZENOH_LISTEN_ENV).ok().as_deref(),
690                std::env::var(DORA_ZENOH_LISTEN_EXTRA_ENV).ok().as_deref(),
691            );
692
693            let mut listen_eps: Vec<String> = Vec::new();
694            if let Some(ep) = listen_endpoint {
695                listen_eps.push(ep.to_string());
696            }
697            listen_eps.extend(env_listen_endpoints.iter().cloned());
698            if let Some(peer) = inter_daemon_peer {
699                listen_eps.push(peer.to_string());
700            }
701            if let Some(overlay) = &overlay {
702                listen_eps.extend(overlay.listen_endpoints.iter().cloned());
703            }
704            if !listen_eps.is_empty() {
705                let json = endpoint_array_json(&listen_eps);
706                let listen_inserted = match zenoh_config.insert_json5("listen/endpoints", &json) {
707                    Ok(()) => {
708                        listen_inserted_into_configured = listen_endpoint.map(String::from);
709                        listen_configured = true;
710                        true
711                    }
712                    Err(err) => {
713                        warn!("failed to set zenoh listen/endpoints to {json}: {err}");
714                        false
715                    }
716                };
717                // Tolerate a race between OS port reservation and zenoh's
718                // own bind, AND the multi-daemon-same-rendezvous case where
719                // only one daemon wins the bind. The connect side still
720                // works, and child nodes get a clear error rather than the
721                // daemon exiting.
722                if listen_inserted
723                    && let Err(err) = zenoh_config.insert_json5("listen/exit_on_failure", "false")
724                {
725                    warn!("failed to set zenoh listen/exit_on_failure: {err}");
726                }
727            }
728
729            // Drop multicast scouting only once this session is reachable some
730            // other way — otherwise it has no endpoints to dial and no way to
731            // be found, which is the silent partition #1856 exists to prevent.
732            //
733            // Two things make it reachable. `connect_inserted`: explicit
734            // endpoints replaced scouting (the pre-existing rule). Or an
735            // accepted listener plus a caller that asked to stop scouting —
736            // `dora run` without dynamic nodes, or `--zenoh-no-multicast` on a
737            // network where the scouting bind itself fails; a listener means
738            // peers that hold the endpoint can dial in.
739            //
740            // Deliberately *not* honoring the request when neither holds: the
741            // reservation-failure paths in `build_daemon` log "falling back to
742            // multicast scouting only" and mean it. Treating the request as
743            // absolute would disarm that recovery and strand the daemon.
744            let requested_off =
745                multicast_disabled(matches!(multicast, MulticastScouting::Disabled));
746            // Computed once and reused by the listener-did-not-bind diagnostic
747            // below, which used to test `connect_inserted` alone — a proxy that
748            // disagreed with this in both directions, telling a session with
749            // scouting off that it was "falling back to multicast scouting"
750            // (and vice versa). That is the same class of misdirection #2762
751            // fixed once already.
752            let multicast_scouting_off = (connect_inserted && has_authoritative_connect)
753                || (requested_off && listen_configured);
754            if multicast_scouting_off
755                && let Err(err) = zenoh_config.insert_json5("scouting/multicast/enabled", "false")
756            {
757                warn!("failed to disable zenoh scouting/multicast: {err}");
758            }
759
760            if let Some(addr) = coordinator_addr
761                && let Err(err) = zenoh_config
762                    .insert_json5("connect/endpoints", &coordinator_connect_endpoints(addr))
763            {
764                warn!("failed to set zenoh connect/endpoints for coordinator {addr}: {err}");
765            }
766            // Last, so an operator's setting wins over dora's default for the
767            // same key. The endpoint lists are already merged above rather than
768            // overwritten here — that is what makes the overlay additive.
769            if let Some(overlay) = &overlay {
770                overlay.apply(&mut zenoh_config)?;
771            }
772            match zenoh::open(zenoh_config).await {
773                Ok(zenoh_session) => {
774                    // Verify the listener actually bound. `zenoh::open` returning
775                    // Ok is necessary but not sufficient — with
776                    // `listen/exit_on_failure: false` (set above), zenoh tolerates
777                    // a silently-failed listen bind. The most plausible cause is
778                    // the race between `reserve_loopback_zenoh_endpoint` dropping
779                    // its reservation socket and zenoh's own bind, during which
780                    // some other process could grab the port. Trusting `Ok` here
781                    // would advertise an endpoint nothing is listening on, and
782                    // spawned nodes would fail their `DORA_ZENOH_CONNECT` connect
783                    // attempts (#1858).
784                    //
785                    // `info().locators()` is the zenoh-canonical "what actually
786                    // bound" query (unstable API gated behind the workspace
787                    // `unstable` feature, already enabled in the root Cargo.toml
788                    // zenoh dependency).
789                    // Verify every endpoint we asked for, but only ever
790                    // *return* the caller's own: the env-supplied ones belong
791                    // to a node whose daemon planned them and already knows
792                    // them, so there they are a diagnostic, not a value to
793                    // propagate.
794                    let mut verify: Vec<&str> = listen_inserted_into_configured
795                        .iter()
796                        .map(String::as_str)
797                        .collect();
798                    if listen_configured {
799                        verify.extend(env_listen_endpoints.iter().map(String::as_str));
800                    }
801                    if !verify.is_empty() {
802                        let bound_locators: Vec<String> = zenoh_session
803                            .info()
804                            .locators()
805                            .await
806                            .into_iter()
807                            .map(|l| l.as_str().to_string())
808                            .collect();
809                        // Strip zenoh's endpoint-string separators before
810                        // comparing. Per `zenoh-protocol::core::endpoint`,
811                        // `?` separates metadata and `#` separates config
812                        // (e.g. `tcp/127.0.0.1:43217?prio=high#iface=lo0`).
813                        // `Locator::from(EndPoint)` already truncates `#`,
814                        // but a `?`-metadata suffix would survive into
815                        // `info().locators()`'s output. Strip both, then
816                        // exact-match — substring `contains` would
817                        // false-positive on port-prefix collisions (e.g.
818                        // requested `:5000` matching bound `:50000`), which
819                        // is exactly the mismatch this check exists to
820                        // catch. NOTE: the comparison is against the
821                        // requested string verbatim, so a caller must request
822                        // the same canonical `tcp/<addr>:<port>` form that
823                        // zenoh reports back. `reserve_zenoh_endpoint` emits
824                        // that form, but only for a *concrete* address: a
825                        // wildcard request (`tcp/0.0.0.0:<port>`) can never
826                        // match, because zenoh binds every interface and
827                        // reports the concrete one. Callers must therefore
828                        // reject wildcards up front (the daemon does) rather
829                        // than reach this check, which would read the mismatch
830                        // as "the listener did not bind" and silently fall back
831                        // to multicast scouting.
832                        for requested in verify {
833                            let bound = bound_locators
834                                .iter()
835                                .any(|l| l.split(['?', '#']).next() == Some(requested));
836                            if bound {
837                                if listen_inserted_into_configured.as_deref() == Some(requested) {
838                                    effective_listen_endpoint = Some(requested.to_string());
839                                }
840                            } else if multicast_scouting_off {
841                                // Scouting is off for this session, so there is
842                                // NO discovery fallback:
843                                // peers already told to dial `{requested}` (e.g. via
844                                // the per-node `DORA_ZENOH_CONNECT` plan from #2716)
845                                // cannot reach this now-listener-less session, and it
846                                // cannot be scouted either — that edge is silently
847                                // partitioned. Do not claim "multicast scouting only"
848                                // here; that fallback does not exist in this mode and
849                                // the old message pointed debuggers the wrong way
850                                // (#2762).
851                                warn!(
852                                    "zenoh session opened but listener for `{requested}` \
853                                     did not bind (actually bound: {bound_locators:?}); \
854                                     multicast scouting is disabled for this session \
855                                     (explicit connect endpoints are set), so peers told \
856                                     to dial `{requested}` have no fallback path to reach \
857                                     it (#2762)"
858                                );
859                            } else {
860                                warn!(
861                                    "zenoh session opened but listener for `{requested}` \
862                                     did not bind (actually bound: {bound_locators:?}); \
863                                     falling back to multicast scouting for discovery"
864                                );
865                            }
866                        }
867                    }
868                    zenoh_session
869                }
870                Err(err) => {
871                    warn!(
872                        "failed to open tuned zenoh session ({err}), retrying with default config"
873                    );
874                    // Default fallback has no listener; `effective_listen_endpoint`
875                    // stays `None` so peers don't try to reach a bind that isn't
876                    // there (#1856).
877                    let zenoh_config = zenoh::Config::default();
878                    zenoh::open(zenoh_config)
879                        .await
880                        .map_err(|e| eyre!(e))
881                        .context("failed to open zenoh session")?
882                }
883            }
884        }
885        Err(std::env::VarError::NotUnicode(_)) => eyre::bail!(
886            "{} env variable is not valid unicode",
887            zenoh::Config::DEFAULT_CONFIG_PATH_ENV
888        ),
889    };
890    Ok((zenoh_session, effective_listen_endpoint))
891}
892
893/// Render endpoints as a JSON array for `insert_json5`, escaping each one.
894///
895/// `serde_json` output is valid JSON5, and escaping is what keeps a hostile or
896/// merely malformed endpoint from reshaping the array around it.
897#[cfg(feature = "zenoh")]
898fn endpoint_array_json(endpoints: &[String]) -> String {
899    serde_json::Value::Array(
900        endpoints
901            .iter()
902            .map(|e| serde_json::Value::String(e.clone()))
903            .collect(),
904    )
905    .to_string()
906}
907
908/// Whether `endpoint` is safe to accept from the network as a zenoh locator.
909///
910/// Applied to the endpoint a daemon reports to the coordinator, which is the
911/// first value to reach dora's zenoh config from off-machine — everything
912/// before it came from the operator's own command line. The coordinator hands
913/// it to every daemon that registers later, so an unchecked value would travel
914/// straight into their `connect/endpoints`.
915///
916/// Called on every hop that value takes: both ways a daemon can report one
917/// (`accept_reported_zenoh_endpoint` in the coordinator covers the registration
918/// *and* the later correction, which exists to replace it), and again where a
919/// receiving daemon merges the result into its own dial list. Validating only
920/// at the first hop would leave the property depending on which path ran last.
921///
922/// Deliberately a charset check rather than a locator parse: zenoh's locator
923/// grammar covers protocols dora does not model (`quic`, `unixsock-stream`,
924/// metadata after `?`, config after `#`), and rejecting a valid endpoint would
925/// break a working deployment. What matters is that nothing here can terminate
926/// a JSON string or escape it — so quotes, backslashes and control characters
927/// are refused, along with anything long enough to be worth truncating.
928pub fn validate_zenoh_endpoint(endpoint: &str) -> Result<(), String> {
929    const MAX_LEN: usize = 256;
930    if endpoint.is_empty() {
931        return Err("zenoh endpoint must not be empty".to_string());
932    }
933    if endpoint.len() > MAX_LEN {
934        return Err(format!(
935            "zenoh endpoint is {} bytes, over the {MAX_LEN}-byte limit",
936            endpoint.len()
937        ));
938    }
939    if let Some(bad) = endpoint
940        .chars()
941        .find(|c| !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | ':' | '/' | '-' | '_' | '[' | ']' | '%' | '?' | '#' | '=' | '&' | '+' | '*' | ','))
942    {
943        return Err(format!(
944            "zenoh endpoint contains the disallowed character {bad:?}"
945        ));
946    }
947    Ok(())
948}
949
950/// Default TCP port for a daemon's inter-daemon zenoh listener.
951///
952/// Only used when a deployment *names* the port — `--zenoh-listen <IP>` alone
953/// still reserves an ephemeral one. An explicit mesh needs a port its peers can
954/// predict, since they must dial the endpoint before the daemon has told anyone
955/// what it bound. 5456 is the port dora already uses for a zenoh peer in
956/// [`coordinator_connect_endpoints`] and in every deployment doc example.
957pub const DORA_ZENOH_LISTEN_PORT_DEFAULT: u16 = 5456;
958
959/// Format `addr:port` as a zenoh TCP endpoint string.
960///
961/// The address goes through [`SocketAddr`], whose `Display` brackets IPv6 —
962/// which is also zenoh's locator grammar (`tcp/[::1]:7447`). Interpolating a
963/// bare [`IpAddr`] instead would emit `tcp/::1:7447`, where the port colon is
964/// indistinguishable from the address colons and `insert_json5` rejects the
965/// result (#3041).
966///
967/// Unlike [`reserve_zenoh_endpoint`] this binds nothing, so there is no
968/// reserve→bind window for another process to slip into: a named port is
969/// either free when zenoh binds it or it is not, and the
970/// `info().locators()` check in [`open_zenoh_session_with_listen`] tells the
971/// caller which.
972pub fn zenoh_endpoint(addr: IpAddr, port: u16) -> String {
973    format!("tcp/{}", SocketAddr::new(addr, port))
974}
975
976/// Reserve an unused TCP port on `bind` for use as a zenoh listen endpoint.
977/// Returns a string suitable for the zenoh `listen/endpoints` config
978/// (e.g. `tcp/127.0.0.1:43217`, or `tcp/[::1]:43217` for IPv6).
979///
980/// The bind address matters beyond which interface accepts connections:
981/// zenoh advertises the address it bound as its locator, and remote peers
982/// dial exactly that. A daemon that binds loopback is therefore not merely
983/// unreachable from another machine — it actively tells remote daemons to
984/// dial `127.0.0.1`, i.e. their own loopback, where they find nothing (or an
985/// unrelated local process). Since zenoh 1.9 peers do not relay for each
986/// other, such a pair has no fallback path and is silently dead. Multi-machine
987/// deployments must therefore reserve on an address the other machines can
988/// actually reach; see [`reserve_loopback_zenoh_endpoint`] for the
989/// single-machine case.
990///
991/// There is a small race window between dropping the reservation socket
992/// and zenoh's own bind. `open_zenoh_session_with_listen` defends against
993/// it on two layers:
994///
995/// 1. `listen/exit_on_failure: false` keeps the daemon alive if zenoh's
996///    bind silently fails inside the race window.
997/// 2. After `zenoh::open`, the helper queries `session.info().locators()`
998///    and only advertises the returned endpoint when our requested
999///    `addr:port` is actually in the bound-locator list. If the port was
1000///    grabbed by another process, the returned `effective_listen_endpoint`
1001///    is `None` and callers fall back to multicast scouting instead of
1002///    advertising a phantom endpoint (#1858).
1003///
1004/// In practice the OS keeps allocating fresh ephemeral ports each call,
1005/// so collisions remain vanishingly rare.
1006///
1007/// Note that the locator check in (2) compares the requested `addr:port`
1008/// literally, so reserving on the unspecified address (`0.0.0.0`) will not
1009/// match the concrete interface address zenoh reports back. Pass the address
1010/// you want advertised, not a wildcard.
1011pub fn reserve_zenoh_endpoint(bind: IpAddr) -> std::io::Result<String> {
1012    let listener = std::net::TcpListener::bind((bind, 0))?;
1013    let port = listener.local_addr()?.port();
1014    drop(listener);
1015    Ok(zenoh_endpoint(bind, port))
1016}
1017
1018/// Loopback case of [`reserve_zenoh_endpoint`] — the right choice when every
1019/// zenoh peer that needs this listener is on the same host, which is what a
1020/// single-machine deployment looks like.
1021pub fn reserve_loopback_zenoh_endpoint() -> std::io::Result<String> {
1022    reserve_zenoh_endpoint(LOCALHOST)
1023}
1024
1025/// Pick the address a daemon's zenoh listener should bind so that the other
1026/// daemons in the deployment can dial it.
1027///
1028/// Returns [`LOCALHOST`] when the coordinator is itself local: everything that
1029/// needs this listener is then on this host, and binding loopback keeps the
1030/// daemon's zenoh unreachable from the network — which is the status quo for
1031/// single-machine users, and worth preserving.
1032///
1033/// Otherwise the daemon is part of a multi-machine deployment, and we return
1034/// the local address the kernel would use to reach `coordinator_addr`. That is
1035/// the correct choice without anyone configuring anything: on a LAN it is the
1036/// LAN address, and on a mesh VPN (Tailscale/WireGuard) — where the
1037/// coordinator is reached over the tunnel — it is the tunnel address, which is
1038/// exactly the one remote daemons can dial. A multi-homed host that picks the
1039/// wrong interface can be overridden explicitly by the caller.
1040///
1041/// The lookup `connect()`s a UDP socket, which sends no packets: it only asks
1042/// the routing table which source address applies. If there is no route (or
1043/// the lookup otherwise fails) we fall back to [`LOCALHOST`] rather than
1044/// guessing, since a wrong address is advertised to peers and fails silently,
1045/// whereas loopback at least keeps same-host behavior working.
1046pub fn zenoh_bind_address_for(coordinator_addr: SocketAddr) -> IpAddr {
1047    if coordinator_addr.ip().is_loopback() {
1048        return LOCALHOST;
1049    }
1050    local_address_toward(coordinator_addr).unwrap_or(LOCALHOST)
1051}
1052
1053/// Where a daemon's zenoh listener binds: an address, and optionally the port.
1054///
1055/// Both forms are accepted from `--zenoh-listen`:
1056///
1057/// * `10.0.2.100` — the port is left to the OS. Peers learn the resulting
1058///   endpoint by gossip, so this suits a deployment with a rendezvous
1059///   (`--zenoh-peer`) or working multicast.
1060/// * `10.0.2.100:5456`, `[fd7a:1::2]:5456` — a named port, which peers can dial
1061///   without having discovered it first. This is what an explicit mesh needs:
1062///   every daemon's endpoint has to be known before any of them has announced
1063///   anything.
1064///
1065/// IPv6 must be bracketed when naming a port, because `fd7a:1::2:5456` is
1066/// itself a valid IPv6 address and is read as one.
1067#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1068pub struct ZenohListen {
1069    /// Address to bind, which is also the address advertised to peers.
1070    pub addr: IpAddr,
1071    /// Port to bind, or `None` to let the OS pick one.
1072    pub port: Option<u16>,
1073}
1074
1075impl ZenohListen {
1076    /// The endpoint to request, reserving an ephemeral port if none was named.
1077    ///
1078    /// A named port skips the reservation entirely: it has no reserve→bind
1079    /// window for another process to slip into, and zenoh's own bind is the
1080    /// only claim on it.
1081    pub fn endpoint(&self) -> std::io::Result<String> {
1082        match self.port {
1083            Some(port) => Ok(zenoh_endpoint(self.addr, port)),
1084            None => reserve_zenoh_endpoint(self.addr),
1085        }
1086    }
1087}
1088
1089impl std::fmt::Display for ZenohListen {
1090    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1091        match self.port {
1092            Some(port) => write!(f, "{}", SocketAddr::new(self.addr, port)),
1093            None => write!(f, "{}", self.addr),
1094        }
1095    }
1096}
1097
1098impl std::str::FromStr for ZenohListen {
1099    type Err = String;
1100
1101    fn from_str(s: &str) -> Result<Self, Self::Err> {
1102        // `SocketAddr` first: `10.0.2.100:5456` is not an `IpAddr`, while a
1103        // bare `::1` is not a `SocketAddr`, so neither form is stolen by the
1104        // other. The one genuine ambiguity — unbracketed IPv6 with a port —
1105        // resolves to "address", which is why the docs above insist on
1106        // brackets.
1107        let listen = if let Ok(socket) = s.parse::<SocketAddr>() {
1108            Self {
1109                addr: socket.ip(),
1110                port: Some(socket.port()),
1111            }
1112        } else if let Ok(addr) = s.parse::<IpAddr>() {
1113            Self { addr, port: None }
1114        } else {
1115            return Err(format!(
1116                "`{s}` is neither an IP address (`10.0.2.100`) nor an address \
1117                 with a port (`10.0.2.100:5456`, `[fd7a:1::2]:5456`)"
1118            ));
1119        };
1120        if listen.port == Some(0) {
1121            // Port 0 binds an ephemeral port, which is fine, but the endpoint
1122            // we advertise would still say `:0` — no peer could dial it, and
1123            // the `info().locators()` check would read the mismatch as "the
1124            // listener did not bind". Omitting the port asks for the same thing
1125            // and reserves a concrete one to advertise.
1126            return Err(format!(
1127                "`{s}` names port 0; omit the port to let the OS pick one \
1128                 (dora then advertises the concrete port it reserved)"
1129            ));
1130        }
1131        validate_zenoh_listen(listen.addr).map_err(|err| format!("{err}"))?;
1132        Ok(listen)
1133    }
1134}
1135
1136/// Reject a zenoh listen address that cannot be advertised to peers.
1137///
1138/// Only the wildcard is rejected here, and only because it is *structurally*
1139/// unadvertisable: zenoh would bind every interface but report a concrete
1140/// locator, so the listener verification in [`open_zenoh_session_with_listen`]
1141/// could never match it, and the daemon would silently stop advertising an
1142/// endpoint to its nodes. Whether a concrete address actually exists on this
1143/// host is not knowable here — that surfaces as a bind error from
1144/// [`reserve_zenoh_endpoint`], which callers must treat as fatal when the
1145/// operator named the address explicitly.
1146pub fn validate_zenoh_listen(bind: IpAddr) -> eyre::Result<()> {
1147    if bind.is_unspecified() {
1148        eyre::bail!(
1149            "zenoh listen address must be concrete, not the wildcard `{bind}`. \
1150             Zenoh advertises the address it binds and remote daemons dial exactly \
1151             that, so a wildcard has nothing to advertise. Pass the address other \
1152             daemons should use to reach this host (e.g. its LAN or VPN address)."
1153        );
1154    }
1155    Ok(())
1156}
1157
1158/// Whether a source address the routing table handed back can be advertised to
1159/// peers as a locator.
1160///
1161/// The unspecified address is not a real source, and a loopback source for a
1162/// *remote* target means the routing table told us nothing usable (there is no
1163/// route, or the target resolved back to this host). Advertising either would
1164/// point peers at nothing, so both mean "we learned nothing" and the caller
1165/// should fall back rather than guess.
1166fn usable_source(local: IpAddr) -> Option<IpAddr> {
1167    if local.is_unspecified() || local.is_loopback() {
1168        return None;
1169    }
1170    Some(local)
1171}
1172
1173fn local_address_toward(target: SocketAddr) -> Option<IpAddr> {
1174    // Bind the wildcard of the same family as the target, then `connect` to
1175    // consult the routing table. UDP `connect` is local-only: no traffic.
1176    let bind: SocketAddr = if target.is_ipv4() {
1177        (Ipv4Addr::UNSPECIFIED, 0).into()
1178    } else {
1179        (std::net::Ipv6Addr::UNSPECIFIED, 0).into()
1180    };
1181    let socket = std::net::UdpSocket::bind(bind).ok()?;
1182    socket.connect(target).ok()?;
1183    usable_source(socket.local_addr().ok()?.ip())
1184}
1185
1186/// Zenoh key for node output data.
1187///
1188/// Payload format: raw Arrow bytes with postcard `Metadata` in the Zenoh
1189/// attachment. This topic is published by nodes and consumed directly by
1190/// downstream nodes (plus debug-inspection subscribers). Daemon control frames
1191/// must not be published here; use [`zenoh_daemon_control_topic`] instead.
1192#[cfg(feature = "zenoh")]
1193pub fn zenoh_output_publish_topic(
1194    dataflow_id: uuid::Uuid,
1195    node_id: &dora_message::id::NodeId,
1196    output_id: &dora_message::id::DataId,
1197) -> String {
1198    let network_id = "default";
1199    format!("dora/{network_id}/{dataflow_id}/output/{node_id}/{output_id}")
1200}
1201
1202/// Hex-encode a `DataId` so it occupies exactly one zenoh key chunk.
1203///
1204/// A `DataId` may legally contain `/` (unlike a `NodeId`), so embedding one
1205/// verbatim as a key segment would spill into extra chunks. Hex is unambiguous
1206/// (`[0-9a-f]`, never `/`) and collision-free. Same helper previously used for
1207/// readiness liveliness keys (#2666).
1208#[cfg(feature = "zenoh")]
1209fn hex_key_segment(id: &dora_message::id::DataId) -> String {
1210    use std::fmt::Write;
1211    let s: &str = id.as_ref();
1212    let mut out = String::with_capacity(s.len() * 2);
1213    for b in s.bytes() {
1214        let _ = write!(out, "{b:02x}");
1215    }
1216    out
1217}
1218
1219/// Zenoh key carrying the Arrow IPC **schema** for an output's data topic.
1220///
1221/// Layout: `dora/{network}/{dataflow}/schema/{node}/{hex(output_id)}`.
1222///
1223/// This lives under a dedicated `schema/` plane — **not** under
1224/// [`zenoh_output_publish_topic`] — so it cannot collide with a nested DataId
1225/// such as `cmd/_schema` (whose data topic would otherwise share a key with
1226/// the schema side-channel for `cmd`), and so wildcard subscribers on the
1227/// data-topic namespace never see schema traffic.
1228///
1229/// The output id is [hex-encoded](hex_key_segment) into a single chunk because
1230/// DataIds may contain `/`. The key has no `@…` verbatim chunks: zenoh-ext
1231/// liveliness tokens are `${remaining:**}/@adv/${entity}/${zid}/${eid}/${meta}`
1232/// and Zenoh verbatim chunks are hermetic — `**` cannot cross them — so a key
1233/// that introduced `@schema` before `/@adv/…` failed `ke_liveliness::parse`
1234/// and flooded WARN logs (#2923). The producer still publishes here through a
1235/// zenoh-ext `AdvancedPublisher` (cache + `publisher_detection`); subscribers
1236/// recover via `AdvancedSubscriber` history.
1237#[cfg(feature = "zenoh")]
1238pub fn zenoh_output_schema_topic(
1239    dataflow_id: uuid::Uuid,
1240    node_id: &dora_message::id::NodeId,
1241    output_id: &dora_message::id::DataId,
1242) -> String {
1243    let network_id = "default";
1244    let output = hex_key_segment(output_id);
1245    format!("dora/{network_id}/{dataflow_id}/schema/{node_id}/{output}")
1246}
1247
1248/// Zenoh key on which consumers acknowledge a producer's startup route-probe
1249/// markers, as a `/@ack` sub-key of [`zenoh_output_publish_topic`].
1250///
1251/// The producer declares one **exact-key** subscriber here per output and the
1252/// consumers of that output publish their acks to the same exact key, with the
1253/// acking consumer's identity in the attachment (never in the key). Exact-key
1254/// matching means `.../cmd/@ack` and `.../cmd/vel/@ack` can never
1255/// cross-deliver even though `cmd` is a chunk-prefix of `cmd/vel` — the
1256/// collision that forced hex-encoded wildcard keys in the earlier
1257/// liveliness-counting design (#2666) cannot arise without wildcards. The
1258/// `@`-prefixed final chunk additionally keeps the key from matching any
1259/// wildcard subscription on the data-topic namespace.
1260#[cfg(feature = "zenoh")]
1261pub fn zenoh_output_ack_topic(
1262    dataflow_id: uuid::Uuid,
1263    node_id: &dora_message::id::NodeId,
1264    output_id: &dora_message::id::DataId,
1265) -> String {
1266    format!(
1267        "{}/@ack",
1268        zenoh_output_publish_topic(dataflow_id, node_id, output_id)
1269    )
1270}
1271
1272/// Zenoh key for control frames associated with a node output.
1273///
1274/// Payload format: postcard `Timestamped<InterDaemonEvent>` with no Zenoh
1275/// attachment. Published by daemons for inter-daemon control (for example
1276/// `OutputClosed`) and by the coordinator for explicit topic injection. Keeping
1277/// this separate from [`zenoh_output_publish_topic`] avoids mixing control frames
1278/// with raw node output payloads on the same key.
1279#[cfg(feature = "zenoh")]
1280pub fn zenoh_daemon_control_topic(
1281    dataflow_id: uuid::Uuid,
1282    node_id: &dora_message::id::NodeId,
1283    output_id: &dora_message::id::DataId,
1284) -> String {
1285    let network_id = "default";
1286    format!("dora/{network_id}/{dataflow_id}/control/{node_id}/{output_id}")
1287}
1288
1289/// Zenoh topic carrying [`InterDaemonEvent::ExtensionMessage`][msg] for one
1290/// extension within one dataflow. Every daemon in the dataflow subscribes.
1291///
1292/// Per-namespace rather than one shared topic, so two extensions in the same
1293/// dataflow never see each other's traffic.
1294///
1295/// [msg]: dora_message::daemon_to_daemon::InterDaemonEvent::ExtensionMessage
1296pub fn dataflow_extension_topic(dataflow_id: &uuid::Uuid, namespace: &str) -> String {
1297    let network_id = "default";
1298    format!("dora/{network_id}/{dataflow_id}/ext/{namespace}")
1299}
1300
1301#[cfg(test)]
1302mod endpoint_validation_tests {
1303    use super::validate_zenoh_endpoint;
1304
1305    #[test]
1306    fn ordinary_locators_are_accepted() {
1307        for ok in [
1308            "tcp/127.0.0.1:7447",
1309            "tcp/10.0.2.100:5456",
1310            "tcp/[fd7a:1::2]:5456",
1311            "udp/192.168.1.1:7447",
1312            "tcp/host.example:7447",
1313            "tcp/10.0.0.1:7447?prio=high",
1314            "tcp/10.0.0.1:7447#iface=eth0",
1315        ] {
1316            assert!(validate_zenoh_endpoint(ok).is_ok(), "rejected `{ok}`");
1317        }
1318    }
1319
1320    /// The reason this validator exists: the endpoint reaches dora's zenoh
1321    /// config from off-machine, and a quote would end the JSON string it is
1322    /// written into — either breaking the whole array (costing that daemon
1323    /// every connect endpoint) or appending endpoints of someone else's
1324    /// choosing to it.
1325    #[test]
1326    fn quotes_and_escapes_are_rejected() {
1327        for bad in [
1328            r#"tcp/1.2.3.4:1"#.to_string() + "\"",
1329            r#"tcp/1.2.3.4:1","tcp/evil:7447"#.to_string(),
1330            r"tcp/1.2.3.4:1\u0022".to_string(),
1331            "tcp/1.2.3.4:1\n".to_string(),
1332            "tcp/1.2.3.4:1 ".to_string(),
1333        ] {
1334            assert!(validate_zenoh_endpoint(&bad).is_err(), "accepted `{bad}`");
1335        }
1336    }
1337
1338    #[test]
1339    fn empty_and_oversized_endpoints_are_rejected() {
1340        assert!(validate_zenoh_endpoint("").is_err());
1341        assert!(validate_zenoh_endpoint(&"a".repeat(257)).is_err());
1342        assert!(validate_zenoh_endpoint(&"a".repeat(256)).is_ok());
1343    }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348    use super::*;
1349
1350    /// The forward-compat contract: the loopback endpoint stays alone in
1351    /// `DORA_ZENOH_LISTEN` so a node built before `DORA_ZENOH_LISTEN_EXTRA`
1352    /// existed — which pushes that value in as a *single* locator — still gets
1353    /// a usable one. A list there would be rejected wholesale by such a node,
1354    /// costing it loopback too and partitioning it from same-machine consumers
1355    /// (dora-rs/dora#2742).
1356    #[cfg(feature = "zenoh")]
1357    #[test]
1358    fn listen_endpoints_keep_loopback_first_and_merge_the_extra_var() {
1359        assert_eq!(
1360            listen_endpoints_from_env(Some("tcp/127.0.0.1:41000"), Some("tcp/10.0.0.2:41001")),
1361            vec![
1362                "tcp/127.0.0.1:41000".to_string(),
1363                "tcp/10.0.0.2:41001".to_string()
1364            ],
1365            "loopback must stay first — order decides which locator a \
1366             same-machine consumer picks, and only loopback carries shared memory"
1367        );
1368    }
1369
1370    #[cfg(feature = "zenoh")]
1371    #[test]
1372    fn listen_endpoints_tolerate_an_absent_or_empty_extra_var() {
1373        assert_eq!(
1374            listen_endpoints_from_env(Some("tcp/127.0.0.1:41000"), None),
1375            vec!["tcp/127.0.0.1:41000".to_string()]
1376        );
1377        assert_eq!(
1378            listen_endpoints_from_env(Some("tcp/127.0.0.1:41000"), Some("")),
1379            vec!["tcp/127.0.0.1:41000".to_string()]
1380        );
1381        assert!(listen_endpoints_from_env(None, None).is_empty());
1382    }
1383
1384    /// A hand-set list in either variable keeps working, so an operator who
1385    /// already scripted a comma-separated `DORA_ZENOH_LISTEN` is not broken by
1386    /// the split.
1387    #[cfg(feature = "zenoh")]
1388    #[test]
1389    fn listen_endpoints_still_accept_a_list_in_either_var() {
1390        assert_eq!(
1391            listen_endpoints_from_env(Some("tcp/127.0.0.1:41000, tcp/10.0.0.2:41001"), None),
1392            vec![
1393                "tcp/127.0.0.1:41000".to_string(),
1394                "tcp/10.0.0.2:41001".to_string()
1395            ]
1396        );
1397    }
1398
1399    #[cfg(feature = "zenoh")]
1400    #[test]
1401    fn multicast_disable_spellings_are_recognized() {
1402        for value in ["off", "0", "false", "no", "OFF", "False", "  off  "] {
1403            assert!(
1404                multicast_disabled_by_value(Some(value)),
1405                "{value:?} should disable multicast scouting"
1406            );
1407        }
1408    }
1409
1410    #[cfg(feature = "zenoh")]
1411    #[test]
1412    fn unset_or_unrecognized_multicast_value_keeps_default() {
1413        // Anything that is not an explicit disable spelling must leave the
1414        // default behaviour: silently dropping discovery because of a typo
1415        // would be a partition with no error to point at (#1856).
1416        assert!(!multicast_disabled_by_value(None));
1417        for value in ["on", "1", "true", "yes", "", "maybe"] {
1418            assert!(
1419                !multicast_disabled_by_value(Some(value)),
1420                "{value:?} must not disable multicast scouting"
1421            );
1422        }
1423    }
1424
1425    /// A caller's own request must survive the fold, whatever the environment
1426    /// says. The environment half is covered by the value tests above; this
1427    /// pins that [`multicast_disabled`] never *weakens* an explicit request —
1428    /// the daemon forwards its result to every node it spawns.
1429    #[cfg(feature = "zenoh")]
1430    #[test]
1431    fn an_explicit_multicast_disable_is_never_lost() {
1432        assert!(multicast_disabled(true));
1433    }
1434
1435    #[test]
1436    fn reserve_loopback_endpoint_returns_loopback_tcp() {
1437        let endpoint = reserve_loopback_zenoh_endpoint().expect("reservation succeeds");
1438        assert!(
1439            endpoint.starts_with("tcp/127.0.0.1:"),
1440            "expected loopback tcp endpoint, got {endpoint}"
1441        );
1442        let port: u16 = endpoint
1443            .rsplit(':')
1444            .next()
1445            .and_then(|p| p.parse().ok())
1446            .expect("endpoint has a numeric port");
1447        assert!(port > 0, "kernel must hand out a non-zero ephemeral port");
1448    }
1449
1450    // A local coordinator means every zenoh peer is on this host, so we must
1451    // keep binding loopback — a single-machine daemon should not start
1452    // listening on the network just because this code path exists.
1453    #[test]
1454    fn local_coordinator_keeps_the_listener_on_loopback() {
1455        for addr in ["127.0.0.1:6013", "[::1]:6013"] {
1456            let addr: SocketAddr = addr.parse().unwrap();
1457            assert_eq!(
1458                zenoh_bind_address_for(addr),
1459                LOCALHOST,
1460                "a loopback coordinator at {addr} must not move the listener off loopback"
1461            );
1462        }
1463    }
1464
1465    // IPv6 endpoints must be bracketed or zenoh cannot parse the port back off
1466    // (`tcp/::1:7447` is ambiguous).
1467    #[test]
1468    fn ipv6_endpoints_are_bracketed() {
1469        match reserve_zenoh_endpoint(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)) {
1470            Ok(endpoint) => assert!(
1471                endpoint.starts_with("tcp/[::1]:"),
1472                "expected a bracketed IPv6 endpoint, got {endpoint}"
1473            ),
1474            // Many CI runners, Docker containers, and locked-down hosts have no
1475            // IPv6 address on `lo`, so binding `::1` fails with EAFNOSUPPORT
1476            // (`Unsupported`) or `AddrNotAvailable`. That is a property of the
1477            // host, not of the endpoint formatting under test, so skip rather
1478            // than fail — the production path never binds `::1` on such hosts.
1479            Err(e)
1480                if matches!(
1481                    e.kind(),
1482                    std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported
1483                ) || e.raw_os_error() == Some(97) => {}
1484            Err(e) => panic!("unexpected error reserving ::1 endpoint: {e}"),
1485        }
1486    }
1487
1488    // The coordinator peer endpoint must bracket IPv6 too, or `insert_json5`
1489    // rejects the malformed locator and the peer connect-endpoint is silently
1490    // dropped (#3041). Pure string formatting — no session is opened.
1491    #[cfg(feature = "zenoh")]
1492    #[test]
1493    fn coordinator_connect_endpoints_bracket_ipv6() {
1494        let v6 = coordinator_connect_endpoints(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST));
1495        assert!(
1496            v6.contains(r#"peer: ["tcp/[::1]:5456"]"#),
1497            "IPv6 coordinator peer must be bracketed, got {v6}"
1498        );
1499        assert!(
1500            !v6.contains("tcp/::1:5456"),
1501            "unbracketed IPv6 peer locator is malformed, got {v6}"
1502        );
1503
1504        let v4 = coordinator_connect_endpoints(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
1505        assert!(
1506            v4.contains(r#"peer: ["tcp/127.0.0.1:5456"]"#),
1507            "IPv4 coordinator peer must be unbracketed, got {v4}"
1508        );
1509    }
1510
1511    // The filter behind the routing lookup, tested directly rather than through
1512    // the runner's routing table (which would make the assertion depend on the
1513    // machine and, for a remote target, be satisfiable by either branch).
1514    #[test]
1515    fn only_concrete_routable_sources_are_advertisable() {
1516        // Real interface addresses are what we want to advertise.
1517        for ok in ["10.0.2.100", "192.168.1.7", "100.64.0.3"] {
1518            let addr: IpAddr = ok.parse().unwrap();
1519            assert_eq!(
1520                usable_source(addr),
1521                Some(addr),
1522                "{ok} is a concrete routable address and must be advertisable"
1523            );
1524        }
1525        // A wildcard is not a source at all, and a loopback source for a remote
1526        // target means the routing table told us nothing — advertising either
1527        // points peers at nothing.
1528        for rejected in ["0.0.0.0", "127.0.0.1", "::", "::1"] {
1529            let addr: IpAddr = rejected.parse().unwrap();
1530            assert_eq!(
1531                usable_source(addr),
1532                None,
1533                "{rejected} must never be advertised to peers as a locator"
1534            );
1535        }
1536    }
1537
1538    // The wildcard cannot be advertised: zenoh binds every interface but reports
1539    // a concrete locator, so the listener check can never match it and the
1540    // daemon would silently stop advertising an endpoint to its nodes —
1541    // reintroducing the gossip race #2716 removed. Reject it up front instead.
1542    #[test]
1543    fn wildcard_zenoh_listen_is_rejected() {
1544        for wildcard in ["0.0.0.0", "::"] {
1545            let addr: IpAddr = wildcard.parse().unwrap();
1546            let err = validate_zenoh_listen(addr)
1547                .expect_err("wildcard listen address must be rejected, not accepted");
1548            assert!(
1549                err.to_string().contains("concrete"),
1550                "error should tell the operator to pass a concrete address, got: {err}"
1551            );
1552        }
1553    }
1554
1555    // An IPv6 endpoint must bracket its address, or the port colon is
1556    // indistinguishable from the address colons and `insert_json5` rejects the
1557    // locator outright (#3041). A named-port endpoint has to match the shape
1558    // `reserve_zenoh_endpoint` produces, because both end up in the same
1559    // `listen/endpoints` list and are compared verbatim against
1560    // `info().locators()` after open.
1561    #[test]
1562    fn zenoh_endpoint_brackets_ipv6_and_matches_the_reserved_shape() {
1563        assert_eq!(
1564            zenoh_endpoint("10.0.2.100".parse().unwrap(), 5456),
1565            "tcp/10.0.2.100:5456"
1566        );
1567        assert_eq!(
1568            zenoh_endpoint("::1".parse().unwrap(), 5456),
1569            "tcp/[::1]:5456"
1570        );
1571
1572        let reserved = reserve_zenoh_endpoint(LOCALHOST).expect("loopback reservation");
1573        let port = reserved
1574            .rsplit_once(':')
1575            .expect("reserved endpoint carries a port")
1576            .1
1577            .parse()
1578            .expect("reserved port is numeric");
1579        assert_eq!(zenoh_endpoint(LOCALHOST, port), reserved);
1580    }
1581
1582    // `--zenoh-listen` takes both forms, and which one was given decides
1583    // whether peers can dial this daemon before it has announced anything.
1584    #[test]
1585    fn zenoh_listen_parses_address_with_and_without_port() {
1586        let addr_only: ZenohListen = "10.0.2.100".parse().unwrap();
1587        assert_eq!(addr_only.addr, "10.0.2.100".parse::<IpAddr>().unwrap());
1588        assert_eq!(addr_only.port, None);
1589
1590        let with_port: ZenohListen = "10.0.2.100:5456".parse().unwrap();
1591        assert_eq!(with_port.port, Some(5456));
1592        assert_eq!(with_port.endpoint().unwrap(), "tcp/10.0.2.100:5456");
1593
1594        // IPv6 needs brackets to carry a port; unbracketed, the trailing group
1595        // is part of the address. Both parse — they just mean different things,
1596        // which is why the flag docs insist on brackets.
1597        let v6_port: ZenohListen = "[fd7a:1::2]:5456".parse().unwrap();
1598        assert_eq!(v6_port.port, Some(5456));
1599        assert_eq!(v6_port.endpoint().unwrap(), "tcp/[fd7a:1::2]:5456");
1600        let v6_bare: ZenohListen = "fd7a:1::2:5456".parse().unwrap();
1601        assert_eq!(v6_bare.port, None);
1602    }
1603
1604    // A wildcard has nothing to advertise, and port 0 would advertise `:0`.
1605    // Both are rejected at parse time so the operator hears about it at the
1606    // command line rather than as a silent partition later.
1607    #[test]
1608    fn zenoh_listen_rejects_unadvertisable_forms() {
1609        for bad in ["0.0.0.0", "0.0.0.0:5456", "::"] {
1610            let err = bad
1611                .parse::<ZenohListen>()
1612                .expect_err("wildcard must be rejected");
1613            assert!(
1614                err.contains("concrete"),
1615                "unexpected error for {bad}: {err}"
1616            );
1617        }
1618        let err = "10.0.2.100:0"
1619            .parse::<ZenohListen>()
1620            .expect_err("port 0 must be rejected");
1621        assert!(err.contains("port 0"), "unexpected error: {err}");
1622
1623        let err = "not-an-address"
1624            .parse::<ZenohListen>()
1625            .expect_err("garbage must be rejected");
1626        assert!(err.contains("neither"), "unexpected error: {err}");
1627    }
1628
1629    // The endpoint lists merge with dora's rather than replacing them — the
1630    // whole difference between an overlay and `ZENOH_CONFIG`. Naming a router
1631    // must add a path to it, not delete the direct node links dora planned.
1632    #[cfg(feature = "zenoh")]
1633    #[test]
1634    fn an_overlay_splits_off_the_endpoint_lists() {
1635        let overlay = ZenohOverlay::parse(
1636            r#"{
1637                // a router of our own, plus a second listen address
1638                connect: { endpoints: ["tcp/10.0.0.1:7447"], timeout_ms: 5000 },
1639                listen: { endpoints: ["tcp/10.0.0.2:7448"] },
1640                scouting: { multicast: { enabled: true } },
1641            }"#,
1642        )
1643        .expect("valid overlay");
1644
1645        assert_eq!(overlay.connect_endpoints, ["tcp/10.0.0.1:7447"]);
1646        assert_eq!(overlay.listen_endpoints, ["tcp/10.0.0.2:7448"]);
1647        // `endpoints` is taken out of the sections so re-applying them cannot
1648        // overwrite the merged list; a section left empty is dropped entirely.
1649        assert!(overlay.rest.contains_key("connect"));
1650        assert!(!overlay.rest.contains_key("listen"));
1651        assert!(overlay.rest.contains_key("scouting"));
1652    }
1653
1654    // An overlay is applied to a real config: the values must land where zenoh
1655    // expects them, and a subkey must not wipe its siblings.
1656    #[cfg(feature = "zenoh")]
1657    #[test]
1658    fn an_overlay_applies_onto_a_zenoh_config() {
1659        let mut config = zenoh::Config::default();
1660        config
1661            .insert_json5("connect/endpoints", r#"["tcp/127.0.0.1:1"]"#)
1662            .expect("dora's own endpoints");
1663
1664        ZenohOverlay::parse(r#"{ connect: { timeout_ms: 5000 }, mode: "peer" }"#)
1665            .expect("valid overlay")
1666            .apply(&mut config)
1667            .expect("overlay applies");
1668
1669        let rendered = config.to_string();
1670        assert!(
1671            rendered.contains("5000"),
1672            "overlay value missing: {rendered}"
1673        );
1674        assert!(
1675            rendered.contains("tcp/127.0.0.1:1"),
1676            "writing a sibling subkey must not wipe dora's endpoints: {rendered}"
1677        );
1678    }
1679
1680    // A malformed overlay must fail loudly: the operator asked for these
1681    // settings, and a session quietly opened without them is the "runs but
1682    // talks to nobody" outcome that takes hours to diagnose.
1683    #[cfg(feature = "zenoh")]
1684    #[test]
1685    fn a_malformed_overlay_is_rejected() {
1686        for (bad, expected) in [
1687            ("not an object", "object"),
1688            (
1689                r#"{ connect: { endpoints: "tcp/10.0.0.1:7447" } }"#,
1690                "array",
1691            ),
1692            (r#"{ listen: { endpoints: [7447] } }"#, "endpoint strings"),
1693        ] {
1694            let err = ZenohOverlay::parse(bad)
1695                .expect_err("must be rejected")
1696                .to_string();
1697            assert!(
1698                err.contains(expected),
1699                "unexpected error for `{bad}`: {err}"
1700            );
1701        }
1702    }
1703
1704    // Concrete addresses pass validation whether or not they exist on this host:
1705    // existence is not knowable here and surfaces as a bind error instead.
1706    #[test]
1707    fn concrete_zenoh_listen_addresses_are_accepted() {
1708        for ok in ["127.0.0.1", "10.0.2.100", "::1"] {
1709            let addr: IpAddr = ok.parse().unwrap();
1710            assert!(
1711                validate_zenoh_listen(addr).is_ok(),
1712                "{ok} is concrete and must pass validation"
1713            );
1714        }
1715    }
1716
1717    // Node raw output and daemon control frames MUST live on distinct Zenoh
1718    // keys: they share neither format nor consumer, and merging them caused the
1719    // #1992 crossover (daemon postcard-decoding node output). Guard the split.
1720    #[cfg(feature = "zenoh")]
1721    #[test]
1722    fn output_and_control_topics_are_distinct() {
1723        use dora_message::id::{DataId, NodeId};
1724
1725        let dataflow_id = uuid::Uuid::nil();
1726        let node = NodeId::from("node".to_string());
1727        let output = DataId::from("out".to_string());
1728
1729        let output_topic = zenoh_output_publish_topic(dataflow_id, &node, &output);
1730        let control_topic = zenoh_daemon_control_topic(dataflow_id, &node, &output);
1731
1732        assert!(
1733            output_topic.contains("/output/"),
1734            "node output key must contain `/output/`, got {output_topic}"
1735        );
1736        assert!(
1737            control_topic.contains("/control/"),
1738            "daemon control key must contain `/control/`, got {control_topic}"
1739        );
1740        assert_ne!(
1741            output_topic, control_topic,
1742            "node output and daemon control must not share a Zenoh key (dora #1992/#2008)"
1743        );
1744    }
1745
1746    // Data, schema, ack, and control keys for the same (node, output) must all
1747    // be distinct: each carries a different payload format for a different
1748    // consumer, and any overlap would cross-deliver frames to a decoder that
1749    // cannot parse them.
1750    #[cfg(feature = "zenoh")]
1751    #[test]
1752    fn per_output_topics_are_distinct() {
1753        use dora_message::id::{DataId, NodeId};
1754
1755        let dataflow_id = uuid::Uuid::nil();
1756        let node = NodeId::from("node".to_string());
1757        let output = DataId::from("out".to_string());
1758
1759        let topics = [
1760            zenoh_output_publish_topic(dataflow_id, &node, &output),
1761            zenoh_output_schema_topic(dataflow_id, &node, &output),
1762            zenoh_output_ack_topic(dataflow_id, &node, &output),
1763            zenoh_daemon_control_topic(dataflow_id, &node, &output),
1764        ];
1765        for (i, a) in topics.iter().enumerate() {
1766            for b in &topics[i + 1..] {
1767                assert_ne!(a, b, "per-output zenoh keys must not overlap");
1768            }
1769        }
1770    }
1771
1772    // zenoh-ext publisher-detection liveliness uses
1773    // `${remaining:**}/@adv/...`. Verbatim (`@…`) chunks are hermetic, so a
1774    // schema key that itself introduced `/@schema` before `/@adv/` made tokens
1775    // unparseable and flooded WARN logs (#2923). Keep the schema key free of
1776    // `@` chunks (AdvancedPublisher + publisher_detection still used).
1777    #[cfg(feature = "zenoh")]
1778    #[test]
1779    fn schema_topic_has_no_verbatim_chunks() {
1780        use dora_message::id::{DataId, NodeId};
1781
1782        let dataflow_id = uuid::Uuid::nil();
1783        let node = NodeId::from("node".to_string());
1784        let output = DataId::from("out".to_string());
1785        let topic = zenoh_output_schema_topic(dataflow_id, &node, &output);
1786
1787        assert!(
1788            topic.contains("/schema/"),
1789            "schema side-channel must live under the dedicated `/schema/` plane, got {topic}"
1790        );
1791        assert!(
1792            !topic.contains("/output/"),
1793            "schema side-channel must not nest under the data-topic `/output/` path, got {topic}"
1794        );
1795        for chunk in topic.split('/') {
1796            assert!(
1797                !chunk.starts_with('@'),
1798                "schema topic chunk `{chunk}` must not be verbatim (`@…`); \
1799                 otherwise zenoh_ext liveliness tokens fail to parse (#2923)"
1800            );
1801        }
1802    }
1803
1804    // `/_schema` nested under the output path collided with a valid DataId
1805    // `cmd/_schema`. The schema plane must stay outside `output/{node}/{data_id}`.
1806    #[cfg(feature = "zenoh")]
1807    #[test]
1808    fn schema_topic_does_not_collide_with_nested_data_id() {
1809        use dora_message::id::{DataId, NodeId};
1810
1811        let dataflow_id = uuid::Uuid::nil();
1812        let node = NodeId::from("node".to_string());
1813        let parent = DataId::from("cmd");
1814        let nested = DataId::from("cmd/_schema");
1815        assert_ne!(
1816            zenoh_output_schema_topic(dataflow_id, &node, &parent),
1817            zenoh_output_publish_topic(dataflow_id, &node, &nested),
1818        );
1819    }
1820
1821    // The ack design relies on exact-key matching instead of wildcards, so an
1822    // output id that is a chunk-prefix of another (`cmd` vs `cmd/vel` — the
1823    // collision that forced hex-encoded keys in the #2666 liveliness design)
1824    // must yield distinct ack keys with no subsumption possible.
1825    #[cfg(feature = "zenoh")]
1826    #[test]
1827    fn ack_topics_of_prefix_outputs_are_distinct() {
1828        use dora_message::id::{DataId, NodeId};
1829
1830        let dataflow_id = uuid::Uuid::nil();
1831        let node = NodeId::from("node".to_string());
1832        let cmd = DataId::from("cmd".to_string());
1833        let cmd_vel = DataId::from("cmd/vel".to_string());
1834
1835        let cmd_ack = zenoh_output_ack_topic(dataflow_id, &node, &cmd);
1836        let cmd_vel_ack = zenoh_output_ack_topic(dataflow_id, &node, &cmd_vel);
1837
1838        assert_ne!(cmd_ack, cmd_vel_ack);
1839        // `cmd`'s ack key ends in `cmd/@ack`; the nested output's key contains
1840        // `cmd/vel/@ack`. Neither is a prefix of the other, so exact-key
1841        // subscribers can never receive the other output's acks.
1842        assert!(cmd_ack.ends_with("/cmd/@ack"));
1843        assert!(cmd_vel_ack.ends_with("/cmd/vel/@ack"));
1844        assert!(!cmd_vel_ack.starts_with(&cmd_ack));
1845        assert!(!cmd_ack.starts_with(&cmd_vel_ack));
1846    }
1847}