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