Skip to main content

dora_node_api/node/
mod.rs

1use crate::{
2    DaemonCommunicationWrapper, EventStream, NodeError, NodeResult,
3    daemon_connection::{DaemonChannel, IntegrationTestingEvents},
4    integration_testing::{
5        TestingCommunication, TestingInput, TestingOptions, TestingOutput,
6        take_testing_communication,
7    },
8};
9
10use self::{arrow_utils::ipc_encode, control_channel::ControlChannel};
11use aligned_vec::{AVec, ConstAlign};
12use arrow::array::{Array, ArrayData};
13use colored::Colorize;
14use dora_arrow_convert::{DoraArray, IntoArrow};
15use dora_core::{
16    config::{DataId, NodeId, NodeRunConfig},
17    descriptor::Descriptor,
18    topics::{DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT, DORA_DAEMON_LOCAL_LISTEN_PORT_ENV, LOCALHOST},
19    types::TypeRegistry,
20    uhlc,
21};
22use dora_message::{
23    DataflowId,
24    daemon_to_node::{DaemonCommunication, DaemonReply, NodeConfig, OutputRouting},
25    metadata::{
26        FIN, FLUSH, FRAMING, FRAMING_ARROW_IPC, Metadata, MetadataParameters, Parameter,
27        SCHEMA_HASH, SEGMENT_ID, SEQ, SESSION_ID,
28    },
29    node_to_daemon::{DaemonRequest, DataMessage, Timestamped},
30};
31use eyre::WrapErr;
32use is_terminal::IsTerminal;
33
34use std::{
35    collections::{BTreeMap, BTreeSet, HashMap},
36    path::PathBuf,
37    sync::{
38        Arc, Mutex,
39        atomic::{AtomicBool, Ordering},
40    },
41    time::{Duration, Instant},
42};
43#[cfg(feature = "tracing")]
44use tokio::runtime::Handle;
45
46#[cfg(feature = "tracing")]
47use dora_tracing::{OtelGuard, TracingBuilder};
48use tracing::{debug, error, info, warn};
49
50pub mod arrow_utils;
51mod control_channel;
52
53/// Runtime type checking mode, controlled by `DORA_RUNTIME_TYPE_CHECK` env var.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55enum RuntimeTypeCheck {
56    /// No runtime type checking (default).
57    Off,
58    /// Log warnings on type mismatches.
59    Warn,
60    /// Return errors on type mismatches.
61    Error,
62}
63
64impl RuntimeTypeCheck {
65    fn from_env() -> Self {
66        Self::from_value(std::env::var("DORA_RUNTIME_TYPE_CHECK").ok().as_deref())
67    }
68
69    /// Parse the `DORA_RUNTIME_TYPE_CHECK` value (`None` when the var is unset).
70    fn from_value(value: Option<&str>) -> Self {
71        match value {
72            Some("error") => Self::Error,
73            Some("1" | "warn" | "true" | "on") => Self::Warn,
74            // Accept the natural "disable" spellings without a warning: a user
75            // who sets `=0`/`=false`/`=off` to turn the feature off means to
76            // disable it, not to type an unrecognized value.
77            Some("" | "0" | "false" | "off") | None => Self::Off,
78            Some(other) => {
79                tracing::warn!(
80                    "unknown DORA_RUNTIME_TYPE_CHECK value \"{other}\", \
81                     expected \"warn\" or \"error\"; disabling runtime type check"
82                );
83                Self::Off
84            }
85        }
86    }
87}
88
89#[cfg(test)]
90mod runtime_type_check_tests {
91    use super::RuntimeTypeCheck;
92
93    #[test]
94    fn parses_enable_spellings() {
95        for v in ["1", "warn", "true", "on"] {
96            assert_eq!(
97                RuntimeTypeCheck::from_value(Some(v)),
98                RuntimeTypeCheck::Warn
99            );
100        }
101        assert_eq!(
102            RuntimeTypeCheck::from_value(Some("error")),
103            RuntimeTypeCheck::Error
104        );
105    }
106
107    #[test]
108    fn disable_spellings_and_unset_are_off() {
109        for v in ["", "0", "false", "off"] {
110            assert_eq!(RuntimeTypeCheck::from_value(Some(v)), RuntimeTypeCheck::Off);
111        }
112        assert_eq!(RuntimeTypeCheck::from_value(None), RuntimeTypeCheck::Off);
113    }
114
115    #[test]
116    fn unknown_value_falls_back_to_off() {
117        assert_eq!(
118            RuntimeTypeCheck::from_value(Some("maybe")),
119            RuntimeTypeCheck::Off
120        );
121    }
122}
123
124/// The data size threshold at which we start using shared memory.
125///
126/// Shared memory works by sharing memory pages. This means that the smallest
127/// memory region that can be shared is one memory page, which is typically
128/// 4KiB.
129///
130/// Using shared memory for messages smaller than the page size still requires
131/// sharing a full page, so we have some memory overhead. We also have some
132/// performance overhead because setting up a shared segment is not free. For
133/// small messages it is cheaper to copy them into a heap-buffered publish.
134///
135/// On the zenoh data plane this threshold selects *how* an output is
136/// published: payloads at or above it go through zenoh shared memory
137/// (zero-copy for local subscribers), while smaller payloads are published via
138/// zenoh with a heap-buffered `put`. A large payload that did not get a
139/// shared-memory buffer takes the reliable daemon path instead of the zenoh
140/// one, because a fragmented express publish would be silently dropped
141/// (dora-rs/dora#2366). See [`DoraNode::zero_copy_threshold`] for the runtime
142/// value (overridable via `DORA_ZERO_COPY_THRESHOLD`).
143pub const ZERO_COPY_THRESHOLD: usize = 4096;
144
145/// How many large outbound sends are traced hop-by-hop
146/// (dora-rs/dora#2742 diagnostic; see [`DoraNode::send_output_sample`]).
147///
148/// The Windows nightly wedge happens on a node's *first* large output, so a
149/// handful of traced sends is enough to name the blocked call while keeping a
150/// healthy run's logs clean.
151const LARGE_SEND_DIAG_LIMIT: u32 = 3;
152
153/// How often a starting node re-publishes its startup route-probe markers.
154///
155/// See [`StartupHandshake`]. Markers stop per output as soon as that output's
156/// required acks have arrived (or the grace boundary froze it on the daemon
157/// path), so this rate only applies while the handshake is in flight.
158const ZENOH_STARTUP_MARKER_INTERVAL: Duration = Duration::from_millis(5);
159
160/// The whole budget the startup handshake gets, measured from the daemon's
161/// "all nodes ready" barrier: `init` waits this long for the handshake, and
162/// whatever is still un-acked at the end is frozen on the daemon path for the
163/// run (see [`wait_for_grace`] for why the freeze is unconditional).
164///
165/// The window starts at the barrier, not at spawn: by then every static
166/// consumer has declared its subscribers and ack publishers, so a consumer
167/// that is merely slow to *start* (a Python node importing heavy libraries for
168/// a minute) cannot burn it. The healthy case completes in one marker→ack
169/// round-trip (single-digit milliseconds); half a second of 5 ms markers with
170/// no ack means the route is broken, not slow. Nothing fails at the boundary —
171/// a frozen output just keeps riding the lossless daemon path, trading the
172/// fast path for ordering. This is the single knob for that trade: raising it
173/// gives slow-to-establish routes more chance at direct zenoh, at the cost of
174/// delaying every node whose routes are genuinely broken.
175///
176/// For a **dynamic or restarted** producer the barrier releases immediately —
177/// its consumers have long been running — so this window instead starts a few
178/// milliseconds after the zenoh session opens, while the peer links it needs
179/// may still be dialing. Such a producer is therefore the most likely to end
180/// up frozen on the daemon path; if that shows up as a measurable regression,
181/// this constant (not a late upgrade) is the thing to raise.
182const ZENOH_STARTUP_GRACE: Duration = Duration::from_millis(500);
183
184/// Poll interval for the post-barrier grace wait.
185const ZENOH_STARTUP_GRACE_POLL_INTERVAL: Duration = Duration::from_millis(2);
186
187/// A declared direct-zenoh data publisher plus its startup-handshake state.
188struct DirectOutput {
189    publisher: zenoh::pubsub::Publisher<'static>,
190    /// `false` until the startup handshake proves this output's routes — every
191    /// required consumer acked one of its markers (see [`StartupHandshake`]).
192    /// Settled by [`wait_for_grace`] before `init` returns and immutable from
193    /// then on, so every send of a given output takes the same path: direct
194    /// zenoh when `true`, the reliable daemon path when `false`.
195    ready: Arc<AtomicBool>,
196}
197
198type ZenohPublishers = HashMap<DataId, DirectOutput>;
199
200/// Declare a direct-zenoh data publisher for every output that may ever take
201/// the direct path, plus the per-output ack state the startup handshake needs.
202///
203/// Outputs the daemon pinned `daemon_only` — a consumer only inter-daemon
204/// forwarding can reach (a dynamic node on another daemon, or a remote static
205/// one with no dialable endpoint for this node), and forwarding is fed solely
206/// by daemon-path sends (#2738) — get no publisher and no markers: they stay on
207/// the daemon path for the node's lifetime. Every other output gets a publisher declared eagerly
208/// at init (rather than on first send) for two reasons: zenoh starts wiring
209/// routes immediately, and [`StartupHandshake`] needs the publishers to probe
210/// those routes before the node's first real send. An output with no required
211/// ackers (no consumers, or only dynamic local ones) is `ready` immediately.
212///
213/// QoS is set at declare time so it applies to every put: `express(true)` bypasses
214/// zenoh's adaptive batch timer (the single biggest small-message latency win —
215/// without it, per-put delivery on the bare local config collapses to a few
216/// msg/s), `Priority::RealTime` keeps data-plane messages off the bulk-data
217/// queues, and `CongestionControl::Drop` prevents a stalled subscriber from
218/// back-pressuring the publishing node.
219///
220/// An output whose publisher fails to declare is simply absent from the map; its
221/// sends then fall back to the reliable daemon path.
222fn declare_output_publishers(
223    session: &zenoh::Session,
224    dataflow_id: DataflowId,
225    node_id: &NodeId,
226    outputs: &BTreeSet<DataId>,
227    routing: &BTreeMap<DataId, OutputRouting>,
228) -> (ZenohPublishers, Vec<Arc<AckState>>) {
229    use zenoh::Wait;
230    use zenoh::qos::{CongestionControl, Priority};
231
232    let mut publishers = HashMap::new();
233    let mut ack_states = Vec::new();
234    for output_id in outputs {
235        let Some(output_routing) = routing.get(output_id) else {
236            // Defensive: the daemon computes an entry for every declared
237            // output. An output it doesn't know stays on the daemon path.
238            warn!(output = %output_id, "no routing entry for output; staying on the daemon path");
239            continue;
240        };
241        if output_routing.daemon_only {
242            debug!(
243                output = %output_id,
244                "output pinned to the daemon path (a consumer is reachable only by \
245                 inter-daemon forwarding)"
246            );
247            continue;
248        }
249        let topic = dora_core::topics::zenoh_output_publish_topic(dataflow_id, node_id, output_id);
250        let key_expr = match zenoh::key_expr::KeyExpr::new(topic) {
251            Ok(key) => key.into_owned(),
252            Err(e) => {
253                warn!(output = %output_id, "invalid zenoh key ({e}); falling back to daemon path");
254                continue;
255            }
256        };
257        match session
258            .declare_publisher(key_expr)
259            .congestion_control(CongestionControl::Drop)
260            .express(true)
261            .priority(Priority::RealTime)
262            .wait()
263        {
264            Ok(publisher) => {
265                let ready = Arc::new(AtomicBool::new(output_routing.required_ackers.is_empty()));
266                if !output_routing.required_ackers.is_empty() {
267                    ack_states.push(Arc::new(AckState::new(
268                        output_id.clone(),
269                        &output_routing.required_ackers,
270                        ready.clone(),
271                    )));
272                }
273                publishers.insert(output_id.clone(), DirectOutput { publisher, ready });
274            }
275            Err(e) => {
276                warn!(output = %output_id, "failed to declare zenoh publisher ({e}); falling back to daemon path");
277            }
278        }
279    }
280    (publishers, ack_states)
281}
282
283/// Ack bookkeeping for one output whose startup handshake is in flight.
284///
285/// Shared between the output's ack-subscriber callback (which records incoming
286/// acks) and the [`StartupHandshake`] thread (which publishes markers until
287/// completion or the freeze).
288struct AckState {
289    output_id: DataId,
290    /// The (consumer node, input) identities that must ack before the output
291    /// may switch to the direct zenoh path — the daemon's required-acker set,
292    /// derived from actual placement (local static consumers only).
293    required: BTreeSet<(String, String)>,
294    /// Identities that have acked so far.
295    received: Mutex<BTreeSet<(String, String)>>,
296    /// The same flag as the output's [`DirectOutput::ready`]; flipped exactly
297    /// once, when `received` covers `required` before the freeze.
298    ready: Arc<AtomicBool>,
299    /// Set once the grace boundary passed with this output still un-acked: the
300    /// output is pinned to the daemon path and can never upgrade (see
301    /// [`Self::freeze`]).
302    frozen: AtomicBool,
303}
304
305impl AckState {
306    fn new(
307        output_id: DataId,
308        required: &BTreeSet<dora_message::daemon_to_node::RequiredAcker>,
309        ready: Arc<AtomicBool>,
310    ) -> Self {
311        Self {
312            output_id,
313            required: required
314                .iter()
315                .map(|acker| (acker.node_id.to_string(), acker.input_id.to_string()))
316                .collect(),
317            received: Mutex::new(BTreeSet::new()),
318            ready,
319            frozen: AtomicBool::new(false),
320        }
321    }
322
323    /// The ack lock, recovered from poisoning: a panicking callback leaves the
324    /// set intact and losing acks would silently cost the fast path.
325    ///
326    /// Holding this guard is what makes [`Self::record`] and [`Self::freeze`]
327    /// atomic against each other, so every method that touches `received` or
328    /// `frozen` goes through here.
329    fn received(&self) -> std::sync::MutexGuard<'_, BTreeSet<(String, String)>> {
330        self.received
331            .lock()
332            .unwrap_or_else(|poisoned| poisoned.into_inner())
333    }
334
335    /// Records one ack. Identities outside the required set — a dynamic or
336    /// debug consumer may ack too — are ignored; they must never count toward
337    /// completion. Flips `ready` once the required set is covered, unless the
338    /// output was already frozen on the daemon path.
339    fn record(&self, consumer_node: &str, input_id: &str) {
340        let identity = (consumer_node.to_owned(), input_id.to_owned());
341        if !self.required.contains(&identity) {
342            return;
343        }
344        let mut received = self.received();
345        // Read under the same lock `freeze` takes, so the two can't interleave
346        // into a `ready` flip that outlives the freeze.
347        if self.frozen.load(Ordering::Relaxed) {
348            return;
349        }
350        received.insert(identity);
351        if received.len() == self.required.len() {
352            self.ready.store(true, Ordering::Relaxed);
353        }
354    }
355
356    /// Pins this output to the daemon path for the rest of the run: no later
357    /// ack may flip `ready`. Called once, at the grace boundary — see
358    /// [`wait_for_grace`].
359    ///
360    /// Returns whether the output was actually frozen — `false` means it had
361    /// already completed its handshake and keeps the direct-zenoh path. The
362    /// `received` lock makes that decision atomic against a concurrent
363    /// [`Self::record`]: either the ack completed the set before the freeze, or
364    /// it is ignored.
365    fn freeze(&self) -> bool {
366        let _guard = self.received();
367        if self.ready.load(Ordering::Relaxed) {
368            return false;
369        }
370        self.frozen.store(true, Ordering::Relaxed);
371        true
372    }
373
374    /// Whether this output was frozen on the daemon path (marker-thread view;
375    /// no lock needed, a stale `false` just costs one more marker).
376    fn is_frozen(&self) -> bool {
377        self.frozen.load(Ordering::Relaxed)
378    }
379
380    /// The required identities that have not acked (for the freeze warning).
381    fn missing(&self) -> Vec<String> {
382        let received = self.received();
383        self.required
384            .difference(&received)
385            .map(|(node, input)| format!("{node}/{input}"))
386            .collect()
387    }
388}
389
390/// Declare one exact-key ack subscriber per awaited output.
391///
392/// The callback records acks into the output's [`AckState`]. An output whose
393/// ack subscriber fails to declare can never complete its handshake, so it is
394/// removed from the awaited set right away (its `ready` flag stays `false`
395/// and it keeps riding the daemon path) instead of publishing markers no ack
396/// could ever answer.
397fn declare_ack_subscribers(
398    session: &zenoh::Session,
399    dataflow_id: DataflowId,
400    node_id: &NodeId,
401    ack_states: &mut Vec<Arc<AckState>>,
402) -> Vec<zenoh::pubsub::Subscriber<()>> {
403    use zenoh::Wait;
404
405    let mut subscribers = Vec::new();
406    let mut awaited = Vec::new();
407    for state in ack_states.drain(..) {
408        let topic =
409            dora_core::topics::zenoh_output_ack_topic(dataflow_id, node_id, &state.output_id);
410        let state_cb = state.clone();
411        let subscriber = session
412            .declare_subscriber(topic)
413            .callback(move |sample| {
414                let Some(attachment) = sample.attachment() else {
415                    return;
416                };
417                let Ok(metadata) = dora_message::decode::<Metadata>(&attachment.to_bytes()) else {
418                    // Not a dora ack (foreign publisher on the ack key): ignore.
419                    return;
420                };
421                if metadata.metadata_version() != Metadata::CURRENT_VERSION {
422                    // A peer speaking another wire format cannot be attributed
423                    // reliably; never count its acks.
424                    return;
425                }
426                if let Some((consumer, input)) = metadata.startup_ack_identity() {
427                    state_cb.record(consumer, input);
428                }
429            })
430            .wait();
431        match subscriber {
432            Ok(subscriber) => {
433                subscribers.push(subscriber);
434                awaited.push(state);
435            }
436            Err(e) => {
437                warn!(
438                    output = %state.output_id,
439                    "failed to declare startup-ack subscriber ({e}); output stays on the daemon path"
440                );
441            }
442        }
443    }
444    *ack_states = awaited;
445    subscribers
446}
447
448/// The producer half of the startup handshake: publishes route-probe markers
449/// per output until that output's required consumers have acked, then lets the
450/// send path switch it from the reliable daemon path to direct zenoh.
451///
452/// The zenoh data plane is direct node-to-node pub/sub: zenoh drops samples for
453/// a subscription that hasn't propagated to this publisher yet, so a fast
454/// source could otherwise lose its first messages. Rather than infer
455/// route-readiness from zenoh declarations, the handshake proves it end to end
456/// and in both directions: a marker rides the output's *real* topic, and the
457/// consumer's ack rides the output's `@ack` topic back — an arrived ack is
458/// evidence that the route pair carries data. Until then every send takes the
459/// daemon path, so nothing is ever lost; a route that never proves itself only
460/// costs the fast path, never correctness ([`ZENOH_STARTUP_GRACE`]).
461///
462/// The handshake is over by the time `init` returns: [`Self::settle`] either
463/// sees an output acked or freezes it on the daemon path.
464///
465/// Runs on its own thread because the node blocks inside the daemon's "all
466/// nodes ready" barrier while markers must already be flowing: consumers ack
467/// from their subscriber callbacks (also while parked in the barrier), which is
468/// what makes cycles (`a -> b -> a`, and self-loops) resolve rather than
469/// deadlock — no node ever waits on another node's post-barrier progress.
470///
471/// Markers carry an empty payload (so they never touch shared memory) and are
472/// tagged with [`dora_message::metadata::STARTUP_MARKER_PARAM`], which
473/// consumers filter out before decoding — they never reach user code. This
474/// works for late producers too: a dynamic node or a restarted producer runs
475/// the same handshake at join time against consumers that are already running
476/// (their ack publishers answer markers for the consumer's whole lifetime).
477struct StartupHandshake {
478    stop: Arc<AtomicBool>,
479    /// The outputs whose handshake is (or was) in flight.
480    ack_states: Vec<Arc<AckState>>,
481    handle: Option<std::thread::JoinHandle<()>>,
482    /// Per-output ack subscribers. Kept for the node's lifetime (idle once the
483    /// handshake resolves) and dropped before the session in `DoraNode::drop`.
484    ack_subscribers: Vec<zenoh::pubsub::Subscriber<()>>,
485}
486
487impl StartupHandshake {
488    fn start(
489        session: &zenoh::Session,
490        dataflow_id: DataflowId,
491        node_id: &NodeId,
492        publishers: &Arc<ZenohPublishers>,
493        mut ack_states: Vec<Arc<AckState>>,
494        clock: Arc<uhlc::HLC>,
495    ) -> Self {
496        use zenoh::Wait;
497
498        let stop = Arc::new(AtomicBool::new(false));
499        let ack_subscribers =
500            declare_ack_subscribers(session, dataflow_id, node_id, &mut ack_states);
501        if ack_states.is_empty() {
502            // Nothing awaits acks (no consumers, or every ack subscriber
503            // failed): no markers to publish, nothing to stop.
504            return Self {
505                stop,
506                ack_states,
507                handle: None,
508                ack_subscribers,
509            };
510        }
511
512        let thread_stop = stop.clone();
513        let thread_states = ack_states.clone();
514        let thread_publishers = publishers.clone();
515        let handle = std::thread::Builder::new()
516            .name("dora-startup-handshake".into())
517            .spawn(move || {
518                loop {
519                    if thread_stop.load(Ordering::Relaxed) {
520                        return;
521                    }
522                    let mut awaiting = false;
523                    for state in &thread_states {
524                        // A frozen output can never upgrade, so its markers can
525                        // only cost bandwidth; `wait_for_grace` already logged
526                        // why it stays on the daemon path.
527                        if state.ready.load(Ordering::Relaxed) || state.is_frozen() {
528                            continue;
529                        }
530                        awaiting = true;
531                        let Some(output) = thread_publishers.get(&state.output_id) else {
532                            continue;
533                        };
534                        let metadata = Metadata::startup_marker(clock.new_timestamp());
535                        let attachment = match dora_message::encode(&metadata) {
536                            Ok(bytes) => bytes,
537                            Err(e) => {
538                                debug!(output = %state.output_id, "failed to serialize startup marker ({e})");
539                                continue;
540                            }
541                        };
542                        if let Err(e) = output
543                            .publisher
544                            .put(&[][..])
545                            .attachment(&attachment[..])
546                            .wait()
547                        {
548                            // Expected while the route is still coming up.
549                            tracing::trace!(output = %state.output_id, "startup marker put failed ({e})");
550                        }
551                    }
552                    if !awaiting {
553                        // Every output is acked or frozen: the handshake is over.
554                        return;
555                    }
556                    std::thread::sleep(ZENOH_STARTUP_MARKER_INTERVAL);
557                }
558            });
559        match handle {
560            Ok(handle) => Self {
561                stop,
562                ack_states,
563                handle: Some(handle),
564                ack_subscribers,
565            },
566            Err(e) => {
567                // Without markers no consumer will ack, so the awaited outputs
568                // simply stay on the reliable daemon path. Loud because the
569                // fast path is silently lost for this node.
570                error!(
571                    "failed to spawn startup-handshake thread ({e}); outputs stay on the daemon path"
572                );
573                Self {
574                    stop,
575                    ack_states,
576                    handle: None,
577                    ack_subscribers,
578                }
579            }
580        }
581    }
582
583    /// Settle every output's transport: wait out `grace`, then freeze whatever
584    /// has not proven its routes. See [`wait_for_grace`].
585    ///
586    /// `DoraNode::init` **must** call this before returning to user code —
587    /// that is what makes an output's path constant for the run
588    /// (dora-rs/dora#2891). It is a method (rather than only the free function
589    /// the tests drive) so the requirement is discoverable from the type.
590    fn settle(&self, grace: Duration) {
591        wait_for_grace(&self.ack_states, grace);
592    }
593
594    /// Signal the handshake thread to stop and wait for it to exit, so its
595    /// `Arc` clone of the publishers is released. Idempotent.
596    fn shutdown(&mut self) {
597        self.stop.store(true, Ordering::Relaxed);
598        if let Some(handle) = self.handle.take() {
599            let _ = handle.join();
600        }
601    }
602}
603
604impl Drop for StartupHandshake {
605    fn drop(&mut self) {
606        // Safety net for error paths that return before `DoraNode::drop` (e.g.
607        // a failed `EventStream::init`). Without this the handshake thread
608        // would keep publishing and keep the publishers (and session) alive.
609        self.shutdown();
610    }
611}
612
613/// Post-barrier grace wait: give the handshake `grace` to complete, then freeze
614/// whatever is left. This is the boundary that settles every output's transport
615/// before user code runs.
616///
617/// Returns as soon as every awaited output is ready. Anything still un-acked
618/// when `grace` expires is pinned to the daemon path for the rest of the run
619/// ([`AckState::freeze`]) rather than left to upgrade later. Upgrading later is
620/// the bug in dora-rs/dora#2891: user code sends from the moment this returns,
621/// so *any* subsequent upgrade is mid-stream, and the direct-zenoh path has
622/// fewer hops than the daemon-relay path — a message sent just after the switch
623/// can overtake an earlier one still in flight on the daemon path, which the
624/// consumer merges into a single arrival-ordered channel with no cross-path
625/// resequencing. Freezing keeps a topic's per-input FIFO order unconditional;
626/// the cost is the fast path for routes that fail to establish within `grace`.
627///
628/// A free function taking the states (rather than a `StartupHandshake` method)
629/// so tests can exercise the boundary without zenoh, with a `grace` shorter
630/// than [`ZENOH_STARTUP_GRACE`].
631fn wait_for_grace(ack_states: &[Arc<AckState>], grace: Duration) {
632    let grace_deadline = Instant::now() + grace;
633    loop {
634        // Vacuously true when nothing awaits acks (no consumers, or every ack
635        // subscriber failed to declare): there is nothing to wait for.
636        if ack_states
637            .iter()
638            .all(|state| state.ready.load(Ordering::Relaxed))
639        {
640            break;
641        }
642        if Instant::now() >= grace_deadline {
643            break;
644        }
645        std::thread::sleep(ZENOH_STARTUP_GRACE_POLL_INTERVAL);
646    }
647
648    for state in ack_states {
649        // `freeze` re-checks readiness under the ack lock, so an ack landing
650        // right now either completes the handshake or is ignored — never a
651        // half-applied upgrade.
652        if state.freeze() {
653            warn!(
654                output = %state.output_id,
655                missing = ?state.missing(),
656                "startup handshake incomplete after {}ms; output stays on the \
657                 reliable daemon path for the rest of the run",
658                grace.as_millis()
659            );
660        } else {
661            // The positive half of the same decision, and the only signal that
662            // an output is *off* the daemon path — which for a consumer on
663            // another machine means its data no longer crosses two daemons.
664            // Logged per output, once, at the moment it is settled for the run.
665            debug!(
666                output = %state.output_id,
667                "startup handshake complete; output takes the direct zenoh path"
668            );
669        }
670    }
671}
672
673/// The per-output routing the daemon computed for this node, or the safe
674/// all-daemon-path fallback when it provided none.
675///
676/// A missing map means the node was spawned by an older daemon (or an
677/// interactive/manual setup) that doesn't know about the startup handshake.
678/// Without required-acker sets no route can be proven, so every output stays
679/// on the reliable daemon path — correct, just without the zenoh fast path.
680fn normalize_output_routing(
681    routing: Option<BTreeMap<DataId, OutputRouting>>,
682    outputs: &BTreeSet<DataId>,
683) -> BTreeMap<DataId, OutputRouting> {
684    match routing {
685        Some(routing) => routing,
686        None => {
687            if !outputs.is_empty() {
688                warn!(
689                    "node config carries no output routing (spawned by an older daemon?); \
690                     all outputs stay on the reliable daemon path"
691                );
692            }
693            outputs
694                .iter()
695                .map(|output_id| {
696                    (
697                        output_id.clone(),
698                        OutputRouting {
699                            daemon_only: true,
700                            required_ackers: Default::default(),
701                        },
702                    )
703                })
704                .collect()
705        }
706    }
707}
708
709/// Per-phase deadline for tearing down zenoh state on node shutdown (subscribers,
710/// liveliness tokens, and the session/publishers — see [`DoraNode::drop`] and
711/// `EventStream::drop`). Each `undeclare`/close blocks indefinitely when zenoh's net
712/// runtime is wedged (e.g. retrying an unreachable scouted peer on a headless CI
713/// runner), so it is bounded here and abandoned on timeout.
714///
715/// This MUST stay comfortably below the daemon's force-kill grace
716/// (`DEFAULT_STOP_GRACE (10s) + DEFAULT_STOP_GRACE/2 = 15s`, see
717/// `binaries/daemon/src/running_dataflow.rs`), including the worst case where all
718/// three phases wedge sequentially (`3 *` this value). Otherwise a node with a wedged
719/// net runtime is still tearing down when the daemon `TerminateProcess`es it, which on
720/// Windows surfaces as `ExitCode(1)` and reddens the nightly (dora-rs/dora#2742). The
721/// `zenoh_teardown_fits_within_daemon_force_kill_grace` test guards the invariant.
722///
723/// This deliberately no longer exceeds zenoh's internal 10s session-close timeout: a
724/// semi-wedged close that would settle at ~10s is abandoned instead, and peers fall
725/// back to liveliness expiry. That is the right trade for a *shutting-down* node —
726/// waiting out the 10s only to be force-killed anyway yields a worse (unclean) exit.
727pub(crate) const ZENOH_TEARDOWN_TIMEOUT: Duration = Duration::from_secs(3);
728
729/// Capacity of the testing-mode daemon request channel. Kept comfortably above
730/// the number of concurrent requesters (event stream + close channel + control
731/// channel) so Drop's CloseOutputs send does not block on a full queue while
732/// the daemon thread is busy inside `next_event` (dora-rs/dora#2855).
733const TESTING_DAEMON_CHANNEL_CAPACITY: usize = 256;
734
735/// Allows sending outputs and retrieving node information.
736///
737/// The main purpose of this struct is to send outputs via Dora. There are also functions available
738/// for retrieving the node configuration.
739pub struct DoraNode {
740    id: NodeId,
741    dataflow_id: DataflowId,
742    node_config: NodeRunConfig,
743    control_channel: ControlChannel,
744    clock: Arc<uhlc::HLC>,
745
746    /// Zenoh session for direct node-to-node pub/sub (data plane).
747    /// `None` in interactive/testing mode.
748    zenoh_session: Option<zenoh::Session>,
749    /// Zenoh shared memory provider for zero-copy publishing.
750    /// Owns the SHM provider and the zero-copy threshold. Cloned out by
751    /// [`DoraNode::sample_allocator`] so an operator thread can build output
752    /// samples without reaching into the node (dora-rs/dora#2742).
753    sample_allocator: SampleAllocator,
754    /// Per-output zenoh publishers with their handshake state, declared eagerly
755    /// at init (see [`declare_output_publishers`]) so zenoh wires routes
756    /// immediately and [`StartupHandshake`] can probe them before the first
757    /// real send. An output missing here (declaration failed, or pinned to the
758    /// daemon path by the daemon's routing) falls back to the daemon path; one
759    /// whose `ready` flag is still `false` (handshake in flight or frozen)
760    /// does too. Shared with the handshake thread via `Arc`; the thread is
761    /// joined in `drop` before the map is torn down.
762    /// `'static` is sound because zenoh `Publisher` internally holds `Arc<Session>`,
763    /// so it doesn't borrow from the session field on this struct.
764    /// Publishers must be dropped BEFORE the session (enforced in Drop impl).
765    zenoh_publishers: Arc<ZenohPublishers>,
766    /// The producer half of the startup handshake (marker thread + ack
767    /// subscribers). `None` without a zenoh session (interactive/testing).
768    startup_handshake: Option<StartupHandshake>,
769    /// Per-output schema publishers on the `@schema` subtopic (lazily created on
770    /// the first small message). Their cache retains the last schema so a
771    /// late-joining subscriber fetches it via a history query, letting the data
772    /// topic carry only schema-less batches. Dropped before the session, like
773    /// [`zenoh_publishers`](Self::zenoh_publishers).
774    zenoh_schema_publishers: HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
775    /// Per-output schema-once state: the confirmed-published schema hash (so
776    /// the schema is only re-published when it changes or a publish failed) and
777    /// the time of the last full-stream send (for the periodic in-band refresh).
778    zenoh_schema_state: HashMap<DataId, SchemaOnceState>,
779    /// Diagnostic (dora-rs/dora#2742): how many large sends have already been
780    /// traced hop-by-hop. The Windows nightly wedges the *runtime's* main loop
781    /// inside `send_output` on the very first large output, so tracing only the
782    /// first few large sends pins the stuck hop without spamming a healthy run.
783    /// Remove together with the runtime's stall watchdog once #2742 is closed.
784    large_send_diag_count: u32,
785
786    dataflow_descriptor: serde_yaml::Result<Descriptor>,
787    warned_unknown_output: BTreeSet<DataId>,
788    interactive: bool,
789    restart_count: u32,
790
791    /// Runtime type checking state. `None` when off (zero overhead).
792    /// When `Some`, holds the mode (Warn/Error) and a map of output DataId -> expected Arrow DataType.
793    runtime_type_checks: Option<(RuntimeTypeCheck, HashMap<DataId, arrow_schema::DataType>)>,
794
795    /// Tokio runtime owned by the node. Populated only when no ambient
796    /// runtime was available at init. Must drop after the zenoh session
797    /// (which is drained explicitly at the top of [`Drop`]) so that any
798    /// async cleanup triggered by session shutdown can still run.
799    _owned_runtime: Option<tokio::runtime::Runtime>,
800
801    /// Join handle for the in-process testing daemon thread spawned by
802    /// [`Self::init_testing`] / the testing branch of `init_with_options`.
803    /// Joined in [`Drop`] after closing the request channel (dora-rs/dora#2855).
804    testing_daemon: Option<std::thread::JoinHandle<()>>,
805    /// Signals the testing daemon to abort a scheduled `next_event` sleep so
806    /// Drop's CloseOutputs handshake can complete.
807    testing_shutdown: Option<Arc<AtomicBool>>,
808}
809
810impl DoraNode {
811    /// Initiate a node from environment variables set by the Dora daemon or fall back to
812    /// interactive mode.
813    ///
814    /// This is the recommended initialization function for Dora nodes, which are spawned by
815    /// Dora daemon instances. The daemon will set a `DORA_NODE_CONFIG` environment variable to
816    /// configure the node.
817    ///
818    /// When the node is started manually without the `DORA_NODE_CONFIG` environment variable set,
819    /// the initialization will fall back to [`init_interactive`](Self::init_interactive) if `stdin`
820    /// is a terminal (detected through
821    /// [`isatty`](https://www.man7.org/linux/man-pages/man3/isatty.3.html)).
822    ///
823    /// If the `DORA_NODE_CONFIG` environment variable is not set and `DORA_TEST_WITH_INPUTS` is
824    /// set, the node will be initialized in integration test mode. See the
825    /// [integration testing](crate::integration_testing) module for details.
826    ///
827    /// This function will also initialize the node in integration test mode when the
828    /// [`setup_integration_testing`](crate::integration_testing::setup_integration_testing)
829    /// function was called before. This takes precedence over all environment variables.
830    ///
831    /// ```no_run
832    /// use dora_node_api::DoraNode;
833    ///
834    /// let (mut node, mut events) = DoraNode::init_from_env().expect("Could not init node.");
835    /// ```
836    pub fn init_from_env() -> NodeResult<(Self, EventStream)> {
837        Self::init_from_env_inner(true)
838    }
839
840    /// Initialize the node from environment variables set by the Dora daemon; error if not set.
841    ///
842    /// This function behaves the same as [`init_from_env`](Self::init_from_env), but it does _not_
843    /// fall back to [`init_interactive`](Self::init_interactive). Instead, an error is returned
844    /// when the `DORA_NODE_CONFIG` environment variable is missing.
845    pub fn init_from_env_force() -> NodeResult<(Self, EventStream)> {
846        Self::init_from_env_inner(false)
847    }
848
849    fn init_from_env_inner(fallback_to_interactive: bool) -> NodeResult<(Self, EventStream)> {
850        if let Some(testing_comm) = take_testing_communication() {
851            let TestingCommunication {
852                input,
853                output,
854                options,
855            } = *testing_comm;
856            return Self::init_testing(input, output, options);
857        }
858
859        // normal execution (started by dora daemon)
860        match std::env::var("DORA_NODE_CONFIG") {
861            Ok(raw) => {
862                let node_config: NodeConfig =
863                    serde_yaml::from_str(&raw).context("failed to deserialize node config")?;
864                return Self::init(node_config);
865            }
866            Err(std::env::VarError::NotUnicode(_)) => {
867                return Err(NodeError::Init(
868                    "DORA_NODE_CONFIG env variable is not valid unicode".into(),
869                ));
870            }
871            Err(std::env::VarError::NotPresent) => {} // continue trying other init methods
872        };
873
874        // node integration test mode
875        match std::env::var("DORA_TEST_WITH_INPUTS") {
876            Ok(raw) => {
877                let input_file = PathBuf::from(raw);
878                let output_file = match std::env::var("DORA_TEST_WRITE_OUTPUTS_TO") {
879                    Ok(raw) => PathBuf::from(raw),
880                    Err(std::env::VarError::NotUnicode(_)) => {
881                        return Err(NodeError::Init(
882                            "DORA_TEST_WRITE_OUTPUTS_TO env variable is not valid unicode".into(),
883                        ));
884                    }
885                    Err(std::env::VarError::NotPresent) => {
886                        input_file.with_file_name("outputs.jsonl")
887                    }
888                };
889                let skip_output_time_offsets =
890                    std::env::var_os("DORA_TEST_NO_OUTPUT_TIME_OFFSET").is_some();
891
892                let input = TestingInput::FromJsonFile(input_file);
893                let output = TestingOutput::ToFile(output_file);
894                let options = TestingOptions {
895                    skip_output_time_offsets,
896                };
897
898                return Self::init_testing(input, output, options);
899            }
900            Err(std::env::VarError::NotUnicode(_)) => {
901                return Err(NodeError::Init(
902                    "DORA_TEST_WITH_INPUTS env variable is not valid unicode".into(),
903                ));
904            }
905            Err(std::env::VarError::NotPresent) => {} // continue trying other init methods
906        }
907
908        // interactive mode
909        if fallback_to_interactive && std::io::stdin().is_terminal() {
910            println!(
911                "{}",
912                "Starting node in interactive mode as DORA_NODE_CONFIG env variable is not set"
913                    .green()
914            );
915            return Self::init_interactive();
916        }
917
918        // no run mode applicable
919        Err(NodeError::Init(
920            "DORA_NODE_CONFIG env variable is not set".into(),
921        ))
922    }
923
924    /// Create a builder for configuring a node connection.
925    ///
926    /// Setting a `node_id` selects the dynamic-node path; without one, `build()`
927    /// falls back to [`init_from_env`](Self::init_from_env). Use this builder
928    /// when you need a custom daemon port — the other init functions cover the
929    /// common cases. Source-compatible with upstream dora 0.5.x: `.dynamic()`
930    /// is accepted (no-op) so code written against upstream still compiles.
931    ///
932    /// ```no_run
933    /// use dora_node_api::DoraNode;
934    /// use dora_node_api::dora_core::config::NodeId;
935    ///
936    /// let (mut node, mut events) = DoraNode::builder()
937    ///     .node_id(NodeId::from("plot".to_string()))
938    ///     .daemon_port(6789)
939    ///     .build()
940    ///     .expect("Could not init node");
941    /// ```
942    pub fn builder() -> DoraNodeBuilder {
943        DoraNodeBuilder::default()
944    }
945
946    /// Initiate a node from a dataflow id and a node id.
947    ///
948    /// This initialization function should be used for [_dynamic nodes_](index.html#dynamic-nodes).
949    ///
950    /// ```no_run
951    /// use dora_node_api::DoraNode;
952    /// use dora_node_api::dora_core::config::NodeId;
953    ///
954    /// let (mut node, mut events) = DoraNode::init_from_node_id(NodeId::from("plot".to_string())).expect("Could not init node plot");
955    /// ```
956    ///
957    pub fn init_from_node_id(node_id: NodeId) -> NodeResult<(Self, EventStream)> {
958        Self::builder().node_id(node_id).build()
959    }
960
961    /// Dynamic initialization function for nodes that are sometimes used as dynamic nodes.
962    ///
963    /// This function first tries initializing the traditional way through
964    /// [`init_from_env`][Self::init_from_env]. If this fails, it falls back to
965    /// [`init_from_node_id`][Self::init_from_node_id].
966    pub fn init_flexible(node_id: NodeId) -> NodeResult<(Self, EventStream)> {
967        if std::env::var("DORA_NODE_CONFIG").is_ok() {
968            info!(
969                "Skipping {node_id} specified within the node initialization in favor of `DORA_NODE_CONFIG` specified by `dora start`"
970            );
971            Self::init_from_env()
972        } else {
973            Self::init_from_node_id(node_id)
974        }
975    }
976
977    /// Initialize the node in a standalone mode that prompts for inputs on the terminal.
978    ///
979    /// Instead of connecting to a `dora daemon`, this interactive mode prompts for node inputs
980    /// on the terminal. In this mode, the node is completely isolated from the dora daemon and
981    /// other nodes, so it cannot be part of a dataflow.
982    ///
983    /// Note that this function will hang indefinitely if no input is supplied to the interactive
984    /// prompt. So it should be only used through a terminal.
985    ///
986    /// Because of the above limitations, it is not recommended to use this function directly.
987    /// Use [**`init_from_env`**](Self::init_from_env) instead, which supports both normal daemon
988    /// connections and manual interactive runs.
989    ///
990    /// ## Example
991    ///
992    /// Run any node that uses `init_interactive` or [`init_from_env`](Self::init_from_env) directly
993    /// from a terminal. The node will then start in "interactive mode" and prompt you for the next
994    /// input:
995    ///
996    /// ```bash
997    /// > cargo build -p rust-dataflow-example-node
998    /// > target/debug/rust-dataflow-example-node
999    /// hello
1000    /// Starting node in interactive mode as DORA_NODE_CONFIG env variable is not set
1001    /// Node asks for next input
1002    /// ? Input ID
1003    /// [empty input ID to stop]
1004    /// ```
1005    ///
1006    /// The `rust-dataflow-example-node` expects a `tick` input, so let's set the input ID to
1007    /// `tick`. Tick messages don't have any data, so we leave the "Data" empty when prompted:
1008    ///
1009    /// ```bash
1010    /// Node asks for next input
1011    /// > Input ID tick
1012    /// > Data
1013    /// tick 0, sending 0x943ed1be20c711a4
1014    /// node sends output random with data: PrimitiveArray<UInt64>
1015    /// [
1016    ///   10682205980693303716,
1017    /// ]
1018    /// Node asks for next input
1019    /// ? Input ID
1020    /// [empty input ID to stop]
1021    /// ```
1022    ///
1023    /// We see that both the `stdout` output of the node and also the output messages that it sends
1024    /// are printed to the terminal. Then we get another prompt for the next input.
1025    ///
1026    /// If you want to send an input with data, you can either send it as text (for string data)
1027    /// or as a JSON object (for struct, string, or array data). Other data types are not supported
1028    /// currently.
1029    ///
1030    /// Empty input IDs are interpreted as stop instructions:
1031    ///
1032    /// ```bash
1033    /// > Input ID
1034    /// given input ID is empty -> stopping
1035    /// Received stop
1036    /// Node asks for next input
1037    /// event channel was stopped -> returning empty event list
1038    /// node reports EventStreamDropped
1039    /// node reports closed outputs []
1040    /// node reports OutputsDone
1041    /// ```
1042    ///
1043    /// In addition to the node output, we see log messages for the different events that the node
1044    /// reports. After `OutputsDone`, the node should exit.
1045    ///
1046    /// ### JSON data
1047    ///
1048    /// In addition to text input, the `Data` prompt also supports JSON objects, which will be
1049    /// converted to Apache Arrow struct arrays:
1050    ///
1051    /// ```bash
1052    /// Node asks for next input
1053    /// > Input ID some_input
1054    /// > Data { "field_1": 42, "field_2": { "inner": "foo" } }
1055    /// ```
1056    ///
1057    /// This JSON data is converted to the following Arrow array:
1058    ///
1059    /// ```text
1060    /// StructArray
1061    /// -- validity: [valid, ]
1062    /// [
1063    ///   -- child 0: "field_1" (Int64)
1064    ///      PrimitiveArray<Int64>
1065    ///      [42,]
1066    ///   -- child 1: "field_2" (Struct([Field { name: "inner", data_type: Utf8, nullable: true, dict_id: 0, dict_is_ordered: false, metadata: {} }]))
1067    ///      StructArray
1068    ///      -- validity: [valid,]
1069    ///      [
1070    ///        -- child 0: "inner" (Utf8)
1071    ///        StringArray
1072    ///        ["foo",]
1073    ///      ]
1074    /// ]
1075    /// ```
1076    pub fn init_interactive() -> NodeResult<(Self, EventStream)> {
1077        #[cfg(feature = "tracing")]
1078        {
1079            TracingBuilder::new("node")
1080                .with_stdout("debug", false)
1081                .build()
1082                .wrap_err("failed to set up tracing subscriber")?;
1083        }
1084
1085        let node_config = NodeConfig {
1086            dataflow_id: DataflowId::new_v4(),
1087            node_id: "test-node"
1088                .parse()
1089                .map_err(|e| NodeError::Init(format!("{e}")))?,
1090            run_config: NodeRunConfig::default(),
1091            daemon_communication: Some(DaemonCommunication::Interactive),
1092            dataflow_descriptor: serde_yaml::Value::Null,
1093            dynamic: false,
1094            write_events_to: None,
1095            restart_count: 0,
1096            output_routing: None,
1097        };
1098        let (mut node, events) = Self::init(node_config)?;
1099        node.interactive = true;
1100        Ok((node, events))
1101    }
1102
1103    /// Initializes a node in integration test mode.
1104    ///
1105    /// No connection to a dora daemon is made in this mode. Instead, inputs are read from the
1106    /// specified `TestingInput`, and outputs are written to the specified `TestingOutput`.
1107    /// Additional options for the testing mode can be specified through `TestingOptions`.
1108    ///
1109    /// It is recommended to use this function only within test functions.
1110    pub fn init_testing(
1111        input: TestingInput,
1112        output: TestingOutput,
1113        options: TestingOptions,
1114    ) -> NodeResult<(Self, EventStream)> {
1115        let node_config = NodeConfig {
1116            dataflow_id: DataflowId::new_v4(),
1117            node_id: "test-node"
1118                .parse()
1119                .map_err(|e| NodeError::Init(format!("{e}")))?,
1120            run_config: NodeRunConfig::default(),
1121            daemon_communication: None,
1122            dataflow_descriptor: serde_yaml::Value::Null,
1123            dynamic: false,
1124            write_events_to: None,
1125            restart_count: 0,
1126            output_routing: None,
1127        };
1128        let testing_comm = TestingCommunication {
1129            input,
1130            output,
1131            options,
1132        };
1133        let (mut node, events) = Self::init_with_options(node_config, Some(testing_comm))?;
1134        node.interactive = true;
1135        Ok((node, events))
1136    }
1137
1138    /// Internal initialization routine that should not be used outside of Dora.
1139    #[doc(hidden)]
1140    #[tracing::instrument]
1141    pub fn init(node_config: NodeConfig) -> NodeResult<(Self, EventStream)> {
1142        Self::init_with_options(node_config, None)
1143    }
1144
1145    #[tracing::instrument(skip(testing_communication))]
1146    fn init_with_options(
1147        node_config: NodeConfig,
1148        testing_communication: Option<TestingCommunication>,
1149    ) -> NodeResult<(Self, EventStream)> {
1150        // Before anything that can fail or block: a node spawned by `dora run`
1151        // must not outlive the CLI even if the rest of this initialization
1152        // stalls (dora-rs/dora#2856). A no-op on every other spawn path.
1153        crate::orphan_guard::arm_if_run_child();
1154
1155        let NodeConfig {
1156            dataflow_id,
1157            node_id,
1158            run_config,
1159            daemon_communication,
1160            dataflow_descriptor,
1161            dynamic,
1162            write_events_to,
1163            restart_count,
1164            output_routing,
1165        } = node_config;
1166        let clock = Arc::new(uhlc::HLC::default());
1167        let input_config = run_config.inputs.clone();
1168
1169        let (daemon_communication, testing_daemon, testing_shutdown) = match daemon_communication {
1170            Some(comm) => (comm.into(), None, None),
1171            None => match testing_communication {
1172                Some(comm) => {
1173                    let TestingCommunication {
1174                        input,
1175                        output,
1176                        options,
1177                    } = comm;
1178                    let (sender, mut receiver) =
1179                        tokio::sync::mpsc::channel(TESTING_DAEMON_CHANNEL_CAPACITY);
1180                    let shutdown = Arc::new(AtomicBool::new(false));
1181                    let new_communication = DaemonCommunicationWrapper::Testing {
1182                        channel: sender,
1183                        shutdown: shutdown.clone(),
1184                    };
1185                    let mut events =
1186                        IntegrationTestingEvents::new(input, output, options, shutdown.clone())?;
1187                    let shutdown_for_loop = shutdown.clone();
1188                    let handle = std::thread::Builder::new()
1189                        .name("dora-testing-daemon".into())
1190                        .spawn(move || {
1191                            while let Some((request, reply_sender)) = receiver.blocking_recv() {
1192                                let outputs_done =
1193                                    matches!(request.inner, DaemonRequest::OutputsDone);
1194                                let reply = events.request(&request);
1195                                if reply_sender
1196                                    .send(reply.unwrap_or_else(|err| {
1197                                        DaemonReply::Result(Err(format!("{err:?}")))
1198                                    }))
1199                                    .is_err()
1200                                {
1201                                    eprintln!("failed to send reply");
1202                                }
1203                                // Exit after OutputsDone under shutdown even if
1204                                // EventStream still holds a sender clone — otherwise
1205                                // node-first Drop waits forever on blocking_recv
1206                                // (dora-rs/dora#2855).
1207                                if outputs_done && shutdown_for_loop.load(Ordering::Relaxed) {
1208                                    break;
1209                                }
1210                            }
1211                        })
1212                        .map_err(|e| {
1213                            NodeError::Init(format!("failed to spawn testing daemon thread: {e}"))
1214                        })?;
1215                    (new_communication, Some(handle), Some(shutdown))
1216                }
1217                None => {
1218                    return Err(NodeError::Init(
1219                        "no daemon communication method specified".into(),
1220                    ));
1221                }
1222            },
1223        };
1224
1225        // Initialize zenoh session for direct node-to-node data plane.
1226        // Skip in interactive/testing mode (no daemon, no dataflow topology).
1227        let is_standard_mode = matches!(
1228            daemon_communication,
1229            DaemonCommunicationWrapper::Standard(_)
1230        );
1231        // Pool size priority: per-node YAML config > env var > built-in default.
1232        let shm_pool_size = run_config
1233            .shared_memory_pool_size
1234            .map(|bs| bs.as_bytes())
1235            .or_else(|| {
1236                std::env::var("DORA_NODE_SHM_POOL_SIZE")
1237                    .ok()
1238                    .and_then(|s| s.parse::<usize>().ok())
1239            })
1240            // 8 MB default — kept deliberately small so the pool fits a
1241            // constrained `/dev/shm` (Docker/Kubernetes commonly cap it at
1242            // 64 MB). A pool that doesn't fit backing memory was observed to
1243            // break large-output delivery entirely (the segment can't be backed
1244            // as it fills), so bumping this default is NOT a safe way to widen
1245            // the large-message pipeline. Throughput under large-message bursts
1246            // is handled instead by the non-blocking `GarbageCollect` alloc
1247            // policy (see `allocate_data_sample` / `zenoh_publish`): the producer
1248            // never stalls on a momentarily-full pool, it just copies via the
1249            // heap path. Raise this only alongside a matching `/dev/shm` (via
1250            // `shared_memory_pool_size` / `DORA_NODE_SHM_POOL_SIZE`) to keep more
1251            // large outputs zero-copy.
1252            .unwrap_or(8 * 1024 * 1024);
1253        let zenoh_zero_copy_threshold = std::env::var("DORA_ZERO_COPY_THRESHOLD")
1254            .ok()
1255            .and_then(|s| s.parse::<usize>().ok())
1256            .unwrap_or(ZERO_COPY_THRESHOLD);
1257        let (zenoh_session, zenoh_shm_provider, owned_runtime) = if !is_standard_mode {
1258            (None, None, None)
1259        } else {
1260            let (handle, owned_runtime) = match tokio::runtime::Handle::try_current() {
1261                Ok(handle) => (handle, None),
1262                Err(_) => {
1263                    let rt = tokio::runtime::Builder::new_multi_thread()
1264                        .enable_all()
1265                        .thread_name("dora-node-runtime")
1266                        .build()
1267                        .map_err(|e| {
1268                            NodeError::Init(format!("failed to create owned tokio runtime: {e}"))
1269                        })?;
1270                    let handle = rt.handle().clone();
1271                    (handle, Some(rt))
1272                }
1273            };
1274            // Use scope + spawn to avoid panicking when called from a tokio
1275            // worker thread (block_on panics in that context on
1276            // current-thread runtimes).
1277            let session = std::thread::scope(|s| {
1278                match s
1279                    .spawn(|| handle.block_on(dora_core::topics::open_zenoh_session(None)))
1280                    .join()
1281                {
1282                    Ok(Ok(session)) => Ok(session),
1283                    Ok(Err(e)) => Err(NodeError::Init(format!(
1284                        "failed to open zenoh session: {e:?}"
1285                    ))),
1286                    Err(_panic) => Err(NodeError::Init("zenoh session init panicked".into())),
1287                }
1288            })?;
1289            // SHM provider is best-effort: if the OS rejects the segment
1290            // allocation (e.g. `/dev/shm` exhausted in CI), fall back to
1291            // `None`. `send_output_sample` already publishes via heap
1292            // buffers when the provider is missing.
1293            let provider = {
1294                use zenoh::Wait;
1295                use zenoh::shm::{AllocAlignment, MemoryLayout, ShmProviderBuilder};
1296
1297                let alignment =
1298                    AllocAlignment::new(crate::arrow_utils::ARROW_BUFFER_ALIGNMENT_EXPONENT)
1299                        .expect("ARROW_BUFFER_ALIGNMENT is a valid power-of-two alignment");
1300                let layout = shm_pool_size
1301                    .checked_next_multiple_of(crate::arrow_utils::ARROW_BUFFER_ALIGNMENT)
1302                    .and_then(|aligned| MemoryLayout::new(aligned, alignment).ok());
1303
1304                match layout {
1305                    Some(layout) => match ShmProviderBuilder::default_backend(layout).wait() {
1306                        Ok(provider) => Some(Arc::new(provider)),
1307                        Err(e) => {
1308                            warn!(
1309                                "failed to create zenoh SHM provider ({e}); \
1310                                 falling back to heap-buffered publishes"
1311                            );
1312                            None
1313                        }
1314                    },
1315                    None => {
1316                        warn!(
1317                            "invalid zenoh SHM pool size ({shm_pool_size}); \
1318                             falling back to heap-buffered publishes"
1319                        );
1320                        None
1321                    }
1322                }
1323            };
1324            (Some(session), provider, owned_runtime)
1325        };
1326
1327        // Declare output publishers and start the startup handshake *before*
1328        // `EventStream::init`, which blocks in the daemon's "all nodes ready"
1329        // barrier: markers must be in flight while we are parked there so that
1330        // consumers (whose subscriber callbacks ack them, also while parked)
1331        // can prove the routes. This ordering is what keeps cycles
1332        // (`a -> b -> a`) and self-loops from deadlocking — every node emits
1333        // markers before it waits on anyone, and no one blocks on acks.
1334        let (zenoh_publishers, startup_handshake) = match zenoh_session.as_ref() {
1335            Some(session) => {
1336                let routing = normalize_output_routing(output_routing, &run_config.outputs);
1337                let (publishers, ack_states) = declare_output_publishers(
1338                    session,
1339                    dataflow_id,
1340                    &node_id,
1341                    &run_config.outputs,
1342                    &routing,
1343                );
1344                let publishers = Arc::new(publishers);
1345                let handshake = StartupHandshake::start(
1346                    session,
1347                    dataflow_id,
1348                    &node_id,
1349                    &publishers,
1350                    ack_states,
1351                    clock.clone(),
1352                );
1353                (publishers, Some(handshake))
1354            }
1355            None => (Arc::new(HashMap::new()), None),
1356        };
1357
1358        let event_stream = EventStream::init(
1359            dataflow_id,
1360            &node_id,
1361            &daemon_communication,
1362            input_config,
1363            &run_config.input_types,
1364            clock.clone(),
1365            write_events_to,
1366            zenoh_session.as_ref(),
1367        )
1368        .wrap_err("failed to init event stream")?;
1369
1370        // The barrier has released: every static consumer is subscribed and
1371        // acking, so the handshake now gets its bounded window. This settles
1372        // every output's transport — acked onto direct zenoh, or frozen on the
1373        // daemon path — before user code sends its first message.
1374        if let Some(handshake) = &startup_handshake {
1375            handshake.settle(ZENOH_STARTUP_GRACE);
1376        }
1377        let control_channel =
1378            ControlChannel::init(dataflow_id, &node_id, &daemon_communication, clock.clone())
1379                .wrap_err("failed to init control channel")?;
1380        let runtime_type_checks = match RuntimeTypeCheck::from_env() {
1381            RuntimeTypeCheck::Off => None,
1382            mode => {
1383                let registry = TypeRegistry::new();
1384                let mut checks = HashMap::new();
1385                for (id, urn) in &run_config.output_types {
1386                    match registry.resolve_arrow_type(urn) {
1387                        Some(dt) => {
1388                            checks.insert(id.clone(), dt);
1389                        }
1390                        None => {
1391                            if registry.resolve(urn).is_some() {
1392                                info!(
1393                                    "runtime type check: skipping complex type \"{urn}\" on output \"{id}\""
1394                                );
1395                            } else {
1396                                warn!(
1397                                    "runtime type check: unknown type URN \"{urn}\" on output \"{id}\""
1398                                );
1399                            }
1400                        }
1401                    }
1402                }
1403                Some((mode, checks))
1404            }
1405        };
1406
1407        let node = Self {
1408            id: node_id,
1409            dataflow_id,
1410            node_config: run_config.clone(),
1411            control_channel,
1412            clock,
1413            zenoh_session,
1414            zenoh_publishers,
1415            startup_handshake,
1416            zenoh_schema_publishers: HashMap::new(),
1417            zenoh_schema_state: HashMap::new(),
1418            sample_allocator: SampleAllocator {
1419                shm_provider: zenoh_shm_provider,
1420                zero_copy_threshold: zenoh_zero_copy_threshold,
1421            },
1422            large_send_diag_count: 0,
1423            dataflow_descriptor: serde_yaml::from_value(dataflow_descriptor),
1424            warned_unknown_output: BTreeSet::new(),
1425            interactive: false,
1426            restart_count,
1427            runtime_type_checks,
1428            _owned_runtime: owned_runtime,
1429            testing_daemon,
1430            testing_shutdown,
1431        };
1432
1433        if dynamic {
1434            // Env vars from the dataflow descriptor are already injected by the
1435            // daemon at spawn time via `Command::env()`.  Setting them here with
1436            // `std::env::set_var` would be undefined behavior because the tokio
1437            // multi-threaded runtime is already running and other threads may
1438            // call `std::env::var` concurrently.
1439            //
1440            // If the node was started outside the daemon (manual dynamic node),
1441            // the user must set the required env vars before launching the
1442            // process.
1443            if let Ok(descriptor) = &node.dataflow_descriptor
1444                && let Some(env_vars) = descriptor
1445                    .nodes
1446                    .iter()
1447                    .find(|n| n.id == node.id)
1448                    .and_then(|n| n.env.as_ref())
1449            {
1450                for key in env_vars.keys() {
1451                    if std::env::var(key).is_err() {
1452                        warn!(
1453                            "env var `{key}` declared in dataflow descriptor is not set; \
1454                                 it should have been injected by the daemon at spawn time"
1455                        );
1456                    }
1457                }
1458            }
1459        }
1460
1461        Ok((node, event_stream))
1462    }
1463
1464    /// Check whether `output_id` is declared as an output of this node.
1465    ///
1466    /// Returns `true` if the output is declared (or this node is `interactive`,
1467    /// which has no static output declaration); `false` and emits a one-time
1468    /// warning if the output is unknown. Public so callers building higher-level
1469    /// send helpers (e.g. the Python `send_output_raw` zero-copy path) can
1470    /// validate before allocating a buffer.
1471    pub fn validate_output(&mut self, output_id: &DataId) -> bool {
1472        if !self.node_config.outputs.contains(output_id) && !self.interactive {
1473            if !self.warned_unknown_output.contains(output_id) {
1474                warn!("Ignoring output `{output_id}` not in node's output list.");
1475                self.warned_unknown_output.insert(output_id.clone());
1476            }
1477            false
1478        } else {
1479            true
1480        }
1481    }
1482
1483    /// Send raw data from the node to the other nodes.
1484    ///
1485    /// We take a closure as an input to enable zero copy on send.
1486    ///
1487    /// ```no_run
1488    /// use dora_node_api::{DoraNode, MetadataParameters};
1489    /// use dora_core::config::DataId;
1490    ///
1491    /// let (mut node, mut events) = DoraNode::init_from_env().expect("Could not init node.");
1492    ///
1493    /// let output = DataId::from("output_id".to_owned());
1494    ///
1495    /// let data: &[u8] = &[0, 1, 2, 3];
1496    /// let parameters = MetadataParameters::default();
1497    ///
1498    /// node.send_output_raw(
1499    ///    output,
1500    ///    parameters,
1501    ///    data.len(),
1502    ///    |out| {
1503    ///         out.copy_from_slice(data);
1504    ///     }).expect("Could not send output");
1505    /// ```
1506    ///
1507    /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
1508    /// configuration file.
1509    pub fn send_output_raw<F>(
1510        &mut self,
1511        output_id: DataId,
1512        parameters: MetadataParameters,
1513        data_len: usize,
1514        data: F,
1515    ) -> NodeResult<()>
1516    where
1517        F: FnOnce(&mut [u8]),
1518    {
1519        if !self.validate_output(&output_id) {
1520            return Ok(());
1521        };
1522        // The receiver expects a self-describing Arrow IPC stream. Build it in
1523        // place: pre-write the UInt8 IPC header into the (shared-memory) sample,
1524        // then let the caller write their bytes straight into the data region —
1525        // zero payload copies (and the SHM sample is moved into zenoh's `put`).
1526        // Prepare the UInt8 IPC header once, then size and fill the sample from
1527        // it — avoids rebuilding the layout + IPC headers for the length query.
1528        let prepared = ipc_encode::PreparedUint8Ipc::new(data_len)
1529            .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
1530        let mut sample = self.allocate_data_sample(prepared.byte_len())?;
1531        let offset = prepared
1532            .encode_header_into(&mut sample)
1533            .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
1534        data(&mut sample[offset..offset + data_len]);
1535
1536        let mut parameters = parameters;
1537        parameters.insert(
1538            FRAMING.to_string(),
1539            Parameter::String(FRAMING_ARROW_IPC.to_string()),
1540        );
1541        self.send_output_sample(output_id, parameters, Some(sample))
1542    }
1543
1544    /// Sends the given Arrow array as an output message.
1545    ///
1546    /// This is the recommended way to emit data from a node: pass any value that
1547    /// implements [`IntoArrow`] (primitives, `Vec<T>`, `&str`, an Arrow array,
1548    /// …) and dora moves it into shared memory for an efficient, near-zero-copy
1549    /// transfer to downstream nodes.
1550    ///
1551    /// Uses shared memory for efficient data transfer if suitable. This method
1552    /// might copy the message once to move it to shared memory.
1553    ///
1554    /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
1555    /// configuration file.
1556    ///
1557    /// # Errors
1558    ///
1559    /// Returns [`NodeError::Output`] if the payload cannot be Arrow-IPC encoded, or if runtime type
1560    /// checking is enabled in error mode (`DORA_RUNTIME_TYPE_CHECK=error`) and the array's Arrow
1561    /// type does not match the output's declared type. An `output_id` that is not declared as an
1562    /// output is *not* an error — the call is ignored and returns `Ok`.
1563    ///
1564    /// ```no_run
1565    /// use dora_node_api::{DoraNode, MetadataParameters};
1566    /// use dora_core::config::DataId;
1567    ///
1568    /// let (mut node, _events) = DoraNode::init_from_env()?;
1569    ///
1570    /// let output = DataId::from("output_id".to_owned());
1571    /// let parameters = MetadataParameters::default();
1572    ///
1573    /// node.send_output(output, parameters, vec![1.0f32, 2.0, 3.0])?;
1574    /// # Ok::<(), eyre::Report>(())
1575    /// ```
1576    pub fn send_output(
1577        &mut self,
1578        output_id: DataId,
1579        parameters: MetadataParameters,
1580        data: impl IntoArrow,
1581    ) -> NodeResult<()> {
1582        if !self.validate_output(&output_id) {
1583            return Ok(());
1584        };
1585
1586        let data = data.into_arrow();
1587        let arrow_array = dora_arrow_convert::internal::array_ref(&data).to_data();
1588        self.check_output_type(&output_id, arrow_array.data_type(), &parameters)?;
1589
1590        let encoded = self.sample_allocator.encode_arrow_data(&arrow_array)?;
1591        self.send_encoded_unchecked(output_id, parameters, encoded.sample)
1592    }
1593
1594    /// Send a payload that has already been IPC-encoded into a dora-owned
1595    /// [`EncodedSample`] by [`SampleAllocator::encode_arrow`].
1596    ///
1597    /// This is the entry point the runtime uses for operator outputs: the
1598    /// operator thread does the encoding so that no memory owned by the
1599    /// operator's language runtime is ever released on the node's thread — see
1600    /// [`SampleAllocator`] (dora-rs/dora#2742).
1601    ///
1602    /// Like [`send_output`](Self::send_output), an `output_id` that is not a
1603    /// declared output is ignored (returns `Ok`).
1604    ///
1605    /// # Errors
1606    ///
1607    /// Returns [`NodeError::Output`] if runtime type checking is enabled in error mode
1608    /// (`DORA_RUNTIME_TYPE_CHECK=error`) and the sample's Arrow type does not match the output's
1609    /// declared type.
1610    pub fn send_output_encoded(
1611        &mut self,
1612        output_id: DataId,
1613        parameters: MetadataParameters,
1614        encoded: EncodedSample,
1615    ) -> NodeResult<()> {
1616        if !self.validate_output(&output_id) {
1617            return Ok(());
1618        };
1619        self.check_output_type(&output_id, &encoded.data_type, &parameters)?;
1620        self.send_encoded_unchecked(output_id, parameters, encoded.sample)
1621    }
1622
1623    /// Tag an already-encoded sample as an Arrow IPC stream and send it. The
1624    /// caller has already run `validate_output` and `check_output_type`.
1625    fn send_encoded_unchecked(
1626        &mut self,
1627        output_id: DataId,
1628        mut parameters: MetadataParameters,
1629        sample: DataSample,
1630    ) -> NodeResult<()> {
1631        parameters.insert(
1632            FRAMING.to_string(),
1633            Parameter::String(FRAMING_ARROW_IPC.to_string()),
1634        );
1635
1636        self.send_output_sample(output_id, parameters, Some(sample))
1637            .wrap_err("failed to send output")?;
1638
1639        Ok(())
1640    }
1641
1642    /// Runtime type check (only when `DORA_RUNTIME_TYPE_CHECK` is set).
1643    ///
1644    /// Skips the check when this message carries pattern metadata
1645    /// (`request_id`, `goal_id`, or `goal_status`). Service, action, and
1646    /// streaming patterns legitimately multiplex multiple Arrow schemas through
1647    /// a single output — a service server may reply with different response
1648    /// shapes for different request types — so a single declared Arrow type
1649    /// cannot cover all variants. Non-pattern messages still get full
1650    /// validation (dora-rs/adora#150).
1651    fn check_output_type(
1652        &self,
1653        output_id: &DataId,
1654        actual: &arrow_schema::DataType,
1655        parameters: &MetadataParameters,
1656    ) -> NodeResult<()> {
1657        if let Some((mode, checks)) = &self.runtime_type_checks
1658            && let Some(expected) = checks.get(output_id)
1659            && !carries_pattern_correlation(parameters)
1660            && actual != expected
1661        {
1662            let msg =
1663                format!("output \"{output_id}\": expected Arrow type {expected:?}, got {actual:?}");
1664            match mode {
1665                RuntimeTypeCheck::Error => {
1666                    return Err(NodeError::Output(msg));
1667                }
1668                RuntimeTypeCheck::Warn => {
1669                    warn!("type mismatch: {msg}");
1670                }
1671                RuntimeTypeCheck::Off => unreachable!(),
1672            }
1673        }
1674        Ok(())
1675    }
1676
1677    /// Send the given raw byte data as output.
1678    ///
1679    /// Might copy the data once to move it into shared memory. `data_len` must equal `data.len()`;
1680    /// the allocated sample is sized from `data_len` and the payload is copied from `data`.
1681    ///
1682    /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
1683    /// configuration file.
1684    ///
1685    /// # Errors
1686    ///
1687    /// Returns [`NodeError::Output`] if `data_len` does not equal `data.len()` (which would
1688    /// otherwise panic in the internal `copy_from_slice`).
1689    pub fn send_output_bytes(
1690        &mut self,
1691        output_id: DataId,
1692        parameters: MetadataParameters,
1693        data_len: usize,
1694        data: &[u8],
1695    ) -> NodeResult<()> {
1696        if !self.validate_output(&output_id) {
1697            return Ok(());
1698        };
1699        // `send_output_raw` allocates a `data_len`-byte sample and the closure
1700        // below copies `data` into it. A mismatch would otherwise panic deep
1701        // inside `copy_from_slice` ("source slice length .. does not match
1702        // destination slice length .."); return a clear error instead.
1703        if data.len() != data_len {
1704            return Err(NodeError::Output(format!(
1705                "send_output_bytes: data_len ({data_len}) does not match data.len() ({})",
1706                data.len()
1707            )));
1708        }
1709        self.send_output_raw(output_id, parameters, data_len, |sample| {
1710            sample.copy_from_slice(data)
1711        })
1712    }
1713
1714    /// Sends the given [`DataSample`] as output.
1715    ///
1716    /// The sample must already be a self-describing Arrow IPC stream (the
1717    /// `FRAMING_ARROW_IPC` parameter should be set). It is recommended to use a
1718    /// function like [`send_output`][Self::send_output] instead, which handles
1719    /// the encoding.
1720    ///
1721    /// Ignores the output if the given `output_id` is not specified as node output in the dataflow
1722    /// configuration file.
1723    pub fn send_output_sample(
1724        &mut self,
1725        output_id: DataId,
1726        mut parameters: MetadataParameters,
1727        sample: Option<DataSample>,
1728    ) -> NodeResult<()> {
1729        // `SCHEMA_HASH` is an internal wire-protocol key that only
1730        // `publish_schema_once` may set, and only for the schema-less batch it
1731        // belongs to. A stale value forwarded from an input's metadata (the
1732        // receive path strips it, but a recorded/hand-built parameter map can
1733        // still carry one) would make receivers route this output's full
1734        // self-describing stream to the schema-once decoder, hash-mismatch, and
1735        // silently drop it (dora-rs/dora#2366 review).
1736        parameters.remove(SCHEMA_HASH);
1737        // Auto-inject OpenTelemetry trace context when telemetry is enabled.
1738        // Uses the ambient OTel context, which is populated when the tracing
1739        // subscriber has an OpenTelemetry layer (e.g., via with_otlp_tracing).
1740        // Only trace/span IDs are propagated (via W3C TraceContext propagator).
1741        // OTel Baggage is NOT propagated to avoid leaking sensitive data across
1742        // node boundaries. If a user explicitly provides this key, it wins.
1743        #[cfg(feature = "tracing")]
1744        if !parameters.contains_key(crate::OPEN_TELEMETRY_CONTEXT) {
1745            let cx = opentelemetry::Context::current();
1746            let serialized = dora_tracing::telemetry::serialize_context(&cx);
1747            if !serialized.is_empty() {
1748                parameters.insert(
1749                    crate::OPEN_TELEMETRY_CONTEXT.to_string(),
1750                    crate::Parameter::String(serialized),
1751                );
1752            }
1753        }
1754
1755        let metadata = Metadata::from_parameters(self.clock.new_timestamp(), parameters);
1756
1757        let finalized = sample.map(|sample| sample.finalize());
1758
1759        // Diagnostic (dora-rs/dora#2742): the Windows nightly wedges a runtime's
1760        // main loop inside this function on the first large output and is
1761        // force-killed at the daemon's grace period, so nothing that only logs
1762        // *after* a hop returns can ever show where it parked. Log on entry to
1763        // each hop instead: the last line printed names the blocked call. Capped
1764        // at the first few large sends (the wedge is on the first), so a healthy
1765        // run pays one comparison per send and prints a handful of lines.
1766        // `warn!` so it survives the default stdout filter and reaches CI logs.
1767        let diag_bytes = finalized.as_ref().map_or(0, |f| f.byte_len());
1768        let diag = diag_bytes >= self.sample_allocator.zero_copy_threshold
1769            && self.large_send_diag_count < LARGE_SEND_DIAG_LIMIT;
1770        if diag {
1771            self.large_send_diag_count += 1;
1772        }
1773
1774        // How a data-plane message should be delivered.
1775        enum Delivery {
1776            /// zenoh delivered the payload (or it was consumed by a failed SHM
1777            /// put); only the daemon's control-plane state needs syncing.
1778            Zenoh,
1779            /// Deliver via the daemon control channel. `None` is a metadata-only
1780            /// message with no payload.
1781            Daemon(Option<DataMessage>),
1782        }
1783
1784        // Publish via direct zenoh only when the output may take the direct
1785        // path: its publisher exists and the startup handshake has proven its
1786        // routes — every required consumer acked a marker (see
1787        // `StartupHandshake`). Everything else takes the reliable daemon path:
1788        // no zenoh session (interactive/testing mode), an output the daemon
1789        // pinned there (a consumer on another daemon needs inter-daemon
1790        // forwarding, which only daemon-path sends feed — #2738), or an output
1791        // whose handshake did not complete before `init` returned and is
1792        // therefore frozen there for the run. An SHM-backed sample is moved
1793        // straight into zenoh's `put` (no extra copy); only the daemon path
1794        // copies it out into a `DataMessage::Vec`.
1795        let delivery = match finalized {
1796            Some(finalized) if self.output_direct_ready(&output_id) => {
1797                tracing::trace!(
1798                    output = %output_id,
1799                    size = finalized.byte_len(),
1800                    "publishing via zenoh"
1801                );
1802                if diag {
1803                    warn!(
1804                        "output `{output_id}`: {diag_bytes} B -> zenoh direct path \
1805                         (dora-rs/dora#2742 diagnostic)"
1806                    );
1807                }
1808                match self.zenoh_publish(&output_id, &metadata, finalized, diag) {
1809                    Ok(PublishOutcome::Published) => Delivery::Zenoh,
1810                    Ok(PublishOutcome::NotPublished(sample)) => {
1811                        Delivery::Daemon(Some(sample.into_data_message()))
1812                    }
1813                    Err(e) => {
1814                        tracing::warn!(
1815                            "zenoh publish failed ({e}); message dropped \
1816                             (SHM payload consumed, no daemon fallback)"
1817                        );
1818                        Delivery::Zenoh
1819                    }
1820                }
1821            }
1822            Some(finalized) => {
1823                if diag {
1824                    warn!(
1825                        "output `{output_id}`: {diag_bytes} B -> daemon path \
1826                         (dora-rs/dora#2742 diagnostic)"
1827                    );
1828                }
1829                Delivery::Daemon(Some(finalized.into_data_message()))
1830            }
1831            None => Delivery::Daemon(None),
1832        };
1833
1834        match delivery {
1835            Delivery::Zenoh => {
1836                // Keep the daemon's control-plane state in sync (input
1837                // deadlines, circuit-breaker recovery) without duplicating the
1838                // data payload that zenoh already delivered.
1839                if diag {
1840                    warn!(
1841                        "output `{output_id}`: entering report_output_sent \
1842                         (dora-rs/dora#2742 diagnostic)"
1843                    );
1844                }
1845                self.control_channel
1846                    .report_output_sent(output_id.clone(), metadata)
1847                    .wrap_err_with(|| format!("failed to report output {output_id}"))?;
1848            }
1849            Delivery::Daemon(data) => {
1850                // The daemon/TCP path serializes the whole message; an oversized
1851                // IPC payload would otherwise fail deep in the transport with a
1852                // generic error. Reject it here with a clear, output-specific
1853                // message. Large payloads are expected to reach a zenoh
1854                // subscriber instead, which has no such limit.
1855                if let Some(DataMessage::Vec(v)) = &data
1856                    && v.len() > dora_message::MAX_MESSAGE_BYTES
1857                {
1858                    return Err(NodeError::Output(format!(
1859                        "output \"{output_id}\": IPC-encoded message is {} bytes, exceeding \
1860                         the {}-byte daemon transport limit (the output is on the daemon \
1861                         path: pinned for a consumer only forwarding can reach, its \
1862                         startup handshake did not complete, or no zenoh route is \
1863                         available)",
1864                        v.len(),
1865                        dora_message::MAX_MESSAGE_BYTES,
1866                    )));
1867                }
1868                if diag {
1869                    warn!(
1870                        "output `{output_id}`: entering control_channel.send_message \
1871                         (dora-rs/dora#2742 diagnostic)"
1872                    );
1873                }
1874                self.control_channel
1875                    .send_message(output_id.clone(), metadata, data)
1876                    .wrap_err_with(|| format!("failed to send output {output_id}"))?;
1877            }
1878        }
1879
1880        Ok(())
1881    }
1882
1883    /// Report the given outputs IDs as closed.
1884    ///
1885    /// The node is not allowed to send more outputs with the closed IDs.
1886    ///
1887    /// Closing outputs early can be helpful to receivers.
1888    ///
1889    /// # Errors
1890    ///
1891    /// Returns [`NodeError::Output`] if any id is not a declared output of this node. Unlike
1892    /// [`send_output`](Self::send_output), which silently ignores unknown outputs, this validates
1893    /// the whole batch *before* closing any output, so on error none of them are closed.
1894    pub fn close_outputs(&mut self, outputs_ids: Vec<DataId>) -> NodeResult<()> {
1895        // Validate the whole batch before mutating any local state. Removing
1896        // outputs eagerly would leave the node's local output set out of sync
1897        // with the daemon if a later id is unknown: the early ones would be
1898        // gone locally, yet `report_closed_outputs` is skipped on error so the
1899        // daemon never learns about them.
1900        for output_id in &outputs_ids {
1901            if !self.node_config.outputs.contains(output_id) {
1902                return Err(NodeError::Output(format!("unknown output {output_id}")));
1903            }
1904        }
1905        for output_id in &outputs_ids {
1906            self.node_config.outputs.remove(output_id);
1907        }
1908
1909        self.control_channel
1910            .report_closed_outputs(outputs_ids)
1911            .wrap_err("failed to report closed outputs to daemon")?;
1912
1913        Ok(())
1914    }
1915
1916    /// Whether `output_id` may take the direct zenoh path: its publisher exists
1917    /// (declared at init, not pinned to the daemon path) and the startup
1918    /// handshake proved its routes — see [`StartupHandshake`].
1919    ///
1920    /// Constant for the node's lifetime — see [`wait_for_grace`].
1921    fn output_direct_ready(&self, output_id: &DataId) -> bool {
1922        self.zenoh_publishers
1923            .get(output_id)
1924            .is_some_and(|output| output.ready.load(Ordering::Relaxed))
1925    }
1926
1927    /// Publish data directly via zenoh (node-to-node, bypassing daemon for data).
1928    /// Uses SHM for zero-copy when possible, falls back to heap buffer.
1929    ///
1930    /// The publisher was declared at init (see [`declare_output_publishers`]) with
1931    /// `express(true)` to bypass zenoh's adaptive batch timer and with
1932    /// `Priority::RealTime` so data-plane messages don't share queues with bulk
1933    /// traffic. Its routes were already proven by the startup handshake
1934    /// ([`StartupHandshake`]) before [`Self::output_direct_ready`] let the send
1935    /// take this path, so the first send here cannot be dropped for a
1936    /// not-yet-established subscription.
1937    ///
1938    /// `diag` enables the per-hop entry logging described in
1939    /// [`Self::send_output_sample`] (dora-rs/dora#2742); it is only ever set for
1940    /// the first few large sends of a node's lifetime.
1941    fn zenoh_publish(
1942        &mut self,
1943        output_id: &DataId,
1944        metadata: &Metadata,
1945        finalized: FinalizedSample,
1946        diag: bool,
1947    ) -> eyre::Result<PublishOutcome> {
1948        use zenoh::Wait;
1949
1950        // Every failure *before* the payload is moved into `put` returns the
1951        // sample as `NotPublished` so the caller can still deliver it via the
1952        // daemon. Only a failed `put` of an SHM buffer (which consumes it)
1953        // returns `Err` — that is the single non-recoverable case.
1954        //
1955        // No publisher means there is no zenoh session, its declaration failed
1956        // at init, or the output is pinned to the daemon path: fall back to the
1957        // reliable daemon path (defense in depth — `output_direct_ready`
1958        // already gates the caller).
1959        let Some(DirectOutput { publisher, .. }) = self.zenoh_publishers.get(output_id) else {
1960            return Ok(PublishOutcome::NotPublished(finalized));
1961        };
1962        let session = self
1963            .zenoh_session
1964            .as_ref()
1965            .expect("a declared publisher implies a zenoh session");
1966
1967        // Serialize metadata as zenoh attachment.
1968        let metadata_bytes = match dora_message::encode(metadata) {
1969            Ok(bytes) => bytes,
1970            Err(e) => {
1971                tracing::warn!(output = %output_id, "failed to serialize metadata ({e}); falling back to daemon path");
1972                return Ok(PublishOutcome::NotPublished(finalized));
1973            }
1974        };
1975
1976        match finalized {
1977            // The producer already wrote into shared memory. Move the SHM
1978            // buffer straight into `put` — no realloc, no copy. This is the
1979            // path that eliminates the former heap-to-SHM second copy.
1980            //
1981            // On a put error the buffer has been consumed and cannot be
1982            // recovered for the daemon fallback, so this returns an error
1983            // (the caller logs and drops the message). This is a deliberate
1984            // trade-off for zero-copy on the common matched-subscriber path.
1985            // Producer-constructed SHM sample: `put` *moves* (consumes) the SHM
1986            // buffer, so — unlike the borrowed-heap `Vec` arm below — there is no
1987            // intact payload left to retry on error. A put failure is therefore
1988            // best-effort: the message is dropped (the caller logs it). This is
1989            // the deliberate, accepted trade-off for the zero-copy large-output
1990            // path, not an oversight.
1991            FinalizedSample::Shm(sbuf) => {
1992                if diag {
1993                    tracing::warn!(
1994                        "output `{output_id}`: entering zenoh put of an SHM buffer \
1995                         (dora-rs/dora#2742 diagnostic)"
1996                    );
1997                }
1998                publisher
1999                    .put(sbuf)
2000                    .attachment(&metadata_bytes[..])
2001                    .wait()
2002                    .map_err(|e| eyre::eyre!("zenoh SHM publish failed: {e}"))?;
2003                Ok(PublishOutcome::Published)
2004            }
2005            // Heap payload. At or above the threshold, copy once into a fresh
2006            // SHM buffer so local subscribers still get zero-copy delivery;
2007            // below it, a heap-buffered put is cheaper than a full SHM page.
2008            // The heap buffer is only borrowed, so any put error can fall back
2009            // to the daemon path with the payload intact.
2010            FinalizedSample::Vec(avec) => {
2011                if avec.len() >= self.sample_allocator.zero_copy_threshold
2012                    && let Some(provider) = &self.sample_allocator.shm_provider
2013                {
2014                    use zenoh::shm::GarbageCollect;
2015                    // Non-blocking: garbage-collect freed chunks and allocate, but
2016                    // do NOT block waiting for the pool to drain. Under a burst of
2017                    // large messages the zero-copy receiver pins each segment for
2018                    // the whole receive pipeline, so the pool can be momentarily
2019                    // exhausted; `BlockOn` would then sleep 1 ms per retry (zenoh
2020                    // 1.8 has no alloc signalling yet), throttling throughput to
2021                    // ~1k msg/s. Falling back to a heap-buffered put instead keeps
2022                    // the producer moving (PR #2366).
2023                    if diag {
2024                        tracing::warn!(
2025                            "output `{output_id}`: entering SHM alloc of {} B \
2026                             (dora-rs/dora#2742 diagnostic)",
2027                            avec.len()
2028                        );
2029                    }
2030                    match provider
2031                        .alloc(avec.len())
2032                        .with_policy::<GarbageCollect>()
2033                        .wait()
2034                    {
2035                        Ok(mut sbuf) => {
2036                            // Mirror the guard in `allocate_data_sample`: only
2037                            // copy into the SHM buffer when it is exactly the
2038                            // requested size. zenoh 1.8 guarantees the logical
2039                            // length matches the request, but `copy_from_slice`
2040                            // requires equal lengths and would panic on the
2041                            // node's send thread if a future provider ever
2042                            // over-allocated. Fall through to the reliable
2043                            // daemon path instead of risking that panic.
2044                            if sbuf.as_mut().len() == avec.len() {
2045                                sbuf.as_mut().copy_from_slice(&avec);
2046                                if diag {
2047                                    tracing::warn!(
2048                                        "output `{output_id}`: entering zenoh put of a \
2049                                         copied SHM buffer (dora-rs/dora#2742 diagnostic)"
2050                                    );
2051                                }
2052                                return match publisher
2053                                    .put(sbuf)
2054                                    .attachment(&metadata_bytes[..])
2055                                    .wait()
2056                                {
2057                                    Ok(()) => Ok(PublishOutcome::Published),
2058                                    Err(e) => {
2059                                        tracing::warn!(
2060                                            "zenoh SHM publish failed ({e}); \
2061                                             falling back to daemon path"
2062                                        );
2063                                        Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)))
2064                                    }
2065                                };
2066                            }
2067                            tracing::debug!(
2068                                "zenoh SHM alloc returned {} bytes for a {}-byte \
2069                                 request; using daemon path",
2070                                sbuf.as_ref().len(),
2071                                avec.len()
2072                            );
2073                        }
2074                        Err(e) => {
2075                            tracing::debug!("SHM alloc failed ({e}), using heap buffer");
2076                        }
2077                    }
2078                }
2079
2080                // A large payload that did not make it into SHM (no provider, or
2081                // the pool was momentarily full) must NOT be published over the
2082                // zenoh data plane: a payload larger than the transport batch
2083                // size is fragmented, and the express/`Drop` data publisher
2084                // silently drops fragmented messages — `put` reports success but
2085                // the subscriber never receives them (PR #2366). Route it via the
2086                // reliable daemon path instead (TCP, up to `MAX_MESSAGE_BYTES`).
2087                // Only sub-threshold payloads, which fit a single batch and never
2088                // fragment, take the zenoh heap put below.
2089                if avec.len() >= self.sample_allocator.zero_copy_threshold {
2090                    return Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)));
2091                }
2092
2093                // Only sub-threshold (single-batch, never-fragmented) payloads
2094                // reach this point — large payloads were routed to the daemon
2095                // path above. Apply the schema-once optimization to small
2096                // messages with a stable Arrow schema: publish the schema on the
2097                // `@schema` subtopic (only on change) and send just the
2098                // schema-less batch on the data topic, tagged with the schema
2099                // hash so the receiver matches it to the decoder primed from the
2100                // subtopic.
2101                //
2102                // The message that (re)publishes the schema — the output's first,
2103                // every schema change, any message after a failed `@schema` put,
2104                // and a periodic refresh — is itself sent as a full
2105                // self-describing stream (`publish_schema_once` returns `None`
2106                // for it). It decodes standalone and primes receivers in-band,
2107                // in data-plane order, so the express batch can never outrun its
2108                // own schema (the `@schema` plane's non-express `Block` publish
2109                // otherwise loses that race) and a one-shot output cannot lose
2110                // its only message.
2111                //
2112                // Service/action request-reply messages (carrying
2113                // `request_id`/`goal_id`/`goal_status`) are excluded: a server
2114                // legitimately multiplexes multiple response schemas through one
2115                // output, interleaved per request, and each per-message schema
2116                // change would force a full stream + `@schema` publish anyway.
2117                // Sending them as full self-describing streams (the pre-PR
2118                // behavior) makes each message decode standalone regardless of
2119                // schema order, at the cost of ~400 B of framing per message —
2120                // acceptable for these request/reply-rate patterns.
2121                //
2122                // Streaming (`session_id`/`segment_id`) is deliberately NOT
2123                // excluded: every chunk of a stream shares one schema, so
2124                // schema-once primes once and each chunk reuses it — streaming is
2125                // the high-rate small-message case schema-once exists for. A
2126                // schema change at a segment boundary is just the one-time
2127                // re-prime window any schema-once output has, not the per-message
2128                // alternation that makes service/action lossy.
2129                //
2130                // `schema_once` is bound here, not inside the match, so its
2131                // attachment bytes outlive the `put` below.
2132                let schema_once = if schema_once_eligible(
2133                    avec.len(),
2134                    self.sample_allocator.zero_copy_threshold,
2135                    &metadata.parameters,
2136                ) {
2137                    publish_schema_once(
2138                        &mut self.zenoh_schema_publishers,
2139                        &mut self.zenoh_schema_state,
2140                        session,
2141                        self.dataflow_id,
2142                        &self.id,
2143                        output_id,
2144                        &avec,
2145                        metadata,
2146                    )
2147                } else {
2148                    None
2149                };
2150                // Fall back to a full standalone stream if the batch slice can't
2151                // be taken (a real IPC stream always can — defensive).
2152                let (payload, attachment): (&[u8], &[u8]) = match schema_once.as_ref() {
2153                    Some(att) => match arrow_utils::ipc_encode::batch_slice(&avec) {
2154                        Some(slice) => (slice, att.as_slice()),
2155                        None => (&avec[..], &metadata_bytes[..]),
2156                    },
2157                    None => (&avec[..], &metadata_bytes[..]),
2158                };
2159                match publisher.put(payload).attachment(attachment).wait() {
2160                    Ok(()) => Ok(PublishOutcome::Published),
2161                    Err(e) => {
2162                        tracing::warn!("zenoh publish failed ({e}); falling back to daemon path");
2163                        // The zenoh data plane did not deliver this message. If
2164                        // it was the one meant to prime receivers in-band (the
2165                        // first message of a schema, or a periodic refresh),
2166                        // `publish_schema_once` already recorded its state and
2167                        // the following messages would go out schema-less with
2168                        // no delivered priming stream. Forget the output's
2169                        // schema-once state so the next message sends a full
2170                        // stream and re-publishes the schema. (A congestion
2171                        // drop reports `Ok` and stays undetectable — inherent
2172                        // to `CongestionControl::Drop`; the periodic refresh
2173                        // bounds that residual window.)
2174                        self.zenoh_schema_state.remove(output_id);
2175                        Ok(PublishOutcome::NotPublished(FinalizedSample::Vec(avec)))
2176                    }
2177                }
2178            }
2179        }
2180    }
2181
2182    /// Returns the ID of the node as specified in the dataflow configuration file.
2183    pub fn id(&self) -> &NodeId {
2184        &self.id
2185    }
2186
2187    /// Returns the unique identifier for the running dataflow instance.
2188    ///
2189    /// Dora assigns each dataflow instance a random identifier when started.
2190    pub fn dataflow_id(&self) -> &DataflowId {
2191        &self.dataflow_id
2192    }
2193
2194    /// Returns the input and output configuration of this node.
2195    pub fn node_config(&self) -> &NodeRunConfig {
2196        &self.node_config
2197    }
2198
2199    /// Returns the zero-copy SHM threshold in bytes.
2200    ///
2201    /// Outputs whose raw payload is at least this many bytes are published via
2202    /// zenoh shared memory (zero-copy for local subscribers); smaller outputs
2203    /// are published via zenoh with a heap-buffered put. Configured via the
2204    /// `DORA_ZERO_COPY_THRESHOLD` env var, defaulting to
2205    /// [`ZERO_COPY_THRESHOLD`].
2206    pub fn zero_copy_threshold(&self) -> usize {
2207        self.sample_allocator.zero_copy_threshold
2208    }
2209
2210    /// Returns true if this node was restarted after a previous exit or failure.
2211    ///
2212    /// Nodes can use this to decide whether to restore saved state or start fresh.
2213    pub fn is_restart(&self) -> bool {
2214        self.restart_count > 0
2215    }
2216
2217    /// Returns how many times this node has been restarted.
2218    ///
2219    /// Returns 0 on the first run, 1 after the first restart, etc.
2220    pub fn restart_count(&self) -> u32 {
2221        self.restart_count
2222    }
2223
2224    /// Returns the current timestamp from the node's Hybrid Logical Clock.
2225    ///
2226    /// This generates a new HLC timestamp, which combines the physical
2227    /// wall-clock time with a logical counter to ensure uniqueness and
2228    /// monotonicity even across nodes. The HLC is the same clock dora
2229    /// stamps every outgoing message with, so this is the right value
2230    /// to subtract from an input event's `metadata.timestamp` when
2231    /// measuring per-event processing latency — using
2232    /// `std::time::SystemTime::now()` instead would mix two unrelated
2233    /// clocks and give meaningless results across daemons.
2234    pub fn timestamp(&self) -> uhlc::Timestamp {
2235        self.clock.new_timestamp()
2236    }
2237
2238    /// Send a structured log message.
2239    ///
2240    /// Outputs a JSONL line to stdout that the daemon parses automatically.
2241    /// Works with `min_log_level` filtering and `send_logs_as` routing.
2242    ///
2243    /// `level` should be one of: `"error"`, `"warn"`, `"info"`, `"debug"`, `"trace"`.
2244    /// Unknown levels default to `"info"`.
2245    pub fn log(&self, level: &str, message: &str, target: Option<&str>) {
2246        self.log_with_fields(level, message, target, None);
2247    }
2248
2249    /// Maximum serialized size of the log `fields` object before it is
2250    /// dropped (60 KB). Matches the downstream 64 KB parse limit with headroom
2251    /// for the message envelope. Measured on the serialized JSON (see
2252    /// [`log_fields_within_budget`]), not the raw key/value byte sum.
2253    const MAX_LOG_FIELDS_BYTES: usize = 60 * 1024;
2254
2255    /// Send a structured log message with optional key-value fields.
2256    ///
2257    /// Like [`log`](Self::log), but accepts additional structured fields that
2258    /// are included in the JSON payload and preserved through `send_logs_as`.
2259    pub fn log_with_fields(
2260        &self,
2261        level: &str,
2262        message: &str,
2263        target: Option<&str>,
2264        fields: Option<&std::collections::BTreeMap<String, String>>,
2265    ) {
2266        let level_str = match level.to_lowercase().as_str() {
2267            "error" => "error",
2268            "warn" | "warning" => "warn",
2269            "info" => "info",
2270            "debug" => "debug",
2271            "trace" => "trace",
2272            _ => "info",
2273        };
2274        let timestamp = chrono::Utc::now().to_rfc3339();
2275        let mut entry = serde_json::json!({
2276            "timestamp": timestamp,
2277            "level": level_str,
2278            "node_id": self.id.to_string(),
2279            "message": message,
2280        });
2281        if let Some(target) = target {
2282            entry["target"] = serde_json::Value::String(target.to_string());
2283        }
2284        if let Some(fields) = fields {
2285            match log_fields_within_budget(fields, Self::MAX_LOG_FIELDS_BYTES) {
2286                Some(value) => entry["fields"] = value,
2287                None => {
2288                    eprintln!("dora log: fields too large, dropping fields");
2289                    entry["fields_dropped"] = serde_json::Value::Bool(true);
2290                }
2291            }
2292        }
2293        match serde_json::to_string(&entry) {
2294            Ok(json) => println!("{json}"),
2295            Err(e) => eprintln!("dora log serialization error: {e}"),
2296        }
2297    }
2298
2299    /// Log an error message.
2300    pub fn log_error(&self, message: &str) {
2301        self.log("error", message, None);
2302    }
2303
2304    /// Log a warning message.
2305    pub fn log_warn(&self, message: &str) {
2306        self.log("warn", message, None);
2307    }
2308
2309    /// Log an info message.
2310    pub fn log_info(&self, message: &str) {
2311        self.log("info", message, None);
2312    }
2313
2314    /// Log a debug message.
2315    pub fn log_debug(&self, message: &str) {
2316        self.log("debug", message, None);
2317    }
2318
2319    /// Log a trace message.
2320    pub fn log_trace(&self, message: &str) {
2321        self.log("trace", message, None);
2322    }
2323
2324    // -----------------------------------------------------------------
2325    // Service / Action helpers
2326    // -----------------------------------------------------------------
2327
2328    /// Generate a new unique request/goal ID (UUID v7, time-ordered).
2329    ///
2330    /// Uses a per-thread monotonic counter context to guarantee uniqueness
2331    /// even when multiple IDs are generated within the same clock tick.
2332    pub fn new_request_id() -> String {
2333        thread_local! {
2334            static CTX: uuid::ContextV7 = const { uuid::ContextV7::new() };
2335        }
2336        CTX.with(|ctx| uuid::Uuid::new_v7(uuid::Timestamp::now(ctx)).to_string())
2337    }
2338
2339    /// Generate a new unique goal ID (UUID v7, time-ordered).
2340    ///
2341    /// This is an alias for [`new_request_id`](Self::new_request_id) that
2342    /// reads more naturally in action (goal/feedback/result) contexts.
2343    pub fn new_goal_id() -> String {
2344        Self::new_request_id()
2345    }
2346
2347    /// Send a service request, automatically injecting a `request_id` into the
2348    /// metadata parameters. Returns the generated request ID.
2349    ///
2350    /// Any existing `request_id` key in `parameters` is replaced.
2351    ///
2352    /// # Errors
2353    ///
2354    /// Propagates any error from [`send_output`](Self::send_output).
2355    pub fn send_service_request(
2356        &mut self,
2357        output_id: DataId,
2358        mut parameters: MetadataParameters,
2359        data: impl IntoArrow,
2360    ) -> NodeResult<String> {
2361        if parameters.contains_key(dora_message::metadata::REQUEST_ID) {
2362            tracing::warn!("send_service_request: caller-provided request_id will be overwritten");
2363        }
2364        let request_id = Self::new_request_id();
2365        parameters.insert(
2366            dora_message::metadata::REQUEST_ID.to_string(),
2367            dora_message::metadata::Parameter::String(request_id.clone()),
2368        );
2369        self.send_output(output_id, parameters, data)?;
2370        Ok(request_id)
2371    }
2372
2373    /// Send a service response. This is a semantic alias for [`send_output`](Self::send_output).
2374    ///
2375    /// The caller is expected to pass through the `request_id` parameter from
2376    /// the incoming request's metadata.
2377    pub fn send_service_response(
2378        &mut self,
2379        output_id: DataId,
2380        parameters: MetadataParameters,
2381        data: impl IntoArrow,
2382    ) -> NodeResult<()> {
2383        self.send_output(output_id, parameters, data)
2384    }
2385
2386    // -----------------------------------------------------------------
2387    // Streaming helpers
2388    // -----------------------------------------------------------------
2389
2390    /// Send a streaming segment chunk. Convenience wrapper around
2391    /// [`send_output`](Self::send_output) that builds metadata from the
2392    /// [`StreamSegment`] builder.
2393    ///
2394    /// # Errors
2395    ///
2396    /// Propagates any error from [`send_output`](Self::send_output).
2397    pub fn send_stream_chunk(
2398        &mut self,
2399        output_id: DataId,
2400        segment: &mut StreamSegment,
2401        fin: bool,
2402        data: impl IntoArrow,
2403    ) -> NodeResult<()> {
2404        self.send_output(output_id, segment.chunk(fin), data)
2405    }
2406
2407    /// Allocates a [`DataSample`] of the specified size.
2408    ///
2409    /// See [`SampleAllocator::allocate`] for the allocation strategy.
2410    pub fn allocate_data_sample(&mut self, data_len: usize) -> NodeResult<DataSample> {
2411        self.sample_allocator.allocate(data_len)
2412    }
2413
2414    /// A handle for building output samples off this node's thread.
2415    ///
2416    /// The runtime hands one to each operator thread so the operator can encode
2417    /// its payload into a dora-owned [`DataSample`] itself — see
2418    /// [`SampleAllocator`] for why that matters (dora-rs/dora#2742).
2419    pub fn sample_allocator(&self) -> SampleAllocator {
2420        self.sample_allocator.clone()
2421    }
2422
2423    /// Returns the full dataflow descriptor that this node is part of.
2424    ///
2425    /// This method returns the parsed dataflow YAML file.
2426    pub fn dataflow_descriptor(&self) -> NodeResult<&Descriptor> {
2427        match &self.dataflow_descriptor {
2428            Ok(d) => Ok(d),
2429            Err(err) => Err(NodeError::Data(format!(
2430                "failed to parse dataflow descriptor: {err}\n\n\
2431                    This might be caused by mismatched version numbers of dora \
2432                    daemon and the dora node API"
2433            ))),
2434        }
2435    }
2436
2437    /// Store an opaque value in the daemon's dataflow-scoped extension table.
2438    ///
2439    /// This is the seam for transports that live outside the dora tree: dora
2440    /// brokers the value's lifetime and nothing else — it never interprets
2441    /// `namespace`, `key` or `value`. See `docs/extensions.md`.
2442    ///
2443    /// The daemon remembers which nodes touched a key so that dropping it
2444    /// notifies them, and reclaims the entry when the dataflow ends or the
2445    /// storing node exits. Drain the notifications with
2446    /// [`event_stream::extensions::drain_dropped_keys`](crate::event_stream::extensions::drain_dropped_keys).
2447    pub fn extension_store(
2448        &mut self,
2449        namespace: impl Into<String>,
2450        key: impl Into<String>,
2451        value: Vec<u8>,
2452    ) -> Result<(), eyre::Error> {
2453        self.control_channel
2454            .extension_store(namespace.into(), key.into(), value)
2455    }
2456
2457    /// Read an opaque value back, optionally removing it in the same round trip.
2458    ///
2459    /// Returns `None` if the key is not in the table — never stored, or
2460    /// already dropped.
2461    pub fn extension_load(
2462        &mut self,
2463        namespace: impl Into<String>,
2464        key: impl Into<String>,
2465        remove: bool,
2466    ) -> Result<Option<Vec<u8>>, eyre::Error> {
2467        self.control_channel
2468            .extension_load(namespace.into(), key.into(), remove)
2469    }
2470
2471    /// Drop an opaque value, notifying every node that stored or loaded it.
2472    pub fn extension_drop(
2473        &mut self,
2474        namespace: impl Into<String>,
2475        key: impl Into<String>,
2476    ) -> Result<(), eyre::Error> {
2477        self.control_channel
2478            .extension_drop(namespace.into(), key.into())
2479    }
2480
2481    /// Send an opaque request to the extension registered under
2482    /// `namespace` on this node's daemon, and return its opaque reply.
2483    ///
2484    /// Companion to [`DoraNode::extension_store`] / [`DoraNode::extension_load`]:
2485    /// those broker a descriptor's lifetime, this one carries a call the
2486    /// extension's daemon half must service. dora interprets neither the
2487    /// namespace nor the bytes — see `docs/extensions.md`.
2488    pub fn extension_request(
2489        &mut self,
2490        namespace: impl Into<String>,
2491        payload: Vec<u8>,
2492    ) -> Result<Vec<u8>, eyre::Error> {
2493        self.control_channel
2494            .extension_request(namespace.into(), payload)
2495    }
2496}
2497
2498/// Return the serialized log `fields` object when it fits `limit`, else `None`.
2499///
2500/// The budget guards a downstream JSON-line parse limit, so it must measure
2501/// the *serialized* size: `"fields":{...}` adds structural bytes (quotes,
2502/// colons, commas) and JSON escaping — a value full of `"`/`\` doubles and
2503/// control characters expand ~6x via `\uXXXX`. Summing raw key/value byte
2504/// lengths can pass a map whose serialized form is well over the limit, which
2505/// the downstream parser then drops or truncates whole.
2506fn log_fields_within_budget(
2507    fields: &std::collections::BTreeMap<String, String>,
2508    limit: usize,
2509) -> Option<serde_json::Value> {
2510    // Count the serialized bytes without allocating a throwaway string, then
2511    // build the JSON value only when it fits.
2512    struct ByteCounter(usize);
2513    impl std::io::Write for ByteCounter {
2514        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2515            self.0 += buf.len();
2516            Ok(buf.len())
2517        }
2518        fn flush(&mut self) -> std::io::Result<()> {
2519            Ok(())
2520        }
2521    }
2522    let mut counter = ByteCounter(0);
2523    serde_json::to_writer(&mut counter, fields).ok()?;
2524    (counter.0 <= limit).then(|| serde_json::json!(fields))
2525}
2526
2527/// Builder for initializing a node with custom connection parameters.
2528///
2529/// Created via [`DoraNode::builder()`]. Callers who don't need a custom daemon
2530/// port should prefer [`DoraNode::init_from_env`] or
2531/// [`DoraNode::init_from_node_id`]. Setting [`node_id`](Self::node_id) selects
2532/// the dynamic-node path; otherwise [`build`](Self::build) falls back to
2533/// [`DoraNode::init_from_env`].
2534#[derive(Default)]
2535pub struct DoraNodeBuilder {
2536    node_id: Option<NodeId>,
2537    daemon_port: Option<u16>,
2538}
2539
2540impl DoraNodeBuilder {
2541    /// Set the node ID. Presence of a node ID selects the dynamic-node path.
2542    pub fn node_id(mut self, node_id: NodeId) -> Self {
2543        self.node_id = Some(node_id);
2544        self
2545    }
2546
2547    /// No-op kept for source compatibility with upstream dora 0.5.x
2548    /// [`#1591`](https://github.com/dora-rs/dora/pull/1591). Upstream gates the
2549    /// dynamic-node path on an explicit `.dynamic()` call; here, dynamic mode
2550    /// is selected by the presence of `node_id`, making the flag redundant.
2551    /// Kept so that `.node_id(id).dynamic().build()` written against upstream
2552    /// still compiles.
2553    #[inline]
2554    pub fn dynamic(self) -> Self {
2555        self
2556    }
2557
2558    /// Override the daemon port. When unset, the builder honours the
2559    /// `DORA_DAEMON_LOCAL_LISTEN_PORT` env var and falls back to
2560    /// `DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT`.
2561    pub fn daemon_port(mut self, port: u16) -> Self {
2562        self.daemon_port = Some(port);
2563        self
2564    }
2565
2566    /// Build and connect the node.
2567    pub fn build(self) -> NodeResult<(DoraNode, EventStream)> {
2568        let Some(node_id) = self.node_id else {
2569            return DoraNode::init_from_env();
2570        };
2571
2572        let port = self.daemon_port.unwrap_or_else(|| {
2573            match std::env::var(DORA_DAEMON_LOCAL_LISTEN_PORT_ENV) {
2574                Ok(p) => p.parse().unwrap_or_else(|e| {
2575                    tracing::warn!(
2576                        "invalid {DORA_DAEMON_LOCAL_LISTEN_PORT_ENV}={p:?}: {e}, using default port"
2577                    );
2578                    DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT
2579                }),
2580                Err(_) => DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT,
2581            }
2582        });
2583        let daemon_address = (LOCALHOST, port).into();
2584
2585        let mut channel =
2586            DaemonChannel::new_tcp(daemon_address).context("Could not connect to the daemon")?;
2587        let clock = Arc::new(uhlc::HLC::default());
2588
2589        let reply = channel
2590            .request(&Timestamped {
2591                inner: DaemonRequest::NodeConfig { node_id },
2592                timestamp: clock.new_timestamp(),
2593            })
2594            .wrap_err("failed to request node config from daemon")?;
2595
2596        match reply {
2597            DaemonReply::NodeConfig {
2598                result: Ok(node_config),
2599            } => DoraNode::init(node_config),
2600            DaemonReply::NodeConfig { result: Err(error) } => {
2601                let capped: String = error.chars().take(512).collect();
2602                Err(NodeError::Init(format!(
2603                    "failed to get node config from daemon: {capped}"
2604                )))
2605            }
2606            _ => Err(NodeError::Init("unexpected reply from daemon".into())),
2607        }
2608    }
2609}
2610
2611/// Runs `teardown` on a dedicated thread, waiting at most `timeout` for it to
2612/// complete. Returns `true` if the teardown finished in time. Panics in
2613/// `teardown` are contained and count as completion. If spawning the thread
2614/// fails, the teardown runs inline without a deadline.
2615///
2616/// On timeout the thread keeps running detached: everything moved into the
2617/// closure (zenoh sockets, SHM segments, the owned tokio runtime) is leaked
2618/// until process exit. That is acceptable for nodes dropped right before
2619/// exit; long-lived hosts (e.g. a Python interpreter dropping a node during
2620/// GC) inherit only the bounded delay instead of a permanent hang.
2621pub(crate) fn teardown_with_timeout(
2622    label: &str,
2623    timeout: Duration,
2624    teardown: impl FnOnce() + Send + 'static,
2625) -> bool {
2626    // The closure is handed over via a channel (instead of being captured by
2627    // the spawned closure) so that it stays available for the inline
2628    // fallback when spawning fails.
2629    let (work_tx, work_rx) = std::sync::mpsc::channel();
2630    let (done_tx, done_rx) = std::sync::mpsc::channel();
2631    let thread = std::thread::Builder::new()
2632        .name(format!("dora-teardown-{label}"))
2633        .spawn(move || {
2634            if let Ok(work) = work_rx.recv() {
2635                let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
2636            }
2637            let _ = done_tx.send(());
2638        });
2639    match thread {
2640        Ok(_) => {
2641            let _ = work_tx.send(teardown);
2642            done_rx.recv_timeout(timeout).is_ok()
2643        }
2644        Err(err) => {
2645            warn!("failed to spawn {label} teardown thread ({err}); running it inline");
2646            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(teardown));
2647            true
2648        }
2649    }
2650}
2651
2652impl Drop for DoraNode {
2653    fn drop(&mut self) {
2654        // The startup handshake's marker thread holds an `Arc` clone of the
2655        // publishers, so it must be stopped and joined before the publishers
2656        // are dropped for the undeclare below to see the last reference. Do
2657        // that join *inside* the bounded teardown: `shutdown()` sets the stop
2658        // flag but cannot interrupt an in-progress `publisher.put().wait()`, so
2659        // on a wedged zenoh net runtime the join could otherwise hang node
2660        // shutdown (and with it the daemon, which waits for `InputClosed`) —
2661        // the exact hang `teardown_with_timeout` exists to bound (#2425). The
2662        // consumer side already joins its acker thread under the same deadline.
2663        let startup_handshake = self.startup_handshake.take();
2664        // Tear down zenoh before notifying the daemon below, so that
2665        // daemon-signaled `InputClosed` cannot overtake in-flight zenoh data.
2666        let publishers = std::mem::take(&mut self.zenoh_publishers);
2667        let schema_publishers = std::mem::take(&mut self.zenoh_schema_publishers);
2668        let shm_provider = self.sample_allocator.shm_provider.take();
2669        let session = self.zenoh_session.take();
2670        let runtime = self._owned_runtime.take();
2671        if session.is_none() && shm_provider.is_none() && publishers.is_empty() {
2672            // no zenoh state (interactive/testing mode): drop inline. A node
2673            // without a zenoh session never has a handshake, but drop it here
2674            // too so this branch stays self-contained.
2675            drop(startup_handshake);
2676            drop(runtime);
2677        } else {
2678            // A wedged zenoh net runtime stalls `Session` close beyond its
2679            // 10s timeout and `Publisher` undeclare indefinitely, which would
2680            // hang node shutdown (and with it the daemon, which waits for
2681            // `InputClosed`). Bound the teardown with a deadline instead.
2682            let completed = teardown_with_timeout("zenoh", ZENOH_TEARDOWN_TIMEOUT, move || {
2683                // Stop + join the marker thread first (bounded by this
2684                // deadline), which releases its publishers `Arc` clone so the
2685                // `drop(publishers)` below holds the last reference and
2686                // undeclares them. Then the documented drop order: subscribers
2687                // (dropped with the handshake) and publishers (data + schema)
2688                // before the session, owned runtime last so async cleanup can
2689                // still run.
2690                if let Some(mut handshake) = startup_handshake {
2691                    handshake.shutdown();
2692                    // Undeclare the ack subscribers before the session below.
2693                    drop(std::mem::take(&mut handshake.ack_subscribers));
2694                }
2695                drop(publishers);
2696                drop(schema_publishers);
2697                drop(shm_provider);
2698                drop(session);
2699                drop(runtime);
2700            });
2701            if !completed {
2702                warn!(
2703                    "zenoh teardown timed out after {}s; continuing node shutdown",
2704                    ZENOH_TEARDOWN_TIMEOUT.as_secs()
2705                );
2706            }
2707        }
2708
2709        // close all outputs first to notify subscribers as early as possible
2710        //
2711        // Testing mode (dora-rs/dora#2855): signal shutdown *before* the
2712        // CloseOutputs handshake so a daemon thread sleeping inside
2713        // `next_event` wakes up, replies, and can then process Drop's requests.
2714        if let Some(shutdown) = &self.testing_shutdown {
2715            shutdown.store(true, Ordering::Relaxed);
2716        }
2717        if let Err(err) = self
2718            .control_channel
2719            .report_closed_outputs(
2720                std::mem::take(&mut self.node_config.outputs)
2721                    .into_iter()
2722                    .collect(),
2723            )
2724            .context("failed to close outputs on drop")
2725        {
2726            tracing::warn!("{err:?}")
2727        }
2728
2729        if let Err(err) = self.control_channel.report_outputs_done() {
2730            tracing::warn!("{err:?}")
2731        }
2732
2733        // Drop our channel sender and join the testing daemon. The daemon loop
2734        // exits after OutputsDone under shutdown even when EventStream still
2735        // holds a sender clone (dora-rs/dora#2855).
2736        if let Some(handle) = self.testing_daemon.take() {
2737            self.control_channel.close_channel();
2738            if handle.join().is_err() {
2739                tracing::warn!("testing daemon thread panicked");
2740            }
2741        }
2742        self.testing_shutdown = None;
2743    }
2744}
2745
2746/// A payload already encoded as an Arrow IPC stream in a dora-owned sample,
2747/// together with the Arrow type it was encoded from.
2748///
2749/// The two travel together so a consumer can still type-check the output after
2750/// the source array is gone — see [`DoraNode::send_output_encoded`].
2751#[derive(Debug)]
2752pub struct EncodedSample {
2753    sample: DataSample,
2754    data_type: arrow_schema::DataType,
2755}
2756
2757impl EncodedSample {
2758    /// A human-readable name for the Arrow type the payload was encoded from,
2759    /// e.g. `"UInt8"`.
2760    ///
2761    /// Returned as a `String` rather than an `arrow_schema::DataType` because
2762    /// `arrow-schema` is the one non-umbrella Arrow crate dora's public API
2763    /// used to name, and naming it would pin 1.x to a single Arrow major.
2764    /// For the real type, enable `arrow-v59` and use
2765    /// [`data_type`](Self::data_type).
2766    pub fn type_name(&self) -> String {
2767        format!("{:?}", self.data_type)
2768    }
2769
2770    /// The Arrow type the payload was encoded from.
2771    ///
2772    /// Gated on `arrow-v59` — dora's current internal Arrow major — because
2773    /// `arrow_schema::DataType` is an Arrow type. It is not returned as a
2774    /// dora-owned type-URN (`dora_core::types::TypeRegistry`) because the URN
2775    /// catalog only covers the standard scalar/struct types: nested lists,
2776    /// dictionaries, timestamps-with-timezone and unions have no URN, so a
2777    /// URN-returning accessor would be lossy for exactly the outputs whose
2778    /// type a caller most needs to inspect.
2779    #[cfg(feature = "arrow-v59")]
2780    pub fn data_type(&self) -> &arrow_schema::DataType {
2781        &self.data_type
2782    }
2783
2784    /// The encoded Arrow IPC stream.
2785    pub fn as_bytes(&self) -> &[u8] {
2786        &self.sample
2787    }
2788}
2789
2790/// Builds dora-owned output samples without borrowing the node.
2791///
2792/// ## Why this exists (dora-rs/dora#2742)
2793///
2794/// An operator runs on its own thread and hands its outputs to the runtime's
2795/// event loop. If what crosses that boundary is an Arrow array whose buffers
2796/// belong to the operator's language runtime — a `pyarrow` array wrapping a
2797/// numpy buffer, say — then the *runtime* ends up freeing them. Releasing a
2798/// numpy-backed buffer acquires the Python GIL (pyarrow's `NumPyBuffer`
2799/// destructor does `PyAcquireGIL`), so the runtime's event loop blocks for as
2800/// long as the operator holds the GIL. That made a node unable to observe
2801/// `Stop`, and the daemon force-killed it at the grace period.
2802///
2803/// Handing the operator thread an allocator instead lets it encode into memory
2804/// dora owns and release its own payload while it still holds the GIL. It is
2805/// not an extra copy: the IPC encode is the same single copy the node would
2806/// otherwise have made, just performed on the other side of the channel.
2807#[derive(Clone)]
2808pub struct SampleAllocator {
2809    shm_provider: Option<Arc<zenoh::shm::ShmProvider<zenoh::shm::PosixShmProviderBackend>>>,
2810    zero_copy_threshold: usize,
2811}
2812
2813impl SampleAllocator {
2814    /// Allocates a [`DataSample`] of the specified size.
2815    ///
2816    /// For payloads at or above the zero-copy threshold the buffer is allocated
2817    /// directly from the zenoh SHM provider (when available), so the producer
2818    /// writes straight into shared memory and publishing moves the buffer into
2819    /// zenoh's `put` without a further copy. Smaller payloads — or the case
2820    /// where no SHM provider exists (interactive/testing mode) — use a
2821    /// heap-allocated, 128-byte-aligned buffer; the SHM provider is
2822    /// page-aligned, so dedicating a full page to a small message is pure waste.
2823    pub fn allocate(&self, data_len: usize) -> NodeResult<DataSample> {
2824        if data_len >= self.zero_copy_threshold
2825            && let Some(provider) = &self.shm_provider
2826        {
2827            use zenoh::Wait;
2828            use zenoh::shm::GarbageCollect;
2829            // Non-blocking (see `zenoh_publish`): GC and allocate, but fall back
2830            // to a heap buffer rather than `BlockOn`-sleeping 1 ms when the pool
2831            // is momentarily full under a large-message burst. The heap buffer
2832            // costs one extra copy on publish but keeps the producer from
2833            // stalling, which is what regressed sustained throughput (PR #2366).
2834            match provider
2835                .alloc(data_len)
2836                .with_policy::<GarbageCollect>()
2837                .wait()
2838            {
2839                Ok(sbuf) => {
2840                    // Use the SHM buffer only when it is exactly the requested
2841                    // size — zenoh 1.8 guarantees this (the logical length
2842                    // matches the request even when the backing chunk is
2843                    // larger). If a future provider ever over-allocates, fall
2844                    // back to heap rather than expose or publish an oversized
2845                    // slice (`DataSample` has no length cap of its own).
2846                    if sbuf.as_ref().len() == data_len {
2847                        return Ok(DataSample {
2848                            storage: SampleStorage::Shm(sbuf),
2849                        });
2850                    }
2851                    tracing::debug!(
2852                        "zenoh SHM alloc returned {} bytes for a {data_len}-byte \
2853                         request; using heap",
2854                        sbuf.as_ref().len()
2855                    );
2856                }
2857                Err(e) => {
2858                    tracing::debug!("SHM alloc failed ({e}), using heap buffer");
2859                }
2860            }
2861        }
2862
2863        let avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, data_len);
2864        Ok(avec.into())
2865    }
2866
2867    /// Encodes `array` as a complete Arrow IPC stream into a freshly allocated
2868    /// sample. Uses the hand-rolled 1-copy fast path when the array type is
2869    /// eligible, falling back to the official writer (one extra copy) otherwise.
2870    ///
2871    /// The returned sample shares no memory with `array`, so the caller may —
2872    /// and, when the payload is owned by a foreign runtime, **must** — drop
2873    /// `array` on its own thread rather than let it travel to the node.
2874    pub fn encode_arrow(&self, array: &DoraArray) -> NodeResult<EncodedSample> {
2875        self.encode_arrow_data(&dora_arrow_convert::internal::array_ref(array).to_data())
2876    }
2877
2878    /// Same, for dora-internal callers that already hold an [`ArrayData`].
2879    pub(crate) fn encode_arrow_data(&self, array: &ArrayData) -> NodeResult<EncodedSample> {
2880        let sample = match ipc_encode::PreparedIpc::from_data(array) {
2881            Some(prepared) => {
2882                // Prepare once: size the sample from the prepared layout, then
2883                // encode into it — avoids rebuilding the layout + IPC headers.
2884                let mut sample = self.allocate(prepared.byte_len())?;
2885                prepared
2886                    .encode_into(&mut sample)
2887                    .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
2888                sample
2889            }
2890            None => {
2891                let bytes = ipc_encode::encode_ipc_to_vec_data(array)
2892                    .map_err(|e| NodeError::Output(format!("Arrow IPC encode: {e}")))?;
2893                let mut sample = self.allocate(bytes.len())?;
2894                sample.copy_from_slice(&bytes);
2895                sample
2896            }
2897        };
2898        Ok(EncodedSample {
2899            sample,
2900            data_type: array.data_type().clone(),
2901        })
2902    }
2903
2904    /// An allocator with no shared memory, so every sample is heap-backed.
2905    ///
2906    /// This is what a node without a zenoh session (interactive/testing mode)
2907    /// uses; it also lets callers that only need the encoding — tests, most
2908    /// obviously — build one without a live node.
2909    pub fn heap() -> Self {
2910        Self {
2911            shm_provider: None,
2912            zero_copy_threshold: ZERO_COPY_THRESHOLD,
2913        }
2914    }
2915}
2916
2917impl std::fmt::Debug for SampleAllocator {
2918    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2919        f.debug_struct("SampleAllocator")
2920            .field("shm", &self.shm_provider.is_some())
2921            .field("zero_copy_threshold", &self.zero_copy_threshold)
2922            .finish()
2923    }
2924}
2925
2926/// A data region suitable for sending as an output message.
2927///
2928/// `DataSample` implements the [`Deref`](std::ops::Deref) and
2929/// [`DerefMut`](std::ops::DerefMut) traits to read and write the mapped data.
2930///
2931/// The backing storage is either a heap buffer or — for payloads at or above
2932/// the zero-copy threshold when a zenoh SHM provider is available — a
2933/// zenoh-shared-memory buffer. Writing into an SHM-backed sample lets the
2934/// producer construct the message straight in shared memory, so publishing it
2935/// needs no further copy (the SHM buffer is moved directly into zenoh's `put`).
2936pub struct DataSample {
2937    storage: SampleStorage,
2938}
2939
2940/// Backing storage for a [`DataSample`]. Kept private so the public API never
2941/// exposes a zenoh SHM type; callers only ever see the `[u8]` view via
2942/// `Deref`/`DerefMut`.
2943enum SampleStorage {
2944    /// Heap-allocated, 128-byte-aligned buffer (used below the zero-copy
2945    /// threshold or when no SHM provider is available).
2946    Heap(AVec<u8, ConstAlign<128>>),
2947    /// Zenoh shared-memory buffer. The producer writes the payload directly
2948    /// into it and the buffer is later moved into the zenoh `put` without
2949    /// copying.
2950    Shm(zenoh::shm::ZShmMut),
2951}
2952
2953impl DataSample {
2954    /// Consume the sample into a [`FinalizedSample`] ready for transport.
2955    fn finalize(self) -> FinalizedSample {
2956        match self.storage {
2957            SampleStorage::Heap(buffer) => FinalizedSample::Vec(buffer),
2958            SampleStorage::Shm(sbuf) => FinalizedSample::Shm(sbuf),
2959        }
2960    }
2961}
2962
2963impl std::ops::Deref for DataSample {
2964    type Target = [u8];
2965
2966    fn deref(&self) -> &Self::Target {
2967        match &self.storage {
2968            SampleStorage::Heap(buffer) => buffer,
2969            SampleStorage::Shm(sbuf) => sbuf.as_ref(),
2970        }
2971    }
2972}
2973
2974impl std::ops::DerefMut for DataSample {
2975    fn deref_mut(&mut self) -> &mut Self::Target {
2976        match &mut self.storage {
2977            SampleStorage::Heap(buffer) => buffer,
2978            SampleStorage::Shm(sbuf) => sbuf.as_mut(),
2979        }
2980    }
2981}
2982
2983impl From<AVec<u8, ConstAlign<128>>> for DataSample {
2984    fn from(value: AVec<u8, ConstAlign<128>>) -> Self {
2985        Self {
2986            storage: SampleStorage::Heap(value),
2987        }
2988    }
2989}
2990
2991impl std::fmt::Debug for DataSample {
2992    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2993        f.debug_struct("DataSample")
2994            .field("len", &self.len())
2995            .finish_non_exhaustive()
2996    }
2997}
2998
2999/// A finalized output payload ready for transport.
3000///
3001/// Kept separate from [`DataMessage`] so SHM buffers stay out of the
3002/// `Serialize`/`Deserialize` TCP path: the zenoh data plane moves an `Shm`
3003/// buffer straight into `put` (zero extra copy), while the daemon fallback
3004/// converts to [`DataMessage::Vec`], copying out of shared memory only when the
3005/// zenoh path could not deliver.
3006enum FinalizedSample {
3007    Vec(AVec<u8, ConstAlign<128>>),
3008    Shm(zenoh::shm::ZShmMut),
3009}
3010
3011impl FinalizedSample {
3012    fn byte_len(&self) -> usize {
3013        match self {
3014            FinalizedSample::Vec(v) => v.len(),
3015            FinalizedSample::Shm(sbuf) => sbuf.as_ref().len(),
3016        }
3017    }
3018
3019    /// Convert into a TCP-transportable [`DataMessage`]. For the `Shm` arm this
3020    /// copies the payload out of shared memory into a heap buffer; it runs only
3021    /// on the daemon fallback (no matching zenoh subscriber / no session).
3022    fn into_data_message(self) -> DataMessage {
3023        match self {
3024            FinalizedSample::Vec(v) => DataMessage::Vec(v),
3025            FinalizedSample::Shm(sbuf) => {
3026                let bytes = sbuf.as_ref();
3027                let mut avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, bytes.len());
3028                avec.copy_from_slice(bytes);
3029                DataMessage::Vec(avec)
3030            }
3031        }
3032    }
3033}
3034
3035/// Outcome of a zenoh publish attempt.
3036enum PublishOutcome {
3037    /// The payload was delivered to zenoh (or, on a rare SHM put error,
3038    /// consumed and lost — see [`DoraNode::zenoh_publish`]).
3039    Published,
3040    /// No matching subscriber, or a transport error before the payload was
3041    /// consumed. The sample is returned so the caller can fall back to the
3042    /// daemon path.
3043    NotPublished(FinalizedSample),
3044}
3045
3046/// FNV-1a hash of `bytes` with a fixed seed (cross-process deterministic).
3047/// Delegates to [`dora_message::metadata::fnv1a`] — the single source of truth
3048/// shared with the daemon's `dora topic` debug path, so schema hashes match.
3049pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
3050    dora_message::metadata::fnv1a(bytes)
3051}
3052
3053/// How often a schema-once output re-sends a full self-describing stream on the
3054/// data topic. Full streams prime receivers in-band, so this bounds how long a
3055/// consumer that missed the single `@schema` emission (e.g. a failed zenoh-ext
3056/// history query) drops schema-less batches: it re-primes at the next refresh
3057/// instead of losing the input permanently. ~400 B of extra framing per output
3058/// per interval — negligible.
3059pub(crate) const SCHEMA_ONCE_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
3060
3061/// Producer-side schema-once state for one output.
3062struct SchemaOnceState {
3063    /// Hash of the schema confirmed published on the `@schema` subtopic.
3064    published_hash: u64,
3065    /// When the last full self-describing stream was sent on the data topic.
3066    last_full_stream: Instant,
3067}
3068
3069/// What `publish_schema_once` should do for the current message.
3070#[derive(Debug)]
3071enum SchemaOnceDecision {
3072    /// The schema for this hash is not confirmed published (first message,
3073    /// schema change, or an earlier `@schema` publish failed): publish it and
3074    /// send this message as a full self-describing stream. The full stream
3075    /// decodes standalone and primes receivers in-band — in data-plane order —
3076    /// so the first message of an output cannot be lost to the express batch
3077    /// racing ahead of the schema on the separate `@schema` plane, and a failed
3078    /// schema publish degrades to "full stream every message" (decodable)
3079    /// instead of "hash-tagged but undecodable" (dora-rs/dora#2366 review).
3080    PublishSchemaAndSendFullStream,
3081    /// The periodic full-stream refresh is due (see
3082    /// [`SCHEMA_ONCE_REFRESH_INTERVAL`]).
3083    SendFullStreamRefresh,
3084    /// Schema confirmed published and fresh: send only the schema-less batch,
3085    /// tagged with the schema hash.
3086    SendSchemaLessBatch,
3087}
3088
3089fn schema_once_decision(
3090    state: Option<&SchemaOnceState>,
3091    hash: u64,
3092    now: Instant,
3093) -> SchemaOnceDecision {
3094    match state {
3095        Some(state) if state.published_hash == hash => {
3096            if now.duration_since(state.last_full_stream) >= SCHEMA_ONCE_REFRESH_INTERVAL {
3097                SchemaOnceDecision::SendFullStreamRefresh
3098            } else {
3099                SchemaOnceDecision::SendSchemaLessBatch
3100            }
3101        }
3102        _ => SchemaOnceDecision::PublishSchemaAndSendFullStream,
3103    }
3104}
3105
3106/// Publish the Arrow IPC schema for `output_id` on its `@schema` subtopic when
3107/// it changes, and return the attachment metadata (carrying the schema hash)
3108/// for the schema-less batch the caller sends on the data topic. Returns `None`
3109/// when the caller must send the full self-describing stream instead: on the
3110/// message that (re)publishes the schema, when the `@schema` publish failed,
3111/// for the periodic full-stream refresh, or if `full_stream` is not a parseable
3112/// IPC stream (see [`SchemaOnceDecision`]).
3113///
3114/// Takes the maps by `&mut` (not `&mut self`) so it can run while an immutable
3115/// borrow of `self.zenoh_publishers` (the data publisher) is live.
3116#[allow(clippy::too_many_arguments)]
3117fn publish_schema_once(
3118    schema_publishers: &mut HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
3119    schema_state: &mut HashMap<DataId, SchemaOnceState>,
3120    session: &zenoh::Session,
3121    dataflow_id: DataflowId,
3122    node_id: &NodeId,
3123    output_id: &DataId,
3124    full_stream: &[u8],
3125    base_metadata: &Metadata,
3126) -> Option<Vec<u8>> {
3127    let (hash, schema_bytes) = arrow_utils::ipc_encode::schema_block_and_hash(full_stream)?;
3128
3129    let now = Instant::now();
3130    let decision = schema_once_decision(schema_state.get(output_id), hash, now);
3131    tracing::debug!(output = %output_id, decision = ?decision, "schema-once decision");
3132
3133    match decision {
3134        SchemaOnceDecision::PublishSchemaAndSendFullStream => {
3135            if let Some(publisher) =
3136                schema_publisher(schema_publishers, session, dataflow_id, node_id, output_id)
3137            {
3138                use zenoh::Wait;
3139                match publisher.put(schema_bytes).wait() {
3140                    // Record the hash only on a successful publish, so a failed
3141                    // emission is retried on the next message rather than
3142                    // silently skipped.
3143                    Ok(()) => {
3144                        tracing::debug!(output = %output_id, hash, "schema published on @schema subtopic");
3145                        schema_state.insert(
3146                            output_id.clone(),
3147                            SchemaOnceState {
3148                                published_hash: hash,
3149                                last_full_stream: now,
3150                            },
3151                        );
3152                    }
3153                    Err(e) => {
3154                        tracing::warn!(output = %output_id, "failed to publish schema on @schema subtopic ({e})");
3155                    }
3156                }
3157            }
3158            None
3159        }
3160        SchemaOnceDecision::SendFullStreamRefresh => {
3161            tracing::debug!(output = %output_id, hash, "sending full-stream refresh");
3162            if let Some(state) = schema_state.get_mut(output_id) {
3163                state.last_full_stream = now;
3164            }
3165            None
3166        }
3167        SchemaOnceDecision::SendSchemaLessBatch => {
3168            tracing::debug!(output = %output_id, hash, "sending schema-less batch with SCHEMA_HASH");
3169            // Every batch carries the schema hash so the receiver can match it
3170            // to the primed decoder (and detect a schema change).
3171            let mut metadata = base_metadata.clone();
3172            metadata
3173                .parameters
3174                .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
3175            dora_message::encode(&metadata).ok()
3176        }
3177    }
3178}
3179
3180/// Get or lazily declare the schema `AdvancedPublisher` for `output_id` on its
3181/// `@schema` subtopic. The cache (depth 1) retains the last schema so a
3182/// late-joining subscriber's history query can fetch it; `publisher_detection`
3183/// lets a subscriber that started first discover this publisher and query its
3184/// cache. `CongestionControl::Block` keeps the single live schema emission from
3185/// being dropped under congestion.
3186fn schema_publisher<'a>(
3187    schema_publishers: &'a mut HashMap<DataId, zenoh_ext::AdvancedPublisher<'static>>,
3188    session: &zenoh::Session,
3189    dataflow_id: DataflowId,
3190    node_id: &NodeId,
3191    output_id: &DataId,
3192) -> Option<&'a zenoh_ext::AdvancedPublisher<'static>> {
3193    if !schema_publishers.contains_key(output_id) {
3194        use zenoh::Wait;
3195        use zenoh::qos::CongestionControl;
3196        use zenoh_ext::{AdvancedPublisherBuilderExt, CacheConfig, MissDetectionConfig};
3197
3198        let topic = dora_core::topics::zenoh_output_schema_topic(dataflow_id, node_id, output_id);
3199        let key = zenoh::key_expr::KeyExpr::new(topic).ok()?.into_owned();
3200        let publisher = match session
3201            .declare_publisher(key)
3202            .congestion_control(CongestionControl::Block)
3203            // `sample_miss_detection` selects SequenceNumber sequencing instead of
3204            // the cache's default Timestamp sequencing, so the schema publisher
3205            // doesn't require session-wide timestamping (which would otherwise add
3206            // an HLC timestamp to every data-plane message too). Its default
3207            // config adds no heartbeat, so there's no extra periodic traffic.
3208            .sample_miss_detection(MissDetectionConfig::default())
3209            .cache(CacheConfig::default())
3210            .publisher_detection()
3211            .wait()
3212        {
3213            Ok(p) => p,
3214            Err(e) => {
3215                tracing::warn!(output = %output_id, "failed to declare schema publisher ({e})");
3216                return None;
3217            }
3218        };
3219        schema_publishers.insert(output_id.clone(), publisher);
3220    }
3221    schema_publishers.get(output_id)
3222}
3223
3224pub(crate) use dora_message::metadata::carries_pattern_correlation;
3225
3226/// Whether the schema-once optimization may be applied to a data-plane message.
3227///
3228/// Eligible only when the payload is below the zero-copy threshold *and* the
3229/// output does not interleave multiple Arrow schemas. Service/action
3230/// request-reply messages (`request_id`/`goal_id`/`goal_status`) multiplex
3231/// response schemas per request and must travel as full self-describing streams
3232/// so each decodes standalone; streaming chunks share one schema and stay
3233/// eligible. See the rationale at the call site in `zenoh_publish`.
3234fn schema_once_eligible(
3235    payload_len: usize,
3236    zero_copy_threshold: usize,
3237    params: &MetadataParameters,
3238) -> bool {
3239    payload_len < zero_copy_threshold && !carries_pattern_correlation(params)
3240}
3241
3242/// Init Opentelemetry Tracing
3243///
3244/// This requires a tokio runtime spawning this function to be functional
3245#[cfg(feature = "tracing")]
3246pub fn init_tracing(
3247    node_id: &NodeId,
3248    dataflow_id: &DataflowId,
3249) -> NodeResult<Arc<Mutex<Option<OtelGuard>>>> {
3250    let node_id_str = node_id.to_string();
3251    let guard: Arc<Mutex<Option<OtelGuard>>> = Arc::new(Mutex::new(None));
3252    let clone = guard.clone();
3253    let tracing_monitor = async move {
3254        let mut builder = TracingBuilder::new(node_id_str.clone());
3255        // Only enable OTLP if environment variable is set
3256        if std::env::var("DORA_OTLP_ENDPOINT").is_ok()
3257            || std::env::var("DORA_JAEGER_TRACING").is_ok()
3258        {
3259            match builder.with_otlp_tracing() {
3260                Ok(b) => {
3261                    builder = b.with_stdout("info", true);
3262                    if let Ok(mut guard) = clone.lock() {
3263                        *guard = builder.guard.take();
3264                    }
3265                }
3266                Err(e) => {
3267                    eprintln!("warning: failed to set up OTLP tracing: {e:?}");
3268                    // Rebuild without OTLP — with_otlp_tracing consumed builder
3269                    builder = TracingBuilder::new(node_id_str).with_stdout("info", true);
3270                }
3271            }
3272        } else {
3273            builder = builder.with_stdout("info", true);
3274        }
3275
3276        if let Err(e) = builder.build() {
3277            eprintln!("warning: failed to set up tracing subscriber: {e:?}");
3278        }
3279    };
3280
3281    let rt = Handle::try_current().context("failed to get tokio runtime handle")?;
3282    rt.spawn(tracing_monitor);
3283
3284    // dataflow_id is only used when metrics feature is enabled
3285    let _ = &dataflow_id;
3286
3287    // Only start the OTLP metrics exporter when an endpoint is configured.
3288    // The exporter schedules via `tokio::time::interval` and would otherwise
3289    // panic on callers whose runtime lacks the time driver, and would also
3290    // attempt to connect to `localhost:4317` on every node startup. Mirrors
3291    // the gating applied to tracing above.
3292    #[cfg(feature = "metrics")]
3293    if let Ok(endpoint) = std::env::var("DORA_OTLP_ENDPOINT") {
3294        let id = format!("{dataflow_id}/{node_id}");
3295        let monitor_task = async move {
3296            use dora_metrics::run_metrics_monitor;
3297
3298            if let Err(e) = run_metrics_monitor(id.clone(), &endpoint)
3299                .await
3300                .wrap_err("metrics monitor exited unexpectedly")
3301            {
3302                warn!("metrics monitor failed: {:#?}", e);
3303            }
3304        };
3305        let rt = Handle::try_current().context("failed to get tokio runtime handle")?;
3306        rt.spawn(monitor_task);
3307    }
3308    Ok(guard)
3309}
3310
3311/// Builder for streaming segment metadata.
3312///
3313/// Manages session/segment IDs and auto-incrementing sequence numbers
3314/// for real-time streaming patterns (voice, video, sensor streams).
3315///
3316/// The state transitions are easy to get subtly wrong, so they are worth
3317/// spelling out: [`chunk`](Self::chunk) stamps the current `(segment_id, seq)`
3318/// and then auto-increments `seq`; [`next_segment`](Self::next_segment) bumps
3319/// `segment_id` and resets `seq` to 0; and [`flush`](Self::flush) advances to a
3320/// new segment and emits a chunk marked `flush = true`, `fin = false` (the
3321/// prior segment is discarded, not completed, so it intentionally never gets a
3322/// `fin = true`).
3323///
3324/// # Example
3325///
3326/// ```
3327/// use dora_node_api::{
3328///     StreamSegment,
3329///     metadata::{FIN, FLUSH, SEGMENT_ID, SEQ, get_bool_param, get_integer_param},
3330/// };
3331///
3332/// let mut seg = StreamSegment::with_session_id("session-1".to_string());
3333///
3334/// // `chunk` stamps the current (segment, seq), then advances seq.
3335/// let first = seg.chunk(false);
3336/// assert_eq!(get_integer_param(&first, SEGMENT_ID), Some(0));
3337/// assert_eq!(get_integer_param(&first, SEQ), Some(0));
3338/// assert_eq!(get_bool_param(&first, FIN), Some(false));
3339///
3340/// let second = seg.chunk(true); // mark this chunk as the end of the segment
3341/// assert_eq!(get_integer_param(&second, SEQ), Some(1)); // seq auto-incremented
3342/// assert_eq!(get_bool_param(&second, FIN), Some(true));
3343///
3344/// // `flush` starts a new segment (seq reset to 0) and marks flush=true,
3345/// // fin=false: the old queued data is discarded, not completed.
3346/// let flushed = seg.flush();
3347/// assert_eq!(get_integer_param(&flushed, SEGMENT_ID), Some(1));
3348/// assert_eq!(get_integer_param(&flushed, SEQ), Some(0));
3349/// assert_eq!(get_bool_param(&flushed, FLUSH), Some(true));
3350/// assert_eq!(get_bool_param(&flushed, FIN), Some(false));
3351/// ```
3352pub struct StreamSegment {
3353    session_id: String,
3354    segment_id: i64,
3355    seq: i64,
3356}
3357
3358impl StreamSegment {
3359    /// Start a new session with a generated session ID and segment 0.
3360    pub fn new() -> Self {
3361        Self {
3362            session_id: DoraNode::new_request_id(),
3363            segment_id: 0,
3364            seq: 0,
3365        }
3366    }
3367
3368    /// Start a new session with an explicit session ID.
3369    pub fn with_session_id(session_id: String) -> Self {
3370        Self {
3371            session_id,
3372            segment_id: 0,
3373            seq: 0,
3374        }
3375    }
3376
3377    /// Advance to a new segment (resets seq to 0). Returns the new segment_id.
3378    pub fn next_segment(&mut self) -> i64 {
3379        self.segment_id += 1;
3380        self.seq = 0;
3381        self.segment_id
3382    }
3383
3384    /// Build metadata parameters for a chunk. Auto-increments seq.
3385    pub fn chunk(&mut self, fin: bool) -> MetadataParameters {
3386        let mut params = MetadataParameters::new();
3387        params.insert(
3388            SESSION_ID.into(),
3389            Parameter::String(self.session_id.clone()),
3390        );
3391        params.insert(SEGMENT_ID.into(), Parameter::Integer(self.segment_id));
3392        params.insert(SEQ.into(), Parameter::Integer(self.seq));
3393        params.insert(FIN.into(), Parameter::Bool(fin));
3394        self.seq += 1;
3395        params
3396    }
3397
3398    /// Build metadata for a flush message (new segment, discards older queued data).
3399    ///
3400    /// Advances to a new segment, then emits a chunk with `flush=true` and
3401    /// `fin=false`. The prior segment ends without a `fin=true` signal -- this
3402    /// is intentional for interruption semantics (the old data is being
3403    /// discarded, not completed).
3404    ///
3405    /// **Note**: flush discards *all* queued messages on the receiver's input
3406    /// regardless of `session_id`. Do not multiplex independent sessions on a
3407    /// single `DataId` when using flush.
3408    pub fn flush(&mut self) -> MetadataParameters {
3409        self.next_segment();
3410        let mut params = self.chunk(false);
3411        params.insert(FLUSH.into(), Parameter::Bool(true));
3412        params
3413    }
3414
3415    /// Returns the session ID.
3416    pub fn session_id(&self) -> &str {
3417        &self.session_id
3418    }
3419
3420    /// Returns the current segment ID.
3421    pub fn segment_id(&self) -> i64 {
3422        self.segment_id
3423    }
3424
3425    /// Returns the sequence number that will be used by the next `chunk()` call.
3426    pub fn seq(&self) -> i64 {
3427        self.seq
3428    }
3429}
3430
3431impl Default for StreamSegment {
3432    fn default() -> Self {
3433        Self::new()
3434    }
3435}
3436
3437#[cfg(test)]
3438mod tests {
3439    use super::*;
3440    use crate::integration_testing::{
3441        IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
3442        integration_testing_format::{IncomingEvent, TimedIncomingEvent},
3443    };
3444
3445    fn required_acker(node: &str, input: &str) -> dora_message::daemon_to_node::RequiredAcker {
3446        dora_message::daemon_to_node::RequiredAcker {
3447            node_id: NodeId::from(node.to_string()),
3448            input_id: DataId::from(input.to_string()),
3449        }
3450    }
3451
3452    /// A not-yet-ready `AckState` for `output` awaiting a single `acker`.
3453    fn test_ack_state(output: &str, acker: (&str, &str)) -> Arc<AckState> {
3454        Arc::new(AckState::new(
3455            DataId::from(output.to_string()),
3456            &BTreeSet::from([required_acker(acker.0, acker.1)]),
3457            Arc::new(AtomicBool::new(false)),
3458        ))
3459    }
3460
3461    #[test]
3462    fn log_fields_budget_measures_serialized_json_not_raw_bytes() {
3463        use std::collections::BTreeMap;
3464        let limit = DoraNode::MAX_LOG_FIELDS_BYTES;
3465
3466        // A small map fits.
3467        let mut small = BTreeMap::new();
3468        small.insert("k".to_string(), "v".to_string());
3469        assert!(log_fields_within_budget(&small, limit).is_some());
3470
3471        // A value that is 20 KB of raw bytes — comfortably under the 60 KB
3472        // budget by the old raw-sum measure — but made entirely of control
3473        // characters, each of which JSON-escapes to `` (6 bytes). Its
3474        // serialized form is ~120 KB, over the budget, so it must be dropped.
3475        // The pre-fix raw-byte check would have let it through and blown the
3476        // downstream parse limit.
3477        let mut big = BTreeMap::new();
3478        big.insert("k".to_string(), "\u{1}".repeat(20 * 1024));
3479        assert!(big.values().map(String::len).sum::<usize>() < limit);
3480        assert!(log_fields_within_budget(&big, limit).is_none());
3481    }
3482
3483    #[test]
3484    fn ack_state_completes_only_when_required_set_is_covered() {
3485        let ready = Arc::new(AtomicBool::new(false));
3486        let required = BTreeSet::from([
3487            required_acker("sink-a", "camera"),
3488            required_acker("sink-b", "cam"),
3489        ]);
3490        let state = AckState::new(DataId::from("image".to_string()), &required, ready.clone());
3491
3492        // An acker outside the required set (dynamic or debug consumer) must
3493        // never count toward completion.
3494        state.record("stranger", "camera");
3495        assert!(!ready.load(Ordering::Relaxed));
3496
3497        // Duplicate acks of one required identity don't complete the set.
3498        state.record("sink-a", "camera");
3499        state.record("sink-a", "camera");
3500        assert!(!ready.load(Ordering::Relaxed));
3501        assert_eq!(state.missing(), vec!["sink-b/cam".to_string()]);
3502
3503        // The last required identity completes it.
3504        state.record("sink-b", "cam");
3505        assert!(ready.load(Ordering::Relaxed));
3506        assert!(state.missing().is_empty());
3507    }
3508
3509    #[test]
3510    fn ack_state_requires_exact_identity_match() {
3511        // (node, input) is one identity: the right node acking the wrong input
3512        // (or vice versa) must not count.
3513        let ready = Arc::new(AtomicBool::new(false));
3514        let required = BTreeSet::from([required_acker("sink", "camera")]);
3515        let state = AckState::new(DataId::from("image".to_string()), &required, ready.clone());
3516
3517        state.record("sink", "other-input");
3518        state.record("other-node", "camera");
3519        assert!(!ready.load(Ordering::Relaxed));
3520
3521        state.record("sink", "camera");
3522        assert!(ready.load(Ordering::Relaxed));
3523    }
3524
3525    // Regression guard for dora-rs/dora#2891: a frozen output must never
3526    // upgrade. Before the fix an ack arriving after the grace (but before the
3527    // old 10s deadline) flipped `ready` while user code was already sending,
3528    // so a message on the shorter direct-zenoh path could overtake an earlier
3529    // one still relaying through the daemon.
3530    #[test]
3531    fn ack_state_freeze_blocks_a_late_upgrade() {
3532        let state = test_ack_state("image", ("sink", "camera"));
3533
3534        assert!(state.freeze(), "an un-acked output is frozen");
3535        assert!(state.is_frozen());
3536
3537        // The consumer's ack arrives late — it must not move the output onto
3538        // the direct path mid-stream.
3539        state.record("sink", "camera");
3540        assert!(
3541            !state.ready.load(Ordering::Relaxed),
3542            "a frozen output must stay on the daemon path for the rest of the run"
3543        );
3544        assert_eq!(state.missing(), vec!["sink/camera".to_string()]);
3545    }
3546
3547    #[test]
3548    fn ack_state_freeze_spares_an_output_that_acked_in_time() {
3549        let state = test_ack_state("image", ("sink", "camera"));
3550
3551        state.record("sink", "camera");
3552        assert!(!state.freeze(), "a ready output is not frozen");
3553        assert!(!state.is_frozen());
3554        assert!(
3555            state.ready.load(Ordering::Relaxed),
3556            "an output that proved its routes keeps the direct zenoh path"
3557        );
3558    }
3559
3560    // dora-rs/dora#2891: whatever has not proven its routes when the grace
3561    // expires is pinned to the daemon path, so every output's transport is
3562    // decided before user code sends its first message.
3563    #[test]
3564    fn grace_boundary_freezes_unacked_outputs_only() {
3565        let acked = test_ack_state("image", ("sink", "camera"));
3566        let unacked = test_ack_state("status", ("sink", "state"));
3567        acked.record("sink", "camera");
3568
3569        wait_for_grace(&[acked.clone(), unacked.clone()], Duration::from_millis(20));
3570
3571        assert!(acked.ready.load(Ordering::Relaxed));
3572        assert!(!acked.is_frozen());
3573        assert!(unacked.is_frozen());
3574
3575        // The straggler's ack lands after the boundary and is ignored.
3576        unacked.record("sink", "state");
3577        assert!(!unacked.ready.load(Ordering::Relaxed));
3578    }
3579
3580    #[test]
3581    fn grace_returns_early_once_every_output_is_acked() {
3582        // Also covers the no-awaited-outputs case (no consumers, or every ack
3583        // subscriber failed to declare): `all` is vacuously true on an empty
3584        // slice, so there is nothing to wait for and nothing to freeze.
3585        let state = test_ack_state("image", ("sink", "camera"));
3586        state.record("sink", "camera");
3587
3588        let start = Instant::now();
3589        wait_for_grace(std::slice::from_ref(&state), Duration::from_secs(30));
3590        wait_for_grace(&[], Duration::from_secs(30));
3591
3592        assert!(!state.is_frozen());
3593        assert!(state.ready.load(Ordering::Relaxed));
3594        assert!(
3595            start.elapsed() < Duration::from_secs(5),
3596            "a completed handshake must not wait out the grace, took {:?}",
3597            start.elapsed()
3598        );
3599    }
3600
3601    #[test]
3602    fn missing_output_routing_pins_every_output_to_the_daemon_path() {
3603        // `None` means an older daemon spawned this node: without
3604        // required-acker sets no route can be proven, so the safe result is
3605        // daemon-only for every declared output.
3606        let outputs = BTreeSet::from([
3607            DataId::from("image".to_string()),
3608            DataId::from("status".to_string()),
3609        ]);
3610        let routing = normalize_output_routing(None, &outputs);
3611        assert_eq!(routing.len(), 2);
3612        for output_id in &outputs {
3613            let entry = routing.get(output_id).expect("entry per output");
3614            assert!(entry.daemon_only);
3615            assert!(entry.required_ackers.is_empty());
3616        }
3617
3618        // No outputs → nothing to pin (interactive mode).
3619        assert!(normalize_output_routing(None, &BTreeSet::new()).is_empty());
3620    }
3621
3622    #[test]
3623    fn provided_output_routing_is_passed_through() {
3624        let outputs = BTreeSet::from([DataId::from("image".to_string())]);
3625        let provided = BTreeMap::from([(
3626            DataId::from("image".to_string()),
3627            OutputRouting {
3628                daemon_only: false,
3629                required_ackers: BTreeSet::from([required_acker("sink", "camera")]),
3630            },
3631        )]);
3632        let routing = normalize_output_routing(Some(provided.clone()), &outputs);
3633        assert_eq!(routing, provided);
3634    }
3635
3636    #[test]
3637    fn new_request_id_returns_valid_uuid() {
3638        let id = DoraNode::new_request_id();
3639        uuid::Uuid::parse_str(&id).expect("should be valid UUID");
3640    }
3641
3642    #[test]
3643    fn new_request_id_is_unique() {
3644        let ids: Vec<String> = (0..100).map(|_| DoraNode::new_request_id()).collect();
3645        let unique: std::collections::HashSet<_> = ids.iter().collect();
3646        assert_eq!(ids.len(), unique.len(), "all IDs should be unique");
3647    }
3648
3649    #[test]
3650    fn new_goal_id_returns_valid_uuid() {
3651        let id = DoraNode::new_goal_id();
3652        uuid::Uuid::parse_str(&id).expect("should be valid UUID");
3653    }
3654
3655    /// `DoraNode::timestamp()` must read from the SAME HLC the node
3656    /// uses to stamp outgoing messages. If a refactor accidentally
3657    /// gives `timestamp()` its own clock, the latency-measurement use
3658    /// case in the docstring silently breaks (subtracting against an
3659    /// `event.metadata.timestamp` from the data plane would mix two
3660    /// unrelated HLCs). Guard by asserting two calls share an HLC ID
3661    /// and that the second reads strictly later than the first.
3662    ///
3663    /// The strict `t2 > t1` assertion holds by HLC construction: if
3664    /// the wall clock advanced between calls, the physical component
3665    /// strictly increases; if not, the logical counter bumps. The
3666    /// lexicographic ordering on `uhlc::Timestamp` puts `t2` strictly
3667    /// after `t1` in either case, so this assertion does not flake on
3668    /// fast machines whose OS clock rounds both calls to the same tick.
3669    #[test]
3670    fn timestamp_uses_node_clock_and_is_monotonic() {
3671        let (node, events, _rx) = test_node();
3672        let t1 = node.timestamp();
3673        let t2 = node.timestamp();
3674        assert_eq!(
3675            t1.get_id(),
3676            t2.get_id(),
3677            "two timestamp() calls must come from the same HLC instance",
3678        );
3679        assert!(
3680            t2 > t1,
3681            "HLC timestamps must be strictly monotonic: {t1:?} >= {t2:?}"
3682        );
3683        drop(node);
3684        drop(events);
3685    }
3686
3687    use crate::integration_testing::{OutputReceiver, drain_outputs};
3688
3689    /// Helper: create a minimal test node with a channel output.
3690    fn test_node() -> (DoraNode, crate::EventStream, OutputReceiver) {
3691        let events = vec![TimedIncomingEvent {
3692            time_offset_secs: 0.1,
3693            event: IncomingEvent::Stop,
3694        }];
3695        let inputs = TestingInput::Input(IntegrationTestInput::new(
3696            "test-node".parse().unwrap(),
3697            events,
3698        ));
3699        let (tx, rx) = crate::integration_testing::output_channel();
3700        let outputs = TestingOutput::ToChannel(tx);
3701        let options = TestingOptions {
3702            skip_output_time_offsets: true,
3703        };
3704        let (node, event_stream) = DoraNode::init_testing(inputs, outputs, options).unwrap();
3705        (node, event_stream, rx)
3706    }
3707
3708    /// Comfortably below any multi-second join/sleep budget so node-first Drop
3709    /// regressions surface without waiting on an internal timeout boundary.
3710    const INIT_TESTING_DROP_BUDGET: Duration = Duration::from_millis(500);
3711
3712    fn init_testing_node_mid_scheduled_wait() -> (DoraNode, crate::EventStream) {
3713        let events = vec![TimedIncomingEvent {
3714            // Long enough that a hang is obvious; the shutdown flag must
3715            // interrupt well before this elapses.
3716            time_offset_secs: 30.0,
3717            event: IncomingEvent::Stop,
3718        }];
3719        let inputs = TestingInput::Input(IntegrationTestInput::new(
3720            "drop-hang-node".parse().unwrap(),
3721            events,
3722        ));
3723        let (tx, _rx) = crate::integration_testing::output_channel();
3724        let outputs = TestingOutput::ToChannel(tx);
3725        let (node, event_stream) =
3726            DoraNode::init_testing(inputs, outputs, TestingOptions::default()).unwrap();
3727
3728        // Give the testing daemon a moment to enter next_event's sleep.
3729        std::thread::sleep(Duration::from_millis(50));
3730        (node, event_stream)
3731    }
3732
3733    /// Regression for dora-rs/dora#2855: events-then-node Drop while the daemon
3734    /// is inside a scheduled `next_event` wait must not hang.
3735    #[test]
3736    fn init_testing_drop_events_then_node_during_scheduled_wait_does_not_hang() {
3737        let (node, event_stream) = init_testing_node_mid_scheduled_wait();
3738
3739        let start = Instant::now();
3740        drop(event_stream);
3741        drop(node);
3742        let elapsed = start.elapsed();
3743        assert!(
3744            elapsed < INIT_TESTING_DROP_BUDGET,
3745            "events-then-node Drop hung for {elapsed:?}; expected interruptible testing-daemon shutdown"
3746        );
3747    }
3748
3749    /// Regression for dora-rs/dora#2855: node-then-events Drop must exit the
3750    /// testing daemon after OutputsDone under shutdown even while EventStream
3751    /// still holds a channel sender clone.
3752    #[test]
3753    fn init_testing_drop_node_then_events_during_scheduled_wait_does_not_hang() {
3754        let (node, event_stream) = init_testing_node_mid_scheduled_wait();
3755
3756        let start = Instant::now();
3757        drop(node);
3758        drop(event_stream);
3759        let elapsed = start.elapsed();
3760        assert!(
3761            elapsed < INIT_TESTING_DROP_BUDGET,
3762            "node-then-events Drop hung for {elapsed:?}; expected OutputsDone under shutdown to exit the testing daemon"
3763        );
3764    }
3765
3766    #[test]
3767    fn send_service_request_returns_valid_id_and_sends_output() {
3768        let (mut node, events, mut rx) = test_node();
3769
3770        let request_id = node
3771            .send_service_request("request".into(), Default::default(), ())
3772            .unwrap();
3773
3774        // Returned ID should be a valid UUID
3775        uuid::Uuid::parse_str(&request_id).expect("returned request_id should be valid UUID");
3776
3777        // Output should have been sent to the channel
3778        drop(node);
3779        drop(events);
3780        let outputs = drain_outputs(&mut rx);
3781        assert_eq!(outputs.len(), 1);
3782        assert_eq!(outputs[0]["id"], "request");
3783    }
3784
3785    #[test]
3786    fn send_service_request_returns_unique_ids() {
3787        let (mut node, events, _rx) = test_node();
3788
3789        let id1 = node
3790            .send_service_request("out".into(), Default::default(), ())
3791            .unwrap();
3792        let id2 = node
3793            .send_service_request("out".into(), Default::default(), ())
3794            .unwrap();
3795
3796        assert_ne!(id1, id2, "successive request IDs should differ");
3797
3798        drop(node);
3799        drop(events);
3800    }
3801
3802    #[test]
3803    fn send_service_response_sends_output() {
3804        let (mut node, events, mut rx) = test_node();
3805
3806        // Simulate passing through a request_id from the incoming request
3807        let mut params = MetadataParameters::default();
3808        params.insert(
3809            dora_message::metadata::REQUEST_ID.to_string(),
3810            dora_message::metadata::Parameter::String("test-req-id".into()),
3811        );
3812        node.send_service_response("response".into(), params, ())
3813            .unwrap();
3814
3815        drop(node);
3816        drop(events);
3817        let outputs = drain_outputs(&mut rx);
3818        assert_eq!(outputs.len(), 1);
3819        assert_eq!(outputs[0]["id"], "response");
3820    }
3821
3822    /// `send_output_bytes` must reject a `data_len` that disagrees with
3823    /// `data.len()` with a clear error instead of panicking inside
3824    /// `copy_from_slice` deep in `send_output_raw`.
3825    #[test]
3826    fn send_output_bytes_rejects_len_mismatch() {
3827        let (mut node, events, _rx) = test_node();
3828
3829        let result = node.send_output_bytes("out".into(), Default::default(), 8, &[1, 2, 3, 4]);
3830
3831        let err = result.expect_err("mismatched data_len must error, not panic");
3832        assert!(
3833            err.to_string().contains("does not match"),
3834            "unexpected error message: {err}"
3835        );
3836
3837        drop(node);
3838        drop(events);
3839    }
3840
3841    /// A heap-backed `DataSample` is writable through `DerefMut`, readable
3842    /// through `Deref`, and `finalize().into_data_message()` preserves the bytes
3843    /// as the `DataMessage::Vec` daemon-path payload. (The SHM-backed arm needs
3844    /// a live zenoh provider and is covered by the copy-count harness/smoke.)
3845    #[test]
3846    fn data_sample_heap_roundtrip() {
3847        let avec: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, 8);
3848        let mut sample: DataSample = avec.into();
3849        sample.copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
3850
3851        assert_eq!(&sample[..], &[1, 2, 3, 4, 5, 6, 7, 8]);
3852        assert_eq!(sample.len(), 8);
3853
3854        match sample.finalize().into_data_message() {
3855            DataMessage::Vec(v) => assert_eq!(v.as_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]),
3856        }
3857    }
3858
3859    /// End-to-end wire contract: a few representative arrays IPC-encoded (fast
3860    /// path) into a sample and decoded back via `decode_arrow_ipc_zero_copy`
3861    /// must equal the input. This is the send->receive round-trip the data
3862    /// plane relies on (zenoh can't be smoke-tested here, so this stands in).
3863    #[test]
3864    fn send_output_ipc_roundtrip() {
3865        use crate::arrow_utils::decode_arrow_ipc_zero_copy_raw;
3866        use crate::arrow_utils::ipc_encode::{encode_ipc_into_data, ipc_fast_path_len_data};
3867        use arrow::array::{ArrayRef, Float32Array, StringArray, StructArray, UInt64Array};
3868        use arrow_schema::{DataType, Field};
3869        use std::ptr::NonNull;
3870
3871        fn roundtrip(data: ArrayData) {
3872            let len = ipc_fast_path_len_data(&data).expect("array should be fast-path eligible");
3873            let mut buf: AVec<u8, ConstAlign<128>> = AVec::__from_elem(128, 0, len);
3874            encode_ipc_into_data(&data, &mut buf).expect("fast-path IPC encode");
3875
3876            // Wrap the aligned sample as an Arrow Buffer (no copy), mirroring the
3877            // receive path, then decode.
3878            let ptr = NonNull::new(buf.as_ptr() as *mut u8).unwrap();
3879            let blen = buf.len();
3880            // SAFETY: ptr/len describe `buf`; the Arc keeps it alive.
3881            let buffer =
3882                unsafe { arrow::buffer::Buffer::from_custom_allocation(ptr, blen, Arc::new(buf)) };
3883            let decoded = decode_arrow_ipc_zero_copy_raw(buffer).expect("zero-copy IPC decode");
3884            assert_eq!(
3885                data, decoded,
3886                "IPC send->receive round-trip must preserve the array"
3887            );
3888        }
3889
3890        roundtrip(Float32Array::from(vec![1.0, 2.5, -3.0, 4.0]).into_data());
3891        roundtrip(UInt64Array::from(vec![Some(1), None, Some(3)]).into_data());
3892        roundtrip(StringArray::from(vec![Some("hello"), None, Some("world")]).into_data());
3893        roundtrip(
3894            StructArray::from(vec![
3895                (
3896                    Arc::new(Field::new("v", DataType::UInt64, true)),
3897                    Arc::new(UInt64Array::from(vec![Some(1), None, Some(3)])) as ArrayRef,
3898                ),
3899                (
3900                    Arc::new(Field::new("s", DataType::Utf8, true)),
3901                    Arc::new(StringArray::from(vec![Some("a"), Some("bb"), None])) as ArrayRef,
3902                ),
3903            ])
3904            .into_data(),
3905        );
3906    }
3907
3908    /// `close_outputs` must be atomic: if any id in the batch is unknown, the
3909    /// call fails *without* removing the valid ids from the local output set.
3910    /// Otherwise the daemon (never notified, because `report_closed_outputs` is
3911    /// skipped on error) and the node disagree about which outputs are open, and
3912    /// the node would silently drop subsequent sends to a still-open output.
3913    #[test]
3914    fn close_outputs_is_atomic_on_unknown_id() {
3915        let (mut node, events, _rx) = test_node();
3916        let valid: DataId = "valid".into();
3917        node.node_config.outputs.insert(valid.clone());
3918
3919        let result = node.close_outputs(vec![valid.clone(), "unknown".into()]);
3920
3921        assert!(
3922            result.is_err(),
3923            "closing a batch containing an unknown output must fail"
3924        );
3925        assert!(
3926            node.node_config.outputs.contains(&valid),
3927            "a failed close_outputs must not remove the valid output from local state"
3928        );
3929
3930        drop(node);
3931        drop(events);
3932    }
3933
3934    // ---- dora-rs/adora#150: pattern polymorphism exemption ----
3935
3936    #[test]
3937    fn carries_pattern_correlation_detects_request_id() {
3938        let mut params = MetadataParameters::default();
3939        params.insert(
3940            dora_message::metadata::REQUEST_ID.to_string(),
3941            dora_message::metadata::Parameter::String("req-1".into()),
3942        );
3943        assert!(carries_pattern_correlation(&params));
3944    }
3945
3946    #[test]
3947    fn carries_pattern_correlation_detects_goal_id() {
3948        let mut params = MetadataParameters::default();
3949        params.insert(
3950            dora_message::metadata::GOAL_ID.to_string(),
3951            dora_message::metadata::Parameter::String("goal-1".into()),
3952        );
3953        assert!(carries_pattern_correlation(&params));
3954    }
3955
3956    #[test]
3957    fn carries_pattern_correlation_detects_goal_status() {
3958        let mut params = MetadataParameters::default();
3959        params.insert(
3960            dora_message::metadata::GOAL_STATUS.to_string(),
3961            dora_message::metadata::Parameter::String("succeeded".into()),
3962        );
3963        assert!(carries_pattern_correlation(&params));
3964    }
3965
3966    #[test]
3967    fn carries_pattern_correlation_empty_is_not_a_pattern() {
3968        let params = MetadataParameters::default();
3969        assert!(!carries_pattern_correlation(&params));
3970    }
3971
3972    #[test]
3973    fn carries_pattern_correlation_ignores_non_pattern_keys() {
3974        let mut params = MetadataParameters::default();
3975        params.insert(
3976            "custom_key".to_string(),
3977            dora_message::metadata::Parameter::String("value".into()),
3978        );
3979        assert!(!carries_pattern_correlation(&params));
3980    }
3981
3982    #[test]
3983    fn schema_once_excludes_pattern_correlation_outputs() {
3984        // Regression (dora-rs/dora#2366 review): a small service/action
3985        // request-reply message — which multiplexes response schemas per request
3986        // — must NOT use schema-once, or a schema-less batch could reach a
3987        // consumer primed for a different schema and be silently dropped. It must
3988        // travel as a full self-describing stream instead.
3989        const THRESHOLD: usize = 4096;
3990
3991        let plain = MetadataParameters::default();
3992        assert!(
3993            schema_once_eligible(100, THRESHOLD, &plain),
3994            "small message on a stable-schema output is eligible"
3995        );
3996        assert!(
3997            !schema_once_eligible(THRESHOLD, THRESHOLD, &plain),
3998            "a message at/above the threshold is not eligible (goes via SHM/full stream)"
3999        );
4000
4001        for key in [
4002            dora_message::metadata::REQUEST_ID,
4003            dora_message::metadata::GOAL_ID,
4004            dora_message::metadata::GOAL_STATUS,
4005        ] {
4006            let mut params = MetadataParameters::default();
4007            params.insert(
4008                key.to_string(),
4009                dora_message::metadata::Parameter::String("x".into()),
4010            );
4011            assert!(
4012                !schema_once_eligible(100, THRESHOLD, &params),
4013                "small pattern-correlation message ({key}) must bypass schema-once"
4014            );
4015        }
4016
4017        // Streaming is deliberately NOT excluded: every chunk of a stream shares
4018        // one schema, so streaming stays the high-rate beneficiary of
4019        // schema-once. Locking this in guards against a well-meaning "also
4020        // exclude streaming" change that would defeat the optimization.
4021        let mut stream = MetadataParameters::default();
4022        stream.insert(
4023            dora_message::metadata::SESSION_ID.to_string(),
4024            dora_message::metadata::Parameter::String("s1".into()),
4025        );
4026        stream.insert(
4027            dora_message::metadata::SEGMENT_ID.to_string(),
4028            dora_message::metadata::Parameter::Integer(0),
4029        );
4030        assert!(
4031            schema_once_eligible(100, THRESHOLD, &stream),
4032            "small streaming chunk (stable schema) stays eligible for schema-once"
4033        );
4034    }
4035
4036    #[test]
4037    fn schema_once_decision_covers_publish_refresh_and_schema_less() {
4038        let start = Instant::now();
4039        let later = start + SCHEMA_ONCE_REFRESH_INTERVAL;
4040        let state = SchemaOnceState {
4041            published_hash: 7,
4042            last_full_stream: start,
4043        };
4044
4045        // No state yet (first message of this output) → publish the schema and
4046        // send THIS message as a full stream: it decodes standalone and primes
4047        // receivers in-band, so the first message can never be lost to the
4048        // batch racing ahead of the schema on the separate `@schema` plane
4049        // (dora-rs/dora#2366 review).
4050        assert!(matches!(
4051            schema_once_decision(None, 7, start),
4052            SchemaOnceDecision::PublishSchemaAndSendFullStream
4053        ));
4054        // Schema changed (or an earlier `@schema` publish failed, which leaves
4055        // the recorded hash stale) → same: publish + full stream.
4056        assert!(matches!(
4057            schema_once_decision(Some(&state), 8, start),
4058            SchemaOnceDecision::PublishSchemaAndSendFullStream
4059        ));
4060        // Schema confirmed published and refresh not due → schema-less batch.
4061        assert!(matches!(
4062            schema_once_decision(Some(&state), 7, start),
4063            SchemaOnceDecision::SendSchemaLessBatch
4064        ));
4065        // Refresh due → send a full stream so any consumer that missed the
4066        // single `@schema` emission re-primes in-band within the interval
4067        // instead of losing the input permanently.
4068        assert!(matches!(
4069            schema_once_decision(Some(&state), 7, later),
4070            SchemaOnceDecision::SendFullStreamRefresh
4071        ));
4072    }
4073
4074    #[test]
4075    fn stream_segment_new_generates_valid_session_id() {
4076        let seg = StreamSegment::new();
4077        uuid::Uuid::parse_str(seg.session_id()).expect("session_id should be valid UUID");
4078        assert_eq!(seg.segment_id(), 0);
4079    }
4080
4081    #[test]
4082    fn stream_segment_with_session_id() {
4083        let seg = StreamSegment::with_session_id("my-session".into());
4084        assert_eq!(seg.session_id(), "my-session");
4085        assert_eq!(seg.segment_id(), 0);
4086        assert_eq!(seg.seq(), 0);
4087    }
4088
4089    #[test]
4090    fn stream_segment_seq_accessor_tracks_next_seq() {
4091        let mut seg = StreamSegment::with_session_id("s1".into());
4092        assert_eq!(seg.seq(), 0);
4093        seg.chunk(false);
4094        assert_eq!(seg.seq(), 1);
4095        seg.chunk(false);
4096        assert_eq!(seg.seq(), 2);
4097        seg.next_segment();
4098        assert_eq!(seg.seq(), 0);
4099    }
4100
4101    #[test]
4102    fn stream_segment_chunk_auto_increments_seq() {
4103        let mut seg = StreamSegment::with_session_id("s1".into());
4104        let p0 = seg.chunk(false);
4105        let p1 = seg.chunk(false);
4106        let p2 = seg.chunk(true);
4107
4108        assert_eq!(p0.get(SEQ), Some(&Parameter::Integer(0)));
4109        assert_eq!(p1.get(SEQ), Some(&Parameter::Integer(1)));
4110        assert_eq!(p2.get(SEQ), Some(&Parameter::Integer(2)));
4111        assert_eq!(p0.get(FIN), Some(&Parameter::Bool(false)));
4112        assert_eq!(p2.get(FIN), Some(&Parameter::Bool(true)));
4113        assert_eq!(p0.get(SESSION_ID), Some(&Parameter::String("s1".into())));
4114        assert_eq!(p0.get(SEGMENT_ID), Some(&Parameter::Integer(0)));
4115    }
4116
4117    #[test]
4118    fn stream_segment_next_segment_resets_seq() {
4119        let mut seg = StreamSegment::with_session_id("s1".into());
4120        seg.chunk(false); // seq=0
4121        seg.chunk(false); // seq=1
4122        let new_id = seg.next_segment();
4123        assert_eq!(new_id, 1);
4124        assert_eq!(seg.segment_id(), 1);
4125
4126        let p = seg.chunk(false);
4127        assert_eq!(p.get(SEQ), Some(&Parameter::Integer(0)));
4128        assert_eq!(p.get(SEGMENT_ID), Some(&Parameter::Integer(1)));
4129    }
4130
4131    #[test]
4132    fn stream_segment_flush_advances_segment_and_sets_flush() {
4133        let mut seg = StreamSegment::with_session_id("s1".into());
4134        seg.chunk(false);
4135        let p = seg.flush();
4136        assert_eq!(seg.segment_id(), 1);
4137        assert_eq!(p.get(FLUSH), Some(&Parameter::Bool(true)));
4138        assert_eq!(p.get(SEGMENT_ID), Some(&Parameter::Integer(1)));
4139        // flush resets seq, then chunk increments it to 1
4140        assert_eq!(p.get(SEQ), Some(&Parameter::Integer(0)));
4141    }
4142
4143    #[test]
4144    fn send_stream_chunk_sends_output() {
4145        let (mut node, events, mut rx) = test_node();
4146        let mut seg = StreamSegment::with_session_id("s1".into());
4147
4148        node.send_stream_chunk("audio".into(), &mut seg, false, ())
4149            .unwrap();
4150
4151        drop(node);
4152        drop(events);
4153        let outputs = drain_outputs(&mut rx);
4154        assert_eq!(outputs.len(), 1);
4155        assert_eq!(outputs[0]["id"], "audio");
4156    }
4157
4158    #[test]
4159    fn teardown_with_timeout_completes_fast_closure() {
4160        let start = Instant::now();
4161        let completed = teardown_with_timeout("fast", Duration::from_secs(5), || {});
4162        assert!(completed, "fast teardown should report completion");
4163        assert!(
4164            start.elapsed() < Duration::from_secs(5),
4165            "fast teardown should not wait for the full timeout"
4166        );
4167    }
4168
4169    #[test]
4170    fn teardown_with_timeout_gives_up_on_wedged_closure() {
4171        let start = Instant::now();
4172        let completed = teardown_with_timeout("wedged", Duration::from_millis(300), || {
4173            std::thread::sleep(Duration::from_secs(60))
4174        });
4175        let elapsed = start.elapsed();
4176        assert!(!completed, "wedged teardown should report a timeout");
4177        assert!(
4178            elapsed >= Duration::from_millis(300),
4179            "should wait the full deadline, returned after {elapsed:?}"
4180        );
4181        assert!(
4182            elapsed < Duration::from_secs(5),
4183            "should give up shortly after the deadline, took {elapsed:?}"
4184        );
4185    }
4186
4187    #[test]
4188    fn teardown_with_timeout_contains_panics() {
4189        let completed = teardown_with_timeout("panicking", Duration::from_secs(5), || {
4190            panic!("teardown panicked")
4191        });
4192        assert!(completed, "panicking teardown still counts as completed");
4193    }
4194
4195    // Regression guard for dora-rs/dora#2742: the node's worst-case zenoh teardown must
4196    // stay under the daemon's force-kill grace (full rationale on `ZENOH_TEARDOWN_TIMEOUT`).
4197    //
4198    // Teardown runs in sequential bounded phases (two in `EventStream::drop`, one in
4199    // `DoraNode::drop`), so the worst case is `TEARDOWN_PHASES * ZENOH_TEARDOWN_TIMEOUT` —
4200    // bump `TEARDOWN_PHASES` if you add another. The grace is `DEFAULT_STOP_GRACE +
4201    // DEFAULT_STOP_GRACE/2 = 15s` (`binaries/daemon/src/running_dataflow.rs`); it lives in
4202    // a binary crate that can't be imported here, hence the hard-coded copy, which the
4203    // daemon const back-references so the two can't silently drift apart.
4204    #[test]
4205    fn zenoh_teardown_fits_within_daemon_force_kill_grace() {
4206        const DAEMON_FORCE_KILL_GRACE: Duration = Duration::from_secs(15);
4207        const TEARDOWN_PHASES: u32 = 3;
4208        let worst_case = ZENOH_TEARDOWN_TIMEOUT * TEARDOWN_PHASES;
4209        assert!(
4210            worst_case < DAEMON_FORCE_KILL_GRACE,
4211            "worst-case zenoh teardown ({worst_case:?}) must stay under the daemon \
4212             force-kill grace ({DAEMON_FORCE_KILL_GRACE:?}); raising ZENOH_TEARDOWN_TIMEOUT \
4213             reintroduces dora-rs/dora#2742"
4214        );
4215    }
4216}
4217
4218/// Ownership invariant at the operator -> runtime boundary (dora-rs/dora#2742).
4219///
4220/// A [`SampleAllocator`] lets an operator thread encode its payload into a
4221/// dora-owned [`EncodedSample`] itself, so the runtime never holds a reference
4222/// to memory whose owner lives in another language runtime. The tests below pin
4223/// the properties that make the result safe to hand across a thread boundary.
4224#[cfg(test)]
4225mod operator_boundary_tests {
4226    use super::*;
4227    use arrow::buffer::Buffer;
4228    use std::ptr::NonNull;
4229    use std::sync::atomic::{AtomicBool, Ordering};
4230
4231    /// Stands in for a foreign owner of an Arrow payload buffer — a numpy array
4232    /// held alive by pyarrow, or a buffer owned by an operator's `.so`. Flips
4233    /// `released` when the last Arrow reference to the buffer goes away.
4234    struct ForeignOwner {
4235        released: Arc<AtomicBool>,
4236        _backing: Vec<u8>,
4237    }
4238
4239    impl Drop for ForeignOwner {
4240        fn drop(&mut self) {
4241            self.released.store(true, Ordering::SeqCst);
4242        }
4243    }
4244
4245    /// A `UInt8` array whose payload buffer is owned by `ForeignOwner`.
4246    fn foreign_owned_array(len: usize) -> (ArrayData, Arc<AtomicBool>) {
4247        let backing = vec![0xABu8; len];
4248        // A `Vec`'s heap allocation does not move when the `Vec` itself is
4249        // moved into `ForeignOwner` below, so this pointer stays valid for as
4250        // long as the owner is alive — which is exactly what the `Allocation`
4251        // contract requires.
4252        let ptr = NonNull::new(backing.as_ptr() as *mut u8).expect("non-null");
4253        let released = Arc::new(AtomicBool::new(false));
4254        let owner = Arc::new(ForeignOwner {
4255            released: released.clone(),
4256            _backing: backing,
4257        });
4258        let buffer = unsafe { Buffer::from_custom_allocation(ptr, len, owner) };
4259        let array = ArrayData::builder(arrow::datatypes::DataType::UInt8)
4260            .len(len)
4261            .add_buffer(buffer)
4262            .build()
4263            .expect("valid UInt8 array");
4264        (array, released)
4265    }
4266
4267    /// The heart of the #2742 fix: the sample the operator hands to the runtime
4268    /// must be an independent, dora-owned copy. If it kept the source buffer
4269    /// alive, the runtime would be the one releasing foreign memory — and for a
4270    /// Python operator that release takes the GIL (pyarrow's `NumPyBuffer`
4271    /// destructor), which stalls the runtime's event loop for as long as the
4272    /// operator holds it.
4273    #[test]
4274    fn encoded_sample_does_not_retain_the_source_payload() {
4275        let allocator = SampleAllocator::heap();
4276        let (array, released) = foreign_owned_array(8192);
4277
4278        let sample = allocator
4279            .encode_arrow_data(&array)
4280            .expect("encoding a UInt8 array must succeed");
4281
4282        assert!(
4283            !released.load(Ordering::SeqCst),
4284            "sanity: the source buffer is still alive while the array is"
4285        );
4286        drop(array);
4287        assert!(
4288            released.load(Ordering::SeqCst),
4289            "the encoded sample must not keep the operator's payload alive; \
4290             otherwise the runtime frees foreign memory (dora-rs/dora#2742)"
4291        );
4292        drop(sample);
4293    }
4294
4295    /// The encode must be lossless: the sample is the same Arrow IPC stream the
4296    /// node would have produced when it did the encoding itself.
4297    #[test]
4298    fn encoded_sample_round_trips_to_the_source_array() {
4299        let allocator = SampleAllocator::heap();
4300        let (array, _released) = foreign_owned_array(1024);
4301
4302        let encoded = allocator.encode_arrow_data(&array).expect("encode");
4303        assert_eq!(encoded.type_name(), format!("{:?}", array.data_type()));
4304        let decoded = crate::node::arrow_utils::decode_arrow_ipc_data(encoded.as_bytes())
4305            .expect("the sample must be a well-formed Arrow IPC stream");
4306
4307        assert_eq!(decoded, array);
4308    }
4309
4310    /// The allocator is handed to operator threads, so it has to cross thread
4311    /// boundaries — and so does the sample it produces.
4312    #[test]
4313    fn allocator_and_sample_cross_thread_boundaries() {
4314        const fn assert_send<T: Send>() {}
4315        const fn assert_send_sync_clone<T: Send + Sync + Clone>() {}
4316        assert_send::<DataSample>();
4317        assert_send::<EncodedSample>();
4318        assert_send_sync_clone::<SampleAllocator>();
4319    }
4320}