Skip to main content

zenkey_fleet/bus/
session.rs

1//! Session setup for un-namespaced observers (RFC 09 §5), and the
2//! session-plus-deployment bundle every bus-facing call runs against.
3
4use std::path::Path;
5use std::time::Duration;
6
7use crate::{Error, Result};
8use zenoh::Session;
9
10/// How long [`open_reporting`] gives `zenoh::open` before calling the
11/// attempt a transport failure (#341).
12///
13/// A **connect deadline of its own**, deliberately not the caller's
14/// `--timeout`. That flag is reply-wait — how long a GET listens for answers
15/// on a session that already exists — and a fleet on a fast LAN legitimately
16/// runs it at half a second, which is not a sane bound on a TLS handshake or
17/// a gossip join. Nor is it the caller's `--for`, which bounds an
18/// observation window. Bringing a transport up is its own act with its own
19/// scale, so it gets its own number, and a caller who disagrees says so
20/// through [`open_reporting_within`].
21///
22/// Ten seconds: long enough that no ordinary open trips it, short enough
23/// that a stalled listener surfaces as a *failure* rather than as a connect
24/// flow that never returns — which is the whole point of
25/// [`OpenFailure::Transport`] existing.
26pub const OPEN_TIMEOUT: Duration = Duration::from_secs(10);
27
28/// A session **and the deployment it is pointed at** — the two halves every
29/// bus-facing entry point in this crate needs, carried together (#218).
30///
31/// The pair used to be threaded positionally through some twenty-five
32/// functions, and one of them — `decode_sample` — had already broken the
33/// order, taking `(store, session, slices, base, …)`. A bundle is not
34/// sugar here: it makes "which base did this call run against?" a question
35/// with one answer per call site instead of one per parameter list.
36///
37/// ## Borrowed, not owned
38///
39/// Both frontends already hold both halves as owned values, in scope, at the
40/// moment they call. zenctl opens one `zenoh::Session` per command and reads
41/// the base off its `Bus`; zengui moves an owned `Session` and `String` into
42/// each `async move` in `services/`, because an `async move` cannot borrow
43/// `&self`. So a `Fleet` is built at the call, borrowed for its duration and
44/// dropped — an owned bundle would clone the base string at every one of
45/// those sites and buy nothing back (`zenoh::Session` is itself refcounted;
46/// a `&str` is cheaper still).
47///
48/// ## What deliberately does *not* take one
49///
50/// The **admin space is base-less by design**: `@/**` is the middleware's
51/// own introspection and sits outside any deployment namespace (RFC 09 §5),
52/// so [`crate::admin_get`], [`crate::routers`], [`crate::storages`],
53/// [`crate::declared_entities`] and [`crate::topology`] keep a bare
54/// `&Session`. [`crate::discover_bases`] likewise: it exists to *find* bases,
55/// so requiring one would be circular. Handing those a `Fleet` would offer a
56/// base the function is obliged to ignore, which is the kind of parameter
57/// that eventually gets used.
58#[derive(Debug, Clone, Copy)]
59pub struct Fleet<'a> {
60    session: &'a Session,
61    base: &'a str,
62}
63
64impl<'a> Fleet<'a> {
65    /// Point a session at a deployment.
66    ///
67    /// An **empty** base is a deployment — the bus-root one, which is the
68    /// RFC v1.6 default — and never means "no base".
69    pub fn new(session: &'a Session, base: &'a str) -> Self {
70        Fleet { session, base }
71    }
72
73    /// The un-namespaced session (RFC 09 §5) every call goes out on.
74    pub fn session(&self) -> &'a Session {
75        self.session
76    }
77
78    /// The deployment base, as the Zenoh **namespace** these keys live under.
79    pub fn base(&self) -> &'a str {
80        self.base
81    }
82
83    /// Compose a base-relative key (RFC 03: keys start at `v1`) into the full
84    /// wire key this un-namespaced session must actually spell.
85    pub fn wire(&self, relative: impl AsRef<str>) -> String {
86        zenkey::grammar::with_base(self.base, relative)
87    }
88}
89
90/// Open a session for a read-only explorer.
91///
92/// RFC 09 §5: debug tools run *without* the session namespace and spell full
93/// keys — "which is also the honest view of what is on the wire". So we never
94/// set `namespace`, and every key this tool prints is the real one.
95///
96/// Scouting defaults to **off**. A bus explorer that multicast-scouts will join
97/// whatever mesh it can find, which is how a throwaway session ends up
98/// contaminating a live fleet; opt in explicitly with `--scouting` when you
99/// mean it.
100pub async fn open(connect: &[String], listen: &[String], scouting: bool) -> Result<Session> {
101    open_with_config(None, connect, listen, Some(scouting)).await
102}
103
104/// Open a session over the user's own zenoh JSON5 config (#122), with the
105/// explorer's three knobs applied **on top** when they were actually given.
106///
107/// The file is what makes a secured bus reachable at all — TLS, QUIC with
108/// certs, usrpwd, anything in zenoh's config space — and passthrough is the
109/// whole scope: no cert flags, no auth prompting, no editing. `None` for a
110/// knob means "not given": endpoints only override the file's when
111/// non-empty, and `scouting: None` leaves the file's choice alone (with no
112/// file it stays the explorer default, off).
113///
114/// One refusal: a file that sets a session `namespace` is rejected with the
115/// pointer — an explorer that stripped keys would be lying about the wire
116/// (RFC 09 §5), and silently unsetting the user's config would be worse.
117pub async fn open_with_config(
118    file: Option<&Path>,
119    connect: &[String],
120    listen: &[String],
121    scouting: Option<bool>,
122) -> Result<Session> {
123    open_reporting(file, connect, listen, scouting)
124        .await
125        .map_err(OpenFailure::into_error)
126}
127
128/// Why a session could not be opened.
129///
130/// The two halves are not interchangeable, and a caller that can answer from
131/// something other than the bus needs to tell them apart (issue #196). A
132/// transport that will not come up — a listener whose port is taken, an
133/// endpoint that cannot be built — leaves whatever is already on disk
134/// perfectly answerable. A config file the user *named* and that does not
135/// parse is their own error, and answering anyway would hide it.
136#[derive(Debug)]
137pub enum OpenFailure {
138    /// The config could not be built: a bad file, or a refused namespace.
139    /// Always an [`Error::Unaskable`] — the user named the file.
140    Config(Error),
141    /// The config was fine; the session could not be brought up. Always an
142    /// [`Error::Bus`].
143    Transport(Error),
144}
145
146impl OpenFailure {
147    pub fn into_error(self) -> Error {
148        match self {
149            OpenFailure::Config(e) | OpenFailure::Transport(e) => e,
150        }
151    }
152}
153
154/// [`open_with_config`], but saying which half failed — and bounded
155/// ([`OPEN_TIMEOUT`]).
156pub async fn open_reporting(
157    file: Option<&Path>,
158    connect: &[String],
159    listen: &[String],
160    scouting: Option<bool>,
161) -> Result<Session, OpenFailure> {
162    open_reporting_within(file, connect, listen, scouting, OPEN_TIMEOUT).await
163}
164
165/// [`open_reporting`] with the connect deadline named by the caller.
166///
167/// **Neither half blocks the runtime, and neither half is unbounded** (#341).
168/// A named config file is read and JSON5-parsed on the blocking pool — it is
169/// a file read, and one on a stalled mount used to park a runtime worker with
170/// nothing to time it out. `zenoh::open` is then raced against `deadline`:
171/// an endpoint that never settles is a **transport** failure, which is the
172/// verdict [`OpenFailure`] exists to distinguish and the one a connect flow
173/// that simply never returned could never reach (RFC 13 §3: a tool that
174/// cannot obtain an observation says so; it does not wait forever in
175/// silence).
176pub async fn open_reporting_within(
177    file: Option<&Path>,
178    connect: &[String],
179    listen: &[String],
180    scouting: Option<bool>,
181    deadline: Duration,
182) -> Result<Session, OpenFailure> {
183    let config = config_off_runtime(file, connect, listen, scouting)
184        .await
185        .map_err(OpenFailure::Config)?;
186    // `async move` because zenoh's builder is `IntoFuture`, not `Future`.
187    opened_within(deadline, async move { zenoh::open(config).await }).await
188}
189
190/// Race one open against its deadline and name which half failed.
191///
192/// Split out from [`open_reporting_within`] for exactly one reason: the
193/// deadline arm is otherwise reachable only with a transport that stalls on
194/// demand, and an untested arm is how "OpenFailure exists to distinguish the
195/// two halves" stayed true on paper while nothing ever reached it (#341).
196async fn opened_within<E: std::fmt::Display>(
197    deadline: Duration,
198    open: impl std::future::Future<Output = std::result::Result<Session, E>>,
199) -> Result<Session, OpenFailure> {
200    match tokio::time::timeout(deadline, open).await {
201        Ok(Ok(session)) => Ok(session),
202        Ok(Err(e)) => Err(OpenFailure::Transport(Error::Bus {
203            op: "failed to open",
204            target: "the Zenoh session".into(),
205            source: e.to_string().into(),
206        })),
207        Err(_) => Err(OpenFailure::Transport(Error::Bus {
208            op: "failed to open",
209            target: "the Zenoh session".into(),
210            source: format!(
211                "did not open within {deadline:?} — the config parsed, so this \
212                 is the transport: an endpoint that never settles, a listener \
213                 that never binds, or a peer that never answers"
214            )
215            .into(),
216        })),
217    }
218}
219
220/// Build the config without holding a runtime thread for a file read.
221///
222/// With no file there is no I/O at all — `Config::default()` plus a few
223/// JSON5 inserts is microseconds of pure CPU, and a `spawn_blocking` hop
224/// would cost more than it saves. With one, the read *and* the JSON5 parse
225/// go to the pool together (`bus/blob/transfer.rs` makes the same call for
226/// the same reason).
227async fn config_off_runtime(
228    file: Option<&Path>,
229    connect: &[String],
230    listen: &[String],
231    scouting: Option<bool>,
232) -> Result<zenoh::Config> {
233    let Some(path) = file else {
234        return build_config(None, connect, listen, scouting);
235    };
236    let path = path.to_path_buf();
237    let connect = connect.to_vec();
238    let listen = listen.to_vec();
239    tokio::task::spawn_blocking(move || build_config(Some(&path), &connect, &listen, scouting))
240        .await
241        // A join failure here is this crate's own task management, not the
242        // user's file and not the fabric.
243        .map_err(|e| Error::Internal(format!("the config read task did not join: {e}")))?
244}
245
246/// The explorer config in one place: un-namespaced, explicit endpoints,
247/// multicast per the caller's stated intent. Shared by [`open`] and the
248/// scout module (which is *sessionless* — `zenoh::scout` takes a config,
249/// not a session, and multicast is its point).
250pub(crate) fn explorer_config(
251    connect: &[String],
252    listen: &[String],
253    multicast: bool,
254) -> zenoh::Config {
255    // Infallible without a file: the only error paths are file-shaped.
256    build_config(None, connect, listen, Some(multicast)).expect("no file, no failure")
257}
258
259fn build_config(
260    file: Option<&Path>,
261    connect: &[String],
262    listen: &[String],
263    scouting: Option<bool>,
264) -> Result<zenoh::Config> {
265    let mut config = match file {
266        Some(path) => {
267            // The user named this file, so its failure is theirs to fix.
268            let config = zenoh::Config::from_file(path).map_err(|e| {
269                Error::unaskable(format!("zenoh config {}", path.display()), e.to_string())
270            })?;
271            // The one thing a passthrough refuses: an explorer with a
272            // namespace strips keys on ingress and would lie about the wire.
273            if let Ok(ns) = config.get_json("namespace")
274                && ns != "null"
275            {
276                return Err(Error::unaskable(
277                    format!("zenoh config {}", path.display()),
278                    format!(
279                        "sets a session namespace ({ns}) — an explorer runs \
280                         un-namespaced so it sees the wire as it really is \
281                         (RFC 09 §5); remove the namespace from the file, or \
282                         use --base to name the deployment"
283                    ),
284                ));
285            }
286            config
287        }
288        None => zenoh::Config::default(),
289    };
290    let json_list = |v: &[String]| {
291        let items: Vec<String> = v.iter().map(|e| format!("{e:?}")).collect();
292        format!("[{}]", items.join(","))
293    };
294    match scouting {
295        Some(on) => {
296            config
297                .insert_json5("scouting/multicast/enabled", &on.to_string())
298                .ok();
299        }
300        // Not asked, no file: the explorer default (off, RFC 09 §0.1's
301        // contamination warning). Not asked, file given: the file's choice
302        // stands — flag > env > context > file, and nothing was given.
303        None if file.is_none() => {
304            config
305                .insert_json5("scouting/multicast/enabled", "false")
306                .ok();
307        }
308        None => {}
309    }
310    if !connect.is_empty() {
311        config
312            .insert_json5("connect/endpoints", &json_list(connect))
313            .ok();
314    }
315    if !listen.is_empty() {
316        config
317            .insert_json5("listen/endpoints", &json_list(listen))
318            .ok();
319    }
320    Ok(config)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    /// The multicast bit follows the caller's flag — the scout path turns it
328    /// on deliberately, the session path defaults it off (#116).
329    #[test]
330    fn the_multicast_bit_follows_the_stated_intent() {
331        for on in [true, false] {
332            let config = explorer_config(&[], &[], on);
333            let json = config.get_json("scouting/multicast/enabled").unwrap();
334            assert_eq!(json, on.to_string());
335        }
336    }
337
338    /// Endpoints ride into the config verbatim, so gossip scouting works
339    /// where multicast is filtered.
340    #[test]
341    fn endpoints_ride_into_the_config() {
342        let config = explorer_config(&["tcp/127.0.0.1:7447".into()], &[], false);
343        let json = config.get_json("connect/endpoints").unwrap();
344        assert!(json.contains("tcp/127.0.0.1:7447"), "{json}");
345    }
346
347    fn temp_config(name: &str, body: &str) -> std::path::PathBuf {
348        let path = std::env::temp_dir().join(format!("zenkey-fleet-session-{name}.json5"));
349        std::fs::write(&path, body).unwrap();
350        path
351    }
352
353    /// #122: the user's file is the base layer; a knob that was not given
354    /// leaves the file's choice alone, a knob that was given wins.
355    #[test]
356    fn the_file_is_the_base_and_given_knobs_win() {
357        let path = temp_config(
358            "layering",
359            r#"{ connect: { endpoints: ["tcp/10.0.0.9:7447"] },
360                 scouting: { multicast: { enabled: true } } }"#,
361        );
362        // Nothing given: the file's endpoints and multicast survive.
363        let config = build_config(Some(&path), &[], &[], None).unwrap();
364        assert!(
365            config
366                .get_json("connect/endpoints")
367                .unwrap()
368                .contains("10.0.0.9"),
369        );
370        assert_eq!(
371            config.get_json("scouting/multicast/enabled").unwrap(),
372            "true"
373        );
374        // Given knobs override per knob, not wholesale.
375        let config = build_config(
376            Some(&path),
377            &["tcp/127.0.0.1:7447".to_string()],
378            &[],
379            Some(false),
380        )
381        .unwrap();
382        let endpoints = config.get_json("connect/endpoints").unwrap();
383        assert!(endpoints.contains("127.0.0.1"), "{endpoints}");
384        assert!(
385            !endpoints.contains("10.0.0.9"),
386            "flag replaces the knob it names"
387        );
388        assert_eq!(
389            config.get_json("scouting/multicast/enabled").unwrap(),
390            "false"
391        );
392        std::fs::remove_file(path).ok();
393    }
394
395    /// #341: an open that never settles is a **transport** verdict naming
396    /// its deadline — not a connect flow that hangs with nothing to time it
397    /// out. The stalled half is a `pending` future because that is precisely
398    /// what a hanging listener looks like from here.
399    #[tokio::test]
400    async fn an_open_that_never_settles_is_a_transport_failure() {
401        let stalled = std::future::pending::<std::result::Result<Session, String>>();
402        match opened_within(Duration::from_millis(10), stalled).await {
403            Err(OpenFailure::Transport(e)) => {
404                // The chain: `Display` names the operation and the deadline
405                // explanation rides underneath (#348). `{:#}` is an `anyhow`
406                // idiom and does nothing here.
407                let text = crate::one_line(&e);
408                assert!(
409                    text.contains("did not open within"),
410                    "the deadline is named, so an operator knows what to raise: {text}"
411                );
412            }
413            Err(OpenFailure::Config(e)) => panic!("a deadline is not a config error: {e}"),
414            Ok(_) => panic!("a pending future opened a session"),
415        }
416    }
417
418    /// The deadline is the *connect* one and stated once — never a
419    /// reply-wait borrowed from a caller's `--timeout` (#341).
420    #[test]
421    fn the_connect_deadline_is_its_own_number() {
422        assert_eq!(OPEN_TIMEOUT, Duration::from_secs(10));
423    }
424
425    /// #122: a file that sets a namespace is refused with the RFC pointer —
426    /// an explorer that stripped keys would lie about the wire.
427    #[test]
428    fn a_namespaced_file_is_refused_loudly() {
429        let path = temp_config("namespaced", r#"{ namespace: "acme" }"#);
430        let err = build_config(Some(&path), &[], &[], None)
431            .unwrap_err()
432            .to_string();
433        assert!(err.contains("RFC 09 §5"), "{err}");
434        assert!(err.contains("--base"), "{err}");
435        std::fs::remove_file(path).ok();
436    }
437}