Skip to main content

dora_core/
topics.rs

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