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}