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(¶ms, 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(¶ms, 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(¶ms, 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}