Skip to main content

dora_node_api/event_stream/
mod.rs

1use std::{
2    collections::{BTreeMap, HashMap, VecDeque},
3    path::PathBuf,
4    pin::pin,
5    sync::{
6        Arc,
7        atomic::{AtomicBool, Ordering},
8    },
9    time::{Duration, Instant},
10};
11
12use dora_message::{
13    DataflowId,
14    daemon_to_node::{DaemonCommunication, DaemonReply, DataMessage, NodeEvent},
15    id::DataId,
16    node_to_daemon::{DaemonRequest, Timestamped},
17};
18pub use event::{Event, StopCause};
19use futures::{
20    FutureExt, Stream,
21    future::{Either, select},
22};
23use futures_timer::Delay;
24use scheduler::{NON_INPUT_EVENT, Scheduler};
25
26use self::thread::{EventItem, EventStreamThreadHandle};
27use crate::{
28    DaemonCommunicationWrapper, PatternError,
29    daemon_connection::{
30        DaemonChannel,
31        node_integration_testing::{convert_arrow_input_to_json, convert_output_to_json},
32    },
33    event_stream::data_conversion::RawData,
34    node::{ZENOH_TEARDOWN_TIMEOUT, teardown_with_timeout},
35};
36use dora_arrow_convert::IntoArrow;
37use dora_core::{
38    config::{Input, NodeId},
39    uhlc,
40};
41use eyre::{Context, eyre};
42
43pub use scheduler::Scheduler as EventScheduler;
44
45mod data_conversion;
46mod event;
47/// Drop notifications for the daemon's opaque extension table.
48pub mod extensions;
49/// Tracks input health (timeouts, liveness) for circuit-breaker recovery.
50pub mod input_tracker;
51/// Merged event streams combining internal and external event sources.
52pub mod merged;
53mod scheduler;
54mod thread;
55
56/// Asynchronous iterator over the incoming [`Event`]s destined for this node.
57///
58/// This struct [implements](#impl-Stream-for-EventStream) the [`Stream`] trait,
59/// so you can use methods of the [`StreamExt`](futures::StreamExt) trait
60/// on this struct. A common pattern is `while let Some(event) = event_stream.next().await`.
61///
62/// Nodes should iterate over this event stream and react to events that they are interested in.
63/// Typically, the most important event type is [`Event::Input`].
64/// You don't need to handle all events, it's fine to ignore events that are not relevant to your node.
65///
66/// The event stream will close itself after a [`Event::Stop`] was received.
67/// A manual `break` on [`Event::Stop`] is typically not needed.
68/// _(You probably do need to use a manual `break` on stop events when using the
69/// [`StreamExt::merge`][`futures_concurrency::stream::StreamExt::merge`] implementation on
70/// [`EventStream`] to combine the stream with an external one.)_
71///
72/// Once the event stream finished, nodes should exit.
73/// Note that Dora kills nodes that don't exit quickly after a [`Event::Stop`] of type
74/// [`StopCause::Manual`] was received.
75pub struct EventStream {
76    node_id: NodeId,
77    // Drop order: Rust drops fields in declaration order (top to bottom).
78    // receiver must drop FIRST so tx_clone.send() in subscriber threads
79    // returns Err, causing threads to exit before JoinHandles are dropped.
80    // Using `tokio::sync::mpsc::Receiver` — instead of `flume` — avoids the
81    // AB-BA deadlock between flume 0.10's spinlock and pyo3's GIL-acquiring
82    // waker when a Python coroutine polls this stream
83    // (upstream dora-rs/dora#1603).
84    receiver: tokio::sync::mpsc::Receiver<EventItem>,
85    _thread_handle: EventStreamThreadHandle,
86    /// Callback subscribers — kept alive for the lifetime of the
87    /// EventStream. Dropping a subscriber undeclares it and stops further
88    /// callbacks. The callbacks use `try_send` (never block), so undeclaring
89    /// does not depend on `receiver` being dropped first. `EventStream::drop`
90    /// explicitly tears these down under a deadline (see there); by the time
91    /// this field drops it is already empty.
92    _zenoh_subscribers: Vec<zenoh::pubsub::Subscriber<()>>,
93    /// Per-input `@schema` subscribers (zenoh-ext AdvancedSubscriber) that prime
94    /// the data subscribers' decoders. Kept alive like `_zenoh_subscribers`.
95    _zenoh_schema_subscribers: Vec<zenoh_ext::AdvancedSubscriber<()>>,
96    /// The `dora-startup-acker` thread (see [`spawn_startup_acker`]). Exits
97    /// once the data subscribers — the only senders into its queue — are
98    /// dropped; joined in `EventStream::drop` inside the bounded zenoh
99    /// teardown, right after those subscribers are dropped.
100    startup_acker: Option<std::thread::JoinHandle<()>>,
101    close_channel: DaemonChannel,
102    clock: Arc<uhlc::HLC>,
103    scheduler: Scheduler,
104    write_events_to: Option<WriteEventsTo>,
105    start_timestamp: uhlc::Timestamp,
106    use_scheduler: bool,
107    /// Expected input types from YAML descriptor (for first-message validation).
108    /// Each input is checked once; after the first message, the entry is removed.
109    input_type_checks: HashMap<DataId, arrow_schema::DataType>,
110    /// Events that were consumed by a pattern-aware helper
111    /// (`recv_service_response`, `recv_action_result`) while searching
112    /// for a correlation match. Drained first on the next `recv()` so
113    /// the caller's main event loop never loses intermediate events
114    /// (dora-rs/adora#148).
115    pending_passthrough: std::collections::VecDeque<Event>,
116    /// Set to true after an `Event::Stop` has been delivered. Zenoh
117    /// subscriber threads hold clones of the event channel sender, so
118    /// the daemon thread's sender drop alone is not enough to close
119    /// the receiver — subsequent `recv_async`/`poll_next` would hang.
120    /// Returning `None` here lets the caller exit and drops the
121    /// `EventStream`, which signals subscriber shutdown.
122    stop_received: bool,
123    /// Testing-mode shutdown flag shared with the in-process daemon thread.
124    /// Set in [`Drop`] before `EventStreamDropped` so a scheduled `next_event`
125    /// sleep cannot deadlock the close handshake (dora-rs/dora#2855).
126    testing_shutdown: Option<Arc<AtomicBool>>,
127}
128
129/// Spawn the consumer half of the startup handshake: a thread that answers
130/// every startup marker with an ack on the marked output's `@ack` topic.
131///
132/// The data callbacks `try_send` the input id of each received marker into
133/// `ack_rx` (a zenoh callback must never `put` itself — it runs on zenoh's IO
134/// worker); this thread publishes the corresponding ack, identifying this
135/// (node, input) in the attachment. The producer switches the output from the
136/// lossless daemon path to direct zenoh once all its required consumers acked.
137///
138/// The thread acks *every* marker for the stream's whole lifetime, so late
139/// producers (dynamic nodes, restarts) get their acks whenever they run their
140/// handshake. Note that the producer's markers are **not** unbounded: it stops
141/// marking an output at its startup grace boundary and pins any still-un-acked
142/// output to the daemon path for the rest of its run (dora-rs/dora#2891). So
143/// acking promptly is what preserves the fast path — an ack-route race that
144/// outlasts the producer's grace does not self-heal, and a dropped ack (the
145/// bounded-queue `try_send` fallback below) costs that output direct zenoh for
146/// the producer's whole run. The thread exits when the data subscribers (the
147/// only senders) are dropped.
148fn spawn_startup_acker(
149    node_id: NodeId,
150    ack_publishers: HashMap<DataId, zenoh::pubsub::Publisher<'static>>,
151    mut ack_rx: tokio::sync::mpsc::Receiver<DataId>,
152    clock: Arc<uhlc::HLC>,
153) -> Option<std::thread::JoinHandle<()>> {
154    use dora_message::metadata::Metadata;
155    use zenoh::Wait;
156
157    if ack_publishers.is_empty() {
158        return None;
159    }
160    let handle = std::thread::Builder::new()
161        .name("dora-startup-acker".into())
162        .spawn(move || {
163            while let Some(input_id) = ack_rx.blocking_recv() {
164                let Some(publisher) = ack_publishers.get(&input_id) else {
165                    continue;
166                };
167                let metadata = Metadata::startup_ack(
168                    clock.new_timestamp(),
169                    node_id.as_ref(),
170                    input_id.as_ref(),
171                );
172                let attachment = match dora_message::encode(&metadata) {
173                    Ok(bytes) => bytes,
174                    Err(e) => {
175                        tracing::debug!(input = %input_id, "failed to serialize startup ack ({e})");
176                        continue;
177                    }
178                };
179                if let Err(e) = publisher.put(&[][..]).attachment(&attachment[..]).wait() {
180                    // Expected while the ack route is still coming up; the
181                    // producer's next marker triggers a retry.
182                    tracing::trace!(input = %input_id, "startup ack put failed ({e})");
183                }
184            }
185        });
186    match handle {
187        Ok(handle) => Some(handle),
188        Err(e) => {
189            // Without acks the producers keep this node's inputs on the
190            // reliable daemon path — correct, just without the fast path.
191            tracing::warn!(
192                "failed to spawn startup-acker thread ({e}); \
193                 producers keep this node's inputs on the daemon path"
194            );
195            None
196        }
197    }
198}
199
200impl EventStream {
201    #[allow(clippy::too_many_arguments)]
202    #[tracing::instrument(level = "trace", skip(clock, zenoh_session))]
203    pub(crate) fn init(
204        dataflow_id: DataflowId,
205        node_id: &NodeId,
206        daemon_communication: &DaemonCommunicationWrapper,
207        input_config: BTreeMap<DataId, Input>,
208        input_types: &BTreeMap<DataId, String>,
209        clock: Arc<uhlc::HLC>,
210        write_events_to: Option<PathBuf>,
211        zenoh_session: Option<&zenoh::Session>,
212    ) -> eyre::Result<Self> {
213        let channel = match daemon_communication {
214            DaemonCommunicationWrapper::Standard(daemon_communication) => {
215                match daemon_communication {
216                    DaemonCommunication::Tcp { socket_addr } => {
217                        DaemonChannel::new_tcp(*socket_addr).wrap_err_with(|| {
218                            format!("failed to connect event stream for node `{node_id}`")
219                        })?
220                    }
221
222                    DaemonCommunication::Interactive => {
223                        DaemonChannel::Interactive(Default::default())
224                    }
225                }
226            }
227
228            DaemonCommunicationWrapper::Testing { channel, .. } => {
229                DaemonChannel::IntegrationTestChannel(channel.clone())
230            }
231        };
232
233        let testing_shutdown = match daemon_communication {
234            DaemonCommunicationWrapper::Testing { shutdown, .. } => Some(shutdown.clone()),
235            _ => None,
236        };
237
238        let close_channel = match daemon_communication {
239            DaemonCommunicationWrapper::Standard(daemon_communication) => {
240                match daemon_communication {
241                    DaemonCommunication::Tcp { socket_addr } => {
242                        DaemonChannel::new_tcp(*socket_addr).wrap_err_with(|| {
243                            format!("failed to connect event close channel for node `{node_id}`")
244                        })?
245                    }
246                    DaemonCommunication::Interactive => {
247                        DaemonChannel::Interactive(Default::default())
248                    }
249                }
250            }
251            DaemonCommunicationWrapper::Testing { channel, .. } => {
252                DaemonChannel::IntegrationTestChannel(channel.clone())
253            }
254        };
255
256        let mut queue_size_limit: HashMap<DataId, (usize, VecDeque<EventItem>)> = input_config
257            .iter()
258            .map(|(input, config)| {
259                (
260                    input.clone(),
261                    (
262                        config
263                            .queue_size
264                            .unwrap_or(dora_message::config::DEFAULT_QUEUE_SIZE),
265                        VecDeque::new(),
266                    ),
267                )
268            })
269            .collect();
270
271        queue_size_limit.insert(
272            DataId::from(NON_INPUT_EVENT.to_string()),
273            (1_000, VecDeque::new()),
274        );
275
276        let queue_policies: HashMap<DataId, dora_message::config::QueuePolicy> = input_config
277            .iter()
278            .filter_map(|(input, config)| config.queue_policy.map(|p| (input.clone(), p)))
279            .collect();
280
281        let scheduler = Scheduler::with_policies(queue_size_limit, queue_policies);
282
283        let total_queue_capacity: usize = input_config
284            .values()
285            .map(|c| {
286                c.queue_size
287                    .unwrap_or(dora_message::config::DEFAULT_QUEUE_SIZE)
288            })
289            .sum::<usize>()
290            .max(64);
291
292        let write_events_to = match write_events_to {
293            Some(path) => {
294                if let Some(parent) = path.parent() {
295                    std::fs::create_dir_all(parent).wrap_err_with(|| {
296                        format!(
297                            "failed to create parent directories for event output file `{}` for node `{}`",
298                            path.display(),
299                            node_id
300                        )
301                    })?;
302                }
303
304                let file = std::fs::File::create(&path).wrap_err_with(|| {
305                    format!(
306                        "failed to create event output file `{}` for node `{}`",
307                        path.display(),
308                        node_id
309                    )
310                })?;
311
312                Some(WriteEventsTo {
313                    node_id: node_id.clone(),
314                    file,
315                    events_buffer: Vec::new(),
316                    poisoned: None,
317                })
318            }
319            None => None,
320        };
321
322        // Resolve input type URNs to Arrow DataTypes for first-message validation.
323        let mut input_type_checks = HashMap::new();
324        {
325            let registry = dora_core::types::TypeRegistry::new();
326            for (input_id, type_urn) in input_types {
327                match registry.resolve_arrow_type(type_urn) {
328                    Some(dt) => {
329                        input_type_checks.insert(input_id.clone(), dt);
330                    }
331                    None => {
332                        // Complex or custom types not resolvable to a simple Arrow DataType
333                        if registry.resolve(type_urn).is_some() {
334                            tracing::debug!(
335                                input = %input_id,
336                                "skipping type check for complex type \"{type_urn}\""
337                            );
338                        } else {
339                            tracing::warn!(
340                                input = %input_id,
341                                "unknown input type URN \"{type_urn}\" — skipping type check"
342                            );
343                        }
344                    }
345                }
346            }
347        }
348
349        Self::init_on_channel(
350            dataflow_id,
351            node_id,
352            channel,
353            close_channel,
354            clock,
355            scheduler,
356            write_events_to,
357            input_type_checks,
358            total_queue_capacity,
359            zenoh_session,
360            &input_config,
361            testing_shutdown,
362        )
363    }
364
365    #[allow(clippy::too_many_arguments)]
366    pub(crate) fn init_on_channel(
367        dataflow_id: DataflowId,
368        node_id: &NodeId,
369        mut channel: DaemonChannel,
370        mut close_channel: DaemonChannel,
371        clock: Arc<uhlc::HLC>,
372        scheduler: Scheduler,
373        write_events_to: Option<WriteEventsTo>,
374        input_type_checks: HashMap<DataId, arrow_schema::DataType>,
375        channel_capacity: usize,
376        zenoh_session: Option<&zenoh::Session>,
377        input_config: &BTreeMap<DataId, Input>,
378        testing_shutdown: Option<Arc<AtomicBool>>,
379    ) -> eyre::Result<Self> {
380        channel.register(dataflow_id, node_id.clone(), clock.new_timestamp())?;
381        let (tx, rx) = tokio::sync::mpsc::channel(channel_capacity);
382
383        let use_scheduler = match &channel {
384            DaemonChannel::IntegrationTestChannel(_) => {
385                // don't use the scheduler for integration tests because it leads to
386                // non-deterministic event ordering
387                false
388            }
389            _ => true,
390        };
391
392        // Declare zenoh subscribers for each input that has a source node.
393        // We use callback subscribers so the zenoh IO thread delivers the
394        // sample directly into the event channel without an intermediate
395        // dora-side thread + recv_async wakeup. The `Subscriber<()>` handle
396        // must be kept alive for the lifetime of the EventStream — we store
397        // them in `_zenoh_subscribers` and drop them after `receiver`.
398        let mut zenoh_subscribers = Vec::new();
399        let mut zenoh_schema_subscribers = Vec::new();
400        // Consumer half of the startup handshake: the data callbacks enqueue
401        // the input id of every received startup marker here, and the
402        // `dora-startup-acker` thread answers each with an ack on the output's
403        // `@ack` topic (see `spawn_startup_acker`). Bounded and `try_send`-fed:
404        // a full queue just delays the ack until the producer's next marker.
405        let (ack_tx, ack_rx) = tokio::sync::mpsc::channel::<DataId>(256);
406        let mut ack_publishers: HashMap<DataId, zenoh::pubsub::Publisher<'static>> = HashMap::new();
407        if let Some(session) = zenoh_session {
408            use zenoh::Wait;
409            use zenoh::qos::CongestionControl;
410            for (input_id, input) in input_config {
411                let mapping = &input.mapping;
412                if let dora_message::config::InputMapping::User(user_mapping) = mapping {
413                    let source_node = &user_mapping.source;
414                    let source_output = &user_mapping.output;
415                    let topic = dora_core::topics::zenoh_output_publish_topic(
416                        dataflow_id,
417                        source_node,
418                        source_output,
419                    );
420                    let key_expr = match zenoh::key_expr::KeyExpr::new(topic.clone()) {
421                        Ok(k) => k.into_owned(),
422                        Err(e) => {
423                            tracing::warn!(input = %input_id, "invalid zenoh key ({e}), using daemon path");
424                            continue;
425                        }
426                    };
427                    // Ack publisher for this input, declared eagerly so its
428                    // route wires while the node is parked in the barrier.
429                    // `express` + `Drop` QoS: a lost ack is retried on the
430                    // producer's next marker. On failure the producer's ack
431                    // deadline keeps the output on the (correct) daemon path.
432                    let ack_topic = dora_core::topics::zenoh_output_ack_topic(
433                        dataflow_id,
434                        source_node,
435                        source_output,
436                    );
437                    match session
438                        .declare_publisher(ack_topic)
439                        .congestion_control(CongestionControl::Drop)
440                        .express(true)
441                        .wait()
442                    {
443                        Ok(publisher) => {
444                            ack_publishers.insert(input_id.clone(), publisher);
445                        }
446                        Err(e) => {
447                            tracing::warn!(
448                                input = %input_id,
449                                "failed to declare startup-ack publisher ({e}); \
450                                 the producer keeps this input on the daemon path"
451                            );
452                        }
453                    }
454                    // Per-input persistent decoder for the schema-once path,
455                    // shared between this data subscriber (which decodes batches
456                    // in zenoh receipt order) and the `@schema` subscriber (which
457                    // primes it from the cached/live schema). `Mutex` because the
458                    // zenoh callbacks are `Fn`; uncontended in practice (each
459                    // subscriber delivers its samples serially).
460                    let decoder = std::sync::Arc::new(std::sync::Mutex::new(
461                        crate::arrow_utils::ipc_encode::InputDecoder::new(),
462                    ));
463                    // Set if the `@schema` subscriber fails to declare: the
464                    // schema plane is then dead for this input and only the
465                    // producer's periodic in-band full-stream refresh can prime
466                    // the decoder. The data callback surfaces a `FatalError` if
467                    // the input stays undecodable past a grace window (see
468                    // below), distinguishing a genuinely dead input from the
469                    // transient "schema not arrived yet" drop.
470                    let schema_plane_failed =
471                        std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
472                    // Start of the current run of undecodable schema-once
473                    // batches while the schema plane is dead; cleared by any
474                    // successful decode. Only touched when `schema_plane_failed`
475                    // is set, so it costs nothing on the healthy path.
476                    let first_undecodable =
477                        std::sync::Arc::new(std::sync::Mutex::new(Option::<Instant>::None));
478                    // The `@schema` subscriber primes `decoder`; its history query
479                    // fetches the cached schema on join (late joiners), and
480                    // `detect_late_publishers` covers a producer that starts later.
481                    declare_schema_subscriber(
482                        session,
483                        dataflow_id,
484                        source_node,
485                        source_output,
486                        input_id,
487                        decoder.clone(),
488                        tx.clone(),
489                        schema_plane_failed.clone(),
490                        &mut zenoh_schema_subscribers,
491                    );
492
493                    let ack_tx_cb = ack_tx.clone();
494                    let tx_cb = tx.clone();
495                    let input_id_cb = input_id.clone();
496                    let decoder = decoder.clone();
497                    let first_undecodable_cb = first_undecodable.clone();
498                    let subscriber = session
499                        .declare_subscriber(key_expr)
500                        .callback(move |sample| {
501                            // catch_unwind: a panic inside the callback would
502                            // otherwise unwind through zenoh's IO worker, which
503                            // is unsafe. Surface as FatalError so the node sees
504                            // it and exits cleanly.
505                            let result =
506                                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
507                                    use dora_message::metadata::Metadata;
508                                    let metadata = match sample.attachment() {
509                                        Some(att) => {
510                                            match dora_message::decode::<Metadata>(&att.to_bytes())
511                                            {
512                                                // A version mismatch that still happens
513                                                // to deserialize: reject with a clear
514                                                // message rather than acting on data from
515                                                // an incompatible wire format.
516                                                Ok(m)
517                                                    if m.metadata_version()
518                                                        != Metadata::CURRENT_VERSION =>
519                                                {
520                                                    tracing::warn!(
521                                                        "dropping zenoh sample: incompatible \
522                                                     metadata wire version {} (this node \
523                                                     speaks {})",
524                                                        m.metadata_version(),
525                                                        Metadata::CURRENT_VERSION
526                                                    );
527                                                    return;
528                                                }
529                                                Ok(m) => m,
530                                                Err(e) => {
531                                                    // A pre-1.0 peer (old ArrowTypeInfo
532                                                    // sidecar layout) misaligns here; name
533                                                    // the likely cause so the failure isn't
534                                                    // a bare deserialization error.
535                                                    tracing::warn!(
536                                                        "zenoh metadata deserialization failed \
537                                                     (possibly a peer using an incompatible \
538                                                     wire format/version): {e}"
539                                                    );
540                                                    return;
541                                                }
542                                            }
543                                        }
544                                        None => {
545                                            tracing::warn!(
546                                                "zenoh sample missing metadata attachment"
547                                            );
548                                            return;
549                                        }
550                                    };
551                                    // Startup marker: its arrival proves this input's
552                                    // zenoh route carries data end-to-end. Answer with
553                                    // an ack (via the acker thread — a zenoh callback
554                                    // must not `put` itself) so the producer can switch
555                                    // the output to the direct zenoh path, and stop — a
556                                    // marker has no payload and must never reach the
557                                    // (stateful) decoder or user code. A full ack queue
558                                    // is fine: the producer's next marker retries.
559                                    if metadata.is_startup_marker() {
560                                        let _ = ack_tx_cb.try_send(input_id_cb.clone());
561                                        return;
562                                    }
563                                    let payload = sample.payload().clone();
564                                    // Decode here (receipt order) so the per-input
565                                    // persistent decoder stays in sync. This runs on
566                                    // zenoh's IO worker: the aligned-SHM path is
567                                    // zero-copy, but an under-aligned heap payload
568                                    // copies its buffers on this thread (acceptable —
569                                    // only small messages take the heap path).
570                                    let mut decoder = decoder.lock().unwrap_or_else(|poison| {
571                                        // A prior callback panicked mid-decode
572                                        // (caught below), poisoning the lock and
573                                        // leaving the decoder in an undefined
574                                        // state. Reset it so the next batch is not
575                                        // fed into a corrupt decoder.
576                                        let mut guard = poison.into_inner();
577                                        guard.reset();
578                                        guard
579                                    });
580                                    let data =
581                                        match decode_zenoh_sample(&mut decoder, &metadata, payload)
582                                        {
583                                            Ok(Some(data)) => data,
584                                            // Schema-less batch we can't decode yet.
585                                            // Normally transient: the priming schema
586                                            // hasn't arrived (lossy data plane), so drop
587                                            // and wait — the window is bounded by the
588                                            // producer's periodic full-stream refresh,
589                                            // which re-primes in-band. But if the
590                                            // `@schema` subscriber failed to declare, the
591                                            // schema plane is dead (a degraded zenoh
592                                            // session) and only that refresh can save the
593                                            // input — give it a grace window, then surface
594                                            // a `FatalError` so the node exits loudly
595                                            // rather than drop messages forever. Only
596                                            // clear the flag once the error is actually
597                                            // queued, so a momentarily-full channel can't
598                                            // swallow the one-shot fatal signal forever; it
599                                            // retries on the next undecodable batch. This
600                                            // callback is the sole reader, so the
601                                            // load-then-store is race-free. `Relaxed` is
602                                            // sufficient: the flag is stored in
603                                            // `declare_schema_subscriber` before the data
604                                            // subscriber that drives this callback is even
605                                            // declared, so that write happens-before any
606                                            // read here.
607                                            Ok(None) => {
608                                                if schema_plane_failed
609                                                    .load(std::sync::atomic::Ordering::Relaxed)
610                                                {
611                                                    let mut first = first_undecodable_cb
612                                                        .lock()
613                                                        .unwrap_or_else(|p| p.into_inner());
614                                                    if first.is_none() {
615                                                        tracing::warn!(
616                                                            input = %input_id_cb,
617                                                            "schema-once batch arrived unprimed \
618                                                             while the `@schema` subscriber is \
619                                                             not declared; dropping, waiting up \
620                                                             to {}s for the producer's in-band \
621                                                             full-stream refresh",
622                                                            SCHEMA_PLANE_FATAL_GRACE.as_secs()
623                                                        );
624                                                    }
625                                                    if schema_plane_fatal_due(
626                                                        &mut first,
627                                                        Instant::now(),
628                                                    ) && tx_cb
629                                                        .try_send(EventItem::FatalError(eyre!(
630                                                            "input `{input_id_cb}`: the `@schema` \
631                                                             subscriber failed to declare (a \
632                                                             degraded zenoh session) and the \
633                                                             input stayed undecodable for {}s \
634                                                             despite the producer's periodic \
635                                                             full-stream refresh — messages on \
636                                                             this input are being dropped",
637                                                            SCHEMA_PLANE_FATAL_GRACE.as_secs()
638                                                        )))
639                                                        .is_ok()
640                                                    {
641                                                        schema_plane_failed.store(
642                                                            false,
643                                                            std::sync::atomic::Ordering::Relaxed,
644                                                        );
645                                                    }
646                                                }
647                                                return;
648                                            }
649                                            Err(e) => {
650                                                tracing::warn!(
651                                                    input = %input_id_cb,
652                                                    "zenoh payload decode failed: {e}"
653                                                );
654                                                return;
655                                            }
656                                        };
657                                    drop(decoder);
658                                    // A successful decode means the input is healthy
659                                    // (in-band priming worked); reset the fatal grace
660                                    // window. Only costs a lock when the schema plane
661                                    // is actually dead.
662                                    if schema_plane_failed
663                                        .load(std::sync::atomic::Ordering::Relaxed)
664                                    {
665                                        *first_undecodable_cb
666                                            .lock()
667                                            .unwrap_or_else(|p| p.into_inner()) = None;
668                                    }
669                                    // Callback runs on zenoh's tokio IO worker —
670                                    // `blocking_send` panics from a tokio context, so
671                                    // use `try_send`. If the channel is full the event
672                                    // is dropped (logged); receiver-dropped also
673                                    // surfaces here, in which case there's nothing to do.
674                                    if let Err(e) = tx_cb.try_send(EventItem::ZenohInput {
675                                        id: input_id_cb.clone(),
676                                        metadata: std::sync::Arc::new(metadata),
677                                        data,
678                                    }) {
679                                        use tokio::sync::mpsc::error::TrySendError;
680                                        match e {
681                                            TrySendError::Full(_) => {
682                                                tracing::warn!(
683                                                    "event channel full; dropping zenoh input"
684                                                );
685                                            }
686                                            TrySendError::Closed(_) => {
687                                                // normal shutdown
688                                            }
689                                        }
690                                    }
691                                }));
692                            if result.is_err() {
693                                tracing::error!(
694                                    input = %input_id_cb,
695                                    "zenoh subscriber callback panicked"
696                                );
697                                let _ = tx_cb.try_send(EventItem::FatalError(eyre!(
698                                    "zenoh subscriber callback for input `{input_id_cb}` panicked"
699                                )));
700                            }
701                        })
702                        .wait();
703                    match subscriber {
704                        Ok(s) => {
705                            tracing::debug!(input = %input_id, %topic, "zenoh subscriber declared (callback)");
706                            zenoh_subscribers.push(s);
707                        }
708                        Err(e) => {
709                            // No zenoh subscriber for this input: no marker can
710                            // ever arrive, so this node never acks it and the
711                            // producer keeps the output on the reliable daemon
712                            // path — the input is served via daemon events.
713                            tracing::warn!(
714                                input = %input_id,
715                                "failed to declare zenoh subscriber ({e}), using daemon path"
716                            );
717                        }
718                    }
719                }
720            }
721        }
722
723        // Consumer half of the startup handshake: from here on, every startup
724        // marker received by a data callback above is answered with an ack.
725        // Nothing blocks and nothing can fail init: a route that never
726        // delivers a marker simply never gets acked, and the producer keeps
727        // that output on the lossless daemon path (see `StartupHandshake` on
728        // the producer side). The acker runs for the stream's whole lifetime
729        // so late producers (dynamic nodes, restarts) get acks too.
730        drop(ack_tx); // the callbacks hold the only remaining senders
731        let startup_acker =
732            spawn_startup_acker(node_id.clone(), ack_publishers, ack_rx, clock.clone());
733
734        let reply = channel
735            .request(&Timestamped {
736                inner: DaemonRequest::Subscribe,
737                timestamp: clock.new_timestamp(),
738            })
739            .map_err(|e| eyre!(e))
740            .wrap_err("failed to create subscription with dora-daemon")?;
741
742        match reply {
743            DaemonReply::Result(Ok(())) => {}
744            DaemonReply::Result(Err(err)) => {
745                eyre::bail!("subscribe failed: {err}")
746            }
747            other => eyre::bail!("unexpected subscribe reply: {other:?}"),
748        }
749
750        close_channel.register(dataflow_id, node_id.clone(), clock.new_timestamp())?;
751
752        let thread_handle = thread::init(node_id.clone(), tx, channel, clock.clone())?;
753
754        Ok(EventStream {
755            node_id: node_id.clone(),
756            receiver: rx,
757            _thread_handle: thread_handle,
758            _zenoh_subscribers: zenoh_subscribers,
759            _zenoh_schema_subscribers: zenoh_schema_subscribers,
760            startup_acker,
761            close_channel,
762            start_timestamp: clock.new_timestamp(),
763            clock,
764            scheduler,
765            write_events_to,
766            use_scheduler,
767            input_type_checks,
768            pending_passthrough: std::collections::VecDeque::new(),
769            stop_received: false,
770            testing_shutdown,
771        })
772    }
773
774    /// Synchronously waits for the next event.
775    ///
776    /// Blocks the thread until the next event arrives.
777    /// Returns [`None`] once the event stream is closed.
778    ///
779    /// For an asynchronous variant of this method see [`recv_async`][Self::recv_async].
780    ///
781    /// ## Event Reordering
782    ///
783    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
784    /// events might be returned in a different order than they occurred. For details, check the
785    /// documentation of the [`EventScheduler`] struct.
786    ///
787    /// If you want to receive the events in their original chronological order, use the
788    /// asynchronous [`StreamExt::next`](futures::StreamExt::next) method instead ([`EventStream`] implements the
789    /// [`Stream`] trait).
790    ///
791    /// The canonical node loop drains this stream until it closes, reacting to
792    /// the events the node cares about (typically [`Event::Input`]) and ignoring
793    /// the rest:
794    ///
795    /// ```no_run
796    /// use dora_node_api::{DoraNode, Event};
797    ///
798    /// let (_node, mut events) = DoraNode::init_from_env()?;
799    ///
800    /// while let Some(event) = events.recv() {
801    ///     match event {
802    ///         Event::Input { id, metadata: _, data } => {
803    ///             // react to the input `id`, reading the Arrow `data`
804    ///             println!("received input `{id}` with {} element(s)", data.len());
805    ///         }
806    ///         Event::Stop(_) => break,
807    ///         _ => {}
808    ///     }
809    /// }
810    /// # Ok::<(), eyre::Report>(())
811    /// ```
812    pub fn recv(&mut self) -> Option<Event> {
813        futures::executor::block_on(self.recv_async())
814    }
815
816    /// Receives the next incoming [`Event`] synchronously with a timeout.
817    ///
818    /// Blocks the thread until the next event arrives or the timeout is reached.
819    /// Returns a [`Event::Error`] if no event was received within the given duration.
820    ///
821    /// Returns [`None`] once the event stream is closed.
822    ///
823    /// For an asynchronous variant of this method see [`recv_async_timeout`][Self::recv_async_timeout].
824    ///
825    /// ## Event Reordering
826    ///
827    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
828    /// events might be returned in a different order than they occurred. For details, check the
829    /// documentation of the [`EventScheduler`] struct.
830    ///
831    /// If you want to receive the events in their original chronological order, use the
832    /// asynchronous [`StreamExt::next`](futures::StreamExt::next) method instead ([`EventStream`] implements the
833    /// [`Stream`] trait).
834    pub fn recv_timeout(&mut self, dur: Duration) -> Option<Event> {
835        futures::executor::block_on(self.recv_async_timeout(dur))
836    }
837
838    /// Receives the next incoming [`Event`] asynchronously, using an [`EventScheduler`] for fairness.
839    ///
840    /// Returns [`None`] once the event stream is closed.
841    ///
842    /// ## Event Reordering
843    ///
844    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
845    /// events might be returned in a different order than they occurred. For details, check the
846    /// documentation of the [`EventScheduler`] struct.
847    ///
848    /// If you want to receive the events in their original chronological order, use the
849    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
850    /// ([`EventStream`] implements the [`Stream`] trait).
851    pub async fn recv_async(&mut self) -> Option<Event> {
852        // Drain any events that were stashed by pattern-aware helpers
853        // (`recv_service_response`, `recv_action_result`) while they
854        // were waiting for a specific correlation. These must be
855        // returned to the caller before we poll the underlying
856        // scheduler, so the caller's main event loop never loses
857        // events that arrived during a helper wait (dora-rs/adora#148).
858        if let Some(event) = self.pending_passthrough.pop_front() {
859            return Some(event);
860        }
861        self.recv_from_stream().await
862    }
863
864    /// Receive the next event straight from the scheduler/receiver,
865    /// **without** draining `pending_passthrough` first.
866    ///
867    /// The pattern-aware wait loop ([`wait_for_correlation`](Self::wait_for_correlation))
868    /// buffers every non-matching event into `pending_passthrough` itself. If
869    /// it pumped the stream through [`recv_async`](Self::recv_async), that
870    /// drain would immediately hand the just-buffered event straight back, the
871    /// classifier would re-buffer it, and the loop would spin on the same event
872    /// forever — never reading the awaited response off the receiver — until it
873    /// hit its deadline and wrongly reported a timeout (while pinning a CPU
874    /// core). Reading through this bypass keeps the buffered events reserved for
875    /// the caller's own `recv`/`recv_async` while the wait loop makes real
876    /// progress.
877    async fn recv_from_stream(&mut self) -> Option<Event> {
878        // Close the stream after a Stop event: the daemon thread has
879        // already dropped its sender, but zenoh subscriber threads
880        // hold clones that would otherwise keep `receiver` open.
881        if self.stop_received {
882            // The scheduler gives `Stop` (a NON_INPUT_EVENT) strict priority
883            // over buffered inputs (`scheduler::next`), so inputs enqueued
884            // before Stop can still be queued when Stop is delivered. Drain
885            // those buffered *inputs* before closing instead of dropping them
886            // silently. Trailing non-input control events (a second Stop /
887            // AllInputsClosed / InputClosed / Reload) are discarded rather than
888            // re-delivered after Stop — the contract is "nothing after Stop
889            // except the inputs that were already queued". Do NOT pull new
890            // events from `receiver`; the dataflow is stopping, and on the
891            // non-scheduler path returning `None` closes the stream against
892            // zenoh-held senders.
893            if self.use_scheduler {
894                while let Some(item) = self.scheduler.next() {
895                    if matches!(
896                        &item,
897                        EventItem::NodeEvent {
898                            event: NodeEvent::Input { .. },
899                            ..
900                        } | EventItem::ZenohInput { .. }
901                    ) {
902                        return Some(Self::convert_event_item(item));
903                    }
904                }
905            }
906            return None;
907        }
908        let event = if !self.use_scheduler {
909            self.receiver.recv().await.map(Self::convert_event_item)
910        } else {
911            // Block for the first event while the scheduler is empty, then drain
912            // the rest non-blocking. The old code re-checked `is_empty()` on
913            // every iteration; `Scheduler::is_empty()` scans every input queue
914            // (O(#inputs)), so draining K events cost O(K·#inputs).
915            //
916            // `add_event` usually pushes, but it can also *drop* the event
917            // without retaining it (e.g. `queue_size: 0` -> `DropIncoming`), so
918            // we must keep blocking while the scheduler is still empty rather
919            // than assume a single `recv` made it non-empty — otherwise a
920            // dropped-only event would fall through to `scheduler.next() ==
921            // None` and be misread as a closed stream. This preserves the
922            // previous "block until a retained event arrives" behavior while
923            // checking `is_empty()` only twice in the common case instead of
924            // once per drained event.
925            while self.scheduler.is_empty() {
926                match self.receiver.recv().await {
927                    Some(event) => self.add_event(event),
928                    None => break,
929                }
930            }
931            while let Ok(event) = self.receiver.try_recv() {
932                self.add_event(event);
933            }
934            self.scheduler.next().map(Self::convert_event_item)
935        };
936
937        if let Some(ref event) = event {
938            self.note_produced_event(event);
939        }
940        event
941    }
942
943    /// Post-process an event just produced by `recv_async` / `poll_next`: run
944    /// the one-shot, first-message input type check and update the
945    /// stop-tracking flag. Shared by both receive paths so they cannot drift —
946    /// the two paths must stay in lockstep (dora-rs/adora#172, #174).
947    fn note_produced_event(&mut self, event: &Event) {
948        // First-message type validation: check once per input, then remove.
949        // `contains_key` short-circuits cheaply once the check is consumed, so
950        // steady-state topic messages pay a single map lookup (zero extra cost
951        // after the first message per input).
952        //
953        // Skip the check (and keep it armed) when the message carries pattern
954        // metadata (`request_id`, `goal_id`, or `goal_status`) — the input is
955        // polymorphic by pattern design and a single declared type cannot cover
956        // all variants (dora-rs/adora#150). The membership test runs before the
957        // pattern predicate so the common consumed-check path avoids the
958        // parameter lookups, and before `remove` so an armed pattern input's
959        // stored `DataType` is never cloned.
960        if let Event::Input { id, metadata, data } = event
961            && self.input_type_checks.contains_key(id)
962            && !crate::node::carries_pattern_correlation(&metadata.parameters)
963            && let Some(expected) = self.input_type_checks.remove(id)
964        {
965            let raw = dora_arrow_convert::internal::array_ref(data);
966            let actual = raw.data_type();
967            // Skip check for Null type (timer ticks, empty payloads)
968            // to avoid spurious warnings on annotated timer inputs.
969            if *actual != arrow_schema::DataType::Null && *actual != expected {
970                tracing::warn!(
971                    input = %id,
972                    expected = ?expected,
973                    actual = ?actual,
974                    "input type mismatch on first message"
975                );
976            }
977        }
978
979        if matches!(event, Event::Stop(_)) {
980            self.stop_received = true;
981        }
982    }
983
984    /// Check if there are any buffered events in the scheduler, the
985    /// receiver, or the passthrough buffer used by pattern-aware helpers.
986    pub fn is_empty(&self) -> bool {
987        self.pending_passthrough.is_empty() && self.scheduler.is_empty() && self.receiver.is_empty()
988    }
989
990    /// Returns and resets the accumulated drop counts per input ID.
991    ///
992    /// When inputs overflow their queue limits, the oldest messages are discarded.
993    /// For `drop_oldest` inputs this happens at `queue_size`. For `backpressure`
994    /// inputs this happens at a hard safety cap of 10x `queue_size`.
995    /// This method returns a map from input ID to the number of messages dropped
996    /// since the last call.
997    pub fn drain_drop_counts(&mut self) -> HashMap<DataId, u64> {
998        self.scheduler.drain_drop_counts()
999    }
1000
1001    fn add_event(&mut self, event: EventItem) {
1002        // Event recording is observability-only (writes to the optional
1003        // `write_events_to` log). A write failure must not panic the event
1004        // loop — drop the log line and continue scheduling.
1005        if let Err(err) = self.record_event(&event) {
1006            tracing::warn!(
1007                node = %self.node_id,
1008                "failed to record event to write_events_to log: {err:?}"
1009            );
1010            // Mark the recording poisoned so consumers can detect events
1011            // are missing from the final JSON. `write_out()` surfaces this
1012            // as a top-level `recording_status` field (#1857).
1013            if let Some(write_events_to) = self.write_events_to.as_mut() {
1014                let time_offset_secs = self
1015                    .clock
1016                    .new_timestamp()
1017                    .get_diff_duration(&self.start_timestamp)
1018                    .as_secs_f64();
1019                write_events_to.mark_poisoned(&err, time_offset_secs);
1020            }
1021        }
1022        self.scheduler.add_event(event);
1023    }
1024
1025    fn record_event(&mut self, event: &EventItem) -> eyre::Result<()> {
1026        if let Some(write_events_to) = &mut self.write_events_to {
1027            let event_json = match event {
1028                EventItem::NodeEvent { event, .. } => match event {
1029                    NodeEvent::Stop => Some(control_event_json(
1030                        &self.clock,
1031                        &self.start_timestamp,
1032                        "Stop",
1033                        None,
1034                    )),
1035                    NodeEvent::Reload { .. } => None,
1036                    NodeEvent::Input { id, metadata, data } => {
1037                        let mut event_json = convert_output_to_json(
1038                            id,
1039                            metadata,
1040                            data,
1041                            self.start_timestamp,
1042                            false,
1043                        )?;
1044                        event_json.insert("type".into(), "Input".into());
1045                        Some(event_json.into())
1046                    }
1047                    NodeEvent::InputClosed { id } => Some(control_event_json(
1048                        &self.clock,
1049                        &self.start_timestamp,
1050                        "InputClosed",
1051                        Some(id.to_string()),
1052                    )),
1053                    NodeEvent::InputRecovered { id } => Some(control_event_json(
1054                        &self.clock,
1055                        &self.start_timestamp,
1056                        "InputRecovered",
1057                        Some(id.to_string()),
1058                    )),
1059                    NodeEvent::NodeRestarted { id } => Some(control_event_json(
1060                        &self.clock,
1061                        &self.start_timestamp,
1062                        "NodeRestarted",
1063                        Some(id.to_string()),
1064                    )),
1065                    NodeEvent::AllInputsClosed => Some(control_event_json(
1066                        &self.clock,
1067                        &self.start_timestamp,
1068                        "AllInputsClosed",
1069                        None,
1070                    )),
1071                    _ => None,
1072                },
1073                // Zenoh-delivered inputs surface to the user as `Event::Input`
1074                // exactly like the daemon-path `NodeEvent::Input` above, but
1075                // bypass the daemon, so neither this recorder nor the daemon
1076                // would otherwise capture them — silently dropping inputs from
1077                // the `write_events_to` recording. Record them here too.
1078                EventItem::ZenohInput { id, metadata, data } => {
1079                    let array = arrow::array::make_array(data.clone());
1080                    let mut event_json = convert_arrow_input_to_json(
1081                        id,
1082                        metadata,
1083                        array,
1084                        self.start_timestamp,
1085                        false,
1086                    )?;
1087                    event_json.insert("type".into(), "Input".into());
1088                    Some(event_json.into())
1089                }
1090                _ => None,
1091            };
1092            if let Some(event_json) = event_json {
1093                write_events_to.events_buffer.push(event_json);
1094            }
1095        }
1096        Ok(())
1097    }
1098
1099    /// Receives the next buffered [`Event`] (if any) without blocking, using an
1100    /// [`EventScheduler`] for fairness.
1101    ///
1102    /// Returns [`TryRecvError::Empty`] if no event is available right now.
1103    /// Returns [`TryRecvError::Closed`] once the event stream is closed.
1104    ///
1105    /// This method never blocks and is safe to use in asynchronous contexts.
1106    ///
1107    /// ## Event Reordering
1108    ///
1109    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
1110    /// events might be returned in a different order than they occurred. For details, check the
1111    /// documentation of the [`EventScheduler`] struct.
1112    ///
1113    /// If you want to receive the events in their original chronological order, use the
1114    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
1115    /// ([`EventStream`] implements the [`Stream`] trait).
1116    pub fn try_recv(&mut self) -> Result<Event, TryRecvError> {
1117        match self.recv_async().now_or_never() {
1118            Some(Some(event)) => Ok(event),
1119            Some(None) => Err(TryRecvError::Closed),
1120            None => Err(TryRecvError::Empty),
1121        }
1122    }
1123
1124    /// Receives all buffered [`Event`]s without blocking, using an [`EventScheduler`] for fairness.
1125    ///
1126    /// Return `Some(Vec::new())` if no events are ready.
1127    /// Returns [`None`] once the event stream is closed and no events are buffered anymore.
1128    ///
1129    /// This method never blocks and is safe to use in asynchronous contexts.
1130    ///
1131    /// This method is equivalent to repeatedly calling [`try_recv`][Self::try_recv]. See its docs
1132    /// for details on event reordering.
1133    pub fn drain(&mut self) -> Option<Vec<Event>> {
1134        let mut events = Vec::new();
1135        loop {
1136            match self.try_recv() {
1137                Ok(event) => events.push(event),
1138                Err(TryRecvError::Empty) => break,
1139                Err(TryRecvError::Closed) => {
1140                    if events.is_empty() {
1141                        return None;
1142                    } else {
1143                        break;
1144                    }
1145                }
1146            }
1147        }
1148        Some(events)
1149    }
1150
1151    /// Receives the next incoming [`Event`] asynchronously with a timeout.
1152    ///
1153    /// Returns a [`Event::Error`] if no event was received within the given duration.
1154    ///
1155    /// Returns [`None`] once the event stream is closed.
1156    ///
1157    /// ## Event Reordering
1158    ///
1159    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
1160    /// events might be returned in a different order than they occurred. For details, check the
1161    /// documentation of the [`EventScheduler`] struct.
1162    ///
1163    /// If you want to receive the events in their original chronological order, use the
1164    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
1165    /// ([`EventStream`] implements the [`Stream`] trait).
1166    pub async fn recv_async_timeout(&mut self, dur: Duration) -> Option<Event> {
1167        match select(Delay::new(dur), pin!(self.recv_async())).await {
1168            Either::Left((_elapsed, _)) => Some(Self::convert_event_item(EventItem::TimeoutError(
1169                eyre!("Receiver timed out"),
1170            ))),
1171            Either::Right((event, _)) => event,
1172        }
1173    }
1174
1175    /// Waits for a service response carrying `request_id` in its metadata.
1176    ///
1177    /// Drives the event loop internally and returns the matching
1178    /// [`Event::Input`] as soon as it arrives. Non-matching events are
1179    /// buffered and replayed on the next call to `recv()` / `recv_async()`,
1180    /// so your main event loop does not lose intermediate events.
1181    ///
1182    /// Terminal conditions return a [`PatternError`]:
1183    ///
1184    /// - `Timeout` — `timeout` elapsed before any matching response arrived.
1185    /// - `ServerRestarted(expected_server)` — the expected server node
1186    ///   restarted, which means its in-flight `request_id` correlation
1187    ///   was orphaned. The caller should retry against the new instance.
1188    /// - `StreamEnded` — the event stream closed (dataflow stopping)
1189    ///   before a response arrived. The terminal `Stop` event is still
1190    ///   returned to the caller's next `recv()`.
1191    /// - `StreamError` — an upstream error event surfaced during the wait.
1192    ///
1193    /// # Example
1194    ///
1195    /// ```ignore
1196    /// let request_id = node.send_service_request(...)?;
1197    /// match events
1198    ///     .recv_service_response(&request_id, &server_id, Duration::from_secs(5))
1199    ///     .await
1200    /// {
1201    ///     Ok(Event::Input { data, .. }) => handle_response(data),
1202    ///     Err(PatternError::Timeout) => fallback_path(),
1203    ///     Err(PatternError::ServerRestarted(_)) => retry_with_new_instance(),
1204    ///     Err(e) => return Err(e.into()),
1205    ///     _ => unreachable!(),
1206    /// }
1207    /// ```
1208    pub async fn recv_service_response(
1209        &mut self,
1210        request_id: &str,
1211        expected_server: &NodeId,
1212        timeout: Duration,
1213    ) -> Result<Event, PatternError> {
1214        self.wait_for_correlation(
1215            timeout,
1216            expected_server,
1217            |event, request_id| match event {
1218                Event::Input { metadata, .. } => {
1219                    dora_message::metadata::get_string_param(
1220                        &metadata.parameters,
1221                        dora_message::metadata::REQUEST_ID,
1222                    ) == Some(request_id)
1223                }
1224                _ => false,
1225            },
1226            request_id,
1227        )
1228        .await
1229    }
1230
1231    /// Waits for a terminal action result (`goal_status` ∈
1232    /// {`succeeded`, `aborted`, `canceled`}) with a matching `goal_id`.
1233    ///
1234    /// Semantics mirror [`recv_service_response`](Self::recv_service_response)
1235    /// but match on `goal_id` + a terminal `goal_status` instead of
1236    /// `request_id`. Intermediate feedback events (matching `goal_id`
1237    /// without a terminal `goal_status`) are stashed for the caller's
1238    /// main loop — use `recv()` separately if you need to observe them.
1239    pub async fn recv_action_result(
1240        &mut self,
1241        goal_id: &str,
1242        expected_server: &NodeId,
1243        timeout: Duration,
1244    ) -> Result<Event, PatternError> {
1245        self.wait_for_correlation(
1246            timeout,
1247            expected_server,
1248            |event, goal_id| match event {
1249                Event::Input { metadata, .. } => {
1250                    let matches_goal = dora_message::metadata::get_string_param(
1251                        &metadata.parameters,
1252                        dora_message::metadata::GOAL_ID,
1253                    ) == Some(goal_id);
1254                    if !matches_goal {
1255                        return false;
1256                    }
1257                    matches!(
1258                        dora_message::metadata::get_string_param(
1259                            &metadata.parameters,
1260                            dora_message::metadata::GOAL_STATUS,
1261                        ),
1262                        Some(dora_message::metadata::GOAL_STATUS_SUCCEEDED)
1263                            | Some(dora_message::metadata::GOAL_STATUS_ABORTED)
1264                            | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
1265                    )
1266                }
1267                _ => false,
1268            },
1269            goal_id,
1270        )
1271        .await
1272    }
1273
1274    /// Core loop for the pattern-aware helpers. Waits up to `timeout`
1275    /// for an event that satisfies `is_match(event, needle)`. Buffers
1276    /// every non-matching event so the caller's main event loop can
1277    /// still see them via `recv()`.
1278    async fn wait_for_correlation<F>(
1279        &mut self,
1280        timeout: Duration,
1281        expected_server: &NodeId,
1282        is_match: F,
1283        needle: &str,
1284    ) -> Result<Event, PatternError>
1285    where
1286        F: Fn(&Event, &str) -> bool,
1287    {
1288        // A previous pattern-aware wait may already have buffered the event
1289        // we are now looking for. With pipelined requests, the response to
1290        // `req-2` can arrive — and be classified non-matching, so buffered
1291        // into `pending_passthrough` — *during* the wait for `req-1`. The loop
1292        // below pumps `recv_from_stream`, which never reads
1293        // `pending_passthrough`, so without this scan that already-buffered
1294        // response would be invisible and the wait would wrongly time out.
1295        // Extract a buffered match in place (preserving the order of the
1296        // remaining events for the caller's own `recv()`/`recv_async()`).
1297        //
1298        // Only a `Match` is pulled from the buffer: a buffered `Stop` is
1299        // already handled by the `stop_received` short-circuit in
1300        // `recv_from_stream`, and a buffered `NodeRestarted` was reported to
1301        // the caller when it was first seen, so it is left for the caller's
1302        // own event loop rather than re-surfaced here.
1303        if let Some(pos) = self
1304            .pending_passthrough
1305            .iter()
1306            .position(|event| is_match(event, needle))
1307            && let Some(event) = self.pending_passthrough.remove(pos)
1308        {
1309            return Ok(event);
1310        }
1311
1312        let deadline = std::time::Instant::now() + timeout;
1313        loop {
1314            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1315            if remaining.is_zero() {
1316                return Err(PatternError::Timeout);
1317            }
1318            // Pump the stream via `recv_from_stream`, NOT `recv_async`: the
1319            // latter drains `pending_passthrough` first, which would re-hand us
1320            // the very events we buffer below and livelock the loop (see
1321            // `recv_from_stream`'s docs).
1322            let event = match select(Delay::new(remaining), pin!(self.recv_from_stream())).await {
1323                Either::Left((_elapsed, _)) => return Err(PatternError::Timeout),
1324                Either::Right((None, _)) => return Err(PatternError::StreamEnded),
1325                Either::Right((Some(e), _)) => e,
1326            };
1327
1328            match classify_correlation_event(&event, expected_server, |e| is_match(e, needle)) {
1329                CorrelationOutcome::Match => return Ok(event),
1330                CorrelationOutcome::ServerRestarted => {
1331                    self.pending_passthrough.push_back(event);
1332                    return Err(PatternError::ServerRestarted(expected_server.to_string()));
1333                }
1334                CorrelationOutcome::StreamEnded => {
1335                    self.pending_passthrough.push_back(event);
1336                    return Err(PatternError::StreamEnded);
1337                }
1338                CorrelationOutcome::StreamError => {
1339                    if let Event::Error(err) = event {
1340                        return Err(PatternError::StreamError(err));
1341                    }
1342                    unreachable!("StreamError only returned for Event::Error");
1343                }
1344                CorrelationOutcome::Passthrough => {
1345                    self.pending_passthrough.push_back(event);
1346                }
1347            }
1348        }
1349    }
1350}
1351
1352/// Build the JSON for a "control" event that carries only a type tag, an
1353/// optional input/node id, and the elapsed time offset since the node started.
1354///
1355/// Shared by the `Stop` / `InputClosed` / `InputRecovered` / `NodeRestarted` /
1356/// `AllInputsClosed` arms of [`EventStream::record_event`], which differ only in
1357/// the `"type"` string and whether an `"id"` field is present. A free function
1358/// (rather than a `&self` method) so it can take the `clock` and
1359/// `start_timestamp` fields by reference while `record_event` holds a mutable
1360/// borrow of the sibling `write_events_to` field.
1361fn control_event_json(
1362    clock: &uhlc::HLC,
1363    start_timestamp: &uhlc::Timestamp,
1364    ty: &str,
1365    id: Option<String>,
1366) -> serde_json::Value {
1367    let time_offset = clock.new_timestamp().get_diff_duration(start_timestamp);
1368    // Build the map explicitly (rather than via `json!`) so the key order
1369    // matches the previous per-arm literals byte-for-byte under serde_json's
1370    // `preserve_order`: `type`, then the optional `id`, then `time_offset_secs`.
1371    let mut event_json = serde_json::Map::new();
1372    event_json.insert("type".to_owned(), ty.into());
1373    if let Some(id) = id {
1374        event_json.insert("id".to_owned(), serde_json::Value::String(id));
1375    }
1376    event_json.insert(
1377        "time_offset_secs".to_owned(),
1378        time_offset.as_secs_f64().into(),
1379    );
1380    serde_json::Value::Object(event_json)
1381}
1382
1383/// Outcome of classifying a single event during a pattern-aware wait.
1384/// Separated from `wait_for_correlation` so the decision logic can be
1385/// unit-tested without a live `EventStream`.
1386#[derive(Debug, PartialEq, Eq)]
1387enum CorrelationOutcome {
1388    /// The event satisfies the caller's predicate — return it.
1389    Match,
1390    /// `Event::NodeRestarted { id }` where `id == expected_server`.
1391    ServerRestarted,
1392    /// `Event::Stop(_)` — the dataflow is shutting down.
1393    StreamEnded,
1394    /// `Event::Error(_)` — the stream surfaced an error.
1395    StreamError,
1396    /// Unrelated event — buffer it and keep waiting.
1397    Passthrough,
1398}
1399
1400fn classify_correlation_event<F>(
1401    event: &Event,
1402    expected_server: &NodeId,
1403    is_match: F,
1404) -> CorrelationOutcome
1405where
1406    F: Fn(&Event) -> bool,
1407{
1408    if is_match(event) {
1409        return CorrelationOutcome::Match;
1410    }
1411    match event {
1412        Event::NodeRestarted { id } if id == expected_server => CorrelationOutcome::ServerRestarted,
1413        Event::Stop(_) => CorrelationOutcome::StreamEnded,
1414        Event::Error(_) => CorrelationOutcome::StreamError,
1415        _ => CorrelationOutcome::Passthrough,
1416    }
1417}
1418
1419impl EventStream {
1420    fn convert_event_item(item: EventItem) -> Event {
1421        match item {
1422            EventItem::NodeEvent { event } => match event {
1423                NodeEvent::Stop => Event::Stop(event::StopCause::Manual),
1424                NodeEvent::Reload { operator_id } => Event::Reload { operator_id },
1425                NodeEvent::InputClosed { id } => Event::InputClosed { id },
1426                NodeEvent::InputRecovered { id } => Event::InputRecovered { id },
1427                NodeEvent::NodeRestarted { id } => Event::NodeRestarted { id },
1428                NodeEvent::Input { id, metadata, data } => {
1429                    let data_inner = data.map(Arc::unwrap_or_clone);
1430                    let result = data_to_arrow_array(data_inner);
1431                    match result {
1432                        Ok(data) => {
1433                            let mut metadata = Arc::unwrap_or_clone(metadata);
1434                            dora_message::metadata::strip_internal_parameters(
1435                                &mut metadata.parameters,
1436                            );
1437                            Event::Input {
1438                                id,
1439                                metadata,
1440                                data: dora_arrow_convert::internal::from_array_ref(data),
1441                            }
1442                        }
1443                        Err(err) => Event::Error(format!("{err:?}")),
1444                    }
1445                }
1446                NodeEvent::AllInputsClosed => Event::Stop(event::StopCause::AllInputsClosed),
1447                NodeEvent::ParamUpdate { key, value_json } => {
1448                    match serde_json::from_slice(&value_json) {
1449                        Ok(value) => Event::ParamUpdate { key, value },
1450                        Err(err) => Event::Error(format!(
1451                            "failed to deserialize ParamUpdate value for `{key}`: {err}"
1452                        )),
1453                    }
1454                }
1455                NodeEvent::ParamDeleted { key } => Event::ParamDeleted { key },
1456                NodeEvent::NodeFailed {
1457                    affected_input_ids,
1458                    error,
1459                    source_node_id,
1460                } => Event::NodeFailed {
1461                    affected_input_ids,
1462                    error,
1463                    source_node_id,
1464                },
1465                other => {
1466                    tracing::warn!("ignoring unrecognized NodeEvent variant: {other:?}");
1467                    Event::Error(format!("unrecognized node event: {other:?}"))
1468                }
1469            },
1470
1471            EventItem::ZenohInput { id, metadata, data } => {
1472                let mut metadata = Arc::unwrap_or_clone(metadata);
1473                dora_message::metadata::strip_internal_parameters(&mut metadata.parameters);
1474                Event::Input {
1475                    id,
1476                    metadata,
1477                    // Already decoded in the subscriber callback (receipt order).
1478                    data: dora_arrow_convert::internal::from_array_data(data),
1479                }
1480            }
1481
1482            EventItem::FatalError(err) => {
1483                Event::Error(format!("fatal event stream error: {err:?}"))
1484            }
1485            EventItem::TimeoutError(err) => {
1486                Event::Error(format!("Timeout event stream error: {err:?}"))
1487            }
1488        }
1489    }
1490}
1491
1492/// No event is available right now or the event stream has been closed.
1493#[derive(Debug)]
1494pub enum TryRecvError {
1495    /// No new event is available right now.
1496    Empty,
1497    /// The event stream has been closed.
1498    Closed,
1499}
1500
1501/// Convert a zenoh `ZBytes` payload into an Arrow array without copying
1502/// for contiguous buffers (e.g. Zenoh SHM).
1503///
1504/// For `Cow::Borrowed` payloads (SHM), the Arrow `Buffer` is backed by
1505/// the original `ZBytes` allocation via `Buffer::from_custom_allocation`,
1506/// achieving true zero-copy. For `Cow::Owned` (normal network path),
1507/// copy into Dora's aligned buffer type before reconstructing Arrow arrays.
1508/// Newtype that owns a Zenoh [`ZBytes`](zenoh::bytes::ZBytes) payload so it can
1509/// back an Arrow `Buffer` via `Buffer::from_custom_allocation`. Keeping the
1510/// `ZBytes` alive keeps the underlying SHM mapping (or heap buffer) valid for
1511/// the lifetime of the zero-copy Arrow buffer.
1512#[allow(dead_code)] // field kept alive to own the zenoh buffer
1513struct ZBytesAllocation(zenoh::bytes::ZBytes);
1514// SAFETY: the wrapped `ZBytes` is only used to keep the backing allocation
1515// alive; the bytes are treated as immutable for the Buffer's lifetime.
1516unsafe impl Sync for ZBytesAllocation {}
1517unsafe impl Send for ZBytesAllocation {}
1518impl std::panic::RefUnwindSafe for ZBytesAllocation {}
1519
1520/// Convert a zenoh payload to an Arrow array (dora-rs/adora#132).
1521///
1522/// Every data-plane payload is a self-describing Arrow IPC stream, so the
1523/// decode needs no type sidecar. An empty payload is a metadata-only message
1524/// and maps to the unit array.
1525/// Wrap a zenoh payload as an Arrow `Buffer` — aliasing the zenoh SHM mapping
1526/// for borrowed payloads (zero-copy), owning the materialized `Vec` otherwise.
1527fn zenoh_payload_to_buffer(payload: zenoh::bytes::ZBytes) -> arrow::buffer::Buffer {
1528    use std::ptr::NonNull;
1529    match payload.to_bytes() {
1530        std::borrow::Cow::Borrowed(slice) => {
1531            let ptr =
1532                NonNull::new(slice.as_ptr() as *mut u8).expect("zenoh SHM payload ptr is null");
1533            let len = slice.len();
1534            // SAFETY: `ptr` points into the SHM region owned by `payload`;
1535            // moving `payload` into the Arc keeps the region mapped for the
1536            // lifetime of the Buffer.
1537            unsafe {
1538                arrow::buffer::Buffer::from_custom_allocation(
1539                    ptr,
1540                    len,
1541                    Arc::new(ZBytesAllocation(payload)),
1542                )
1543            }
1544        }
1545        std::borrow::Cow::Owned(vec) => arrow::buffer::Buffer::from_vec(vec),
1546    }
1547}
1548
1549/// Declare the `@schema` AdvancedSubscriber for an input: its callback primes
1550/// `decoder` from the schema published on the output's schema subtopic. The
1551/// history query fetches the cached schema on join; `detect_late_publishers`
1552/// re-queries a producer that appears after this subscriber.
1553///
1554/// On failure, `schema_plane_failed` is set so the data subscriber surfaces a
1555/// `FatalError` if the input stays undecodable past
1556/// [`SCHEMA_PLANE_FATAL_GRACE`]: a failed declare means a degraded zenoh
1557/// session, so exit loudly rather than limp along on the in-band full-stream
1558/// refresh alone — but give that refresh (which fully heals the input) its
1559/// chance first instead of killing a node that would recover within seconds.
1560/// It never blocks the data subscriber.
1561#[allow(clippy::too_many_arguments)]
1562fn declare_schema_subscriber(
1563    session: &zenoh::Session,
1564    dataflow_id: DataflowId,
1565    source_node: &NodeId,
1566    source_output: &DataId,
1567    input_id: &DataId,
1568    decoder: Arc<std::sync::Mutex<crate::arrow_utils::ipc_encode::InputDecoder>>,
1569    tx: tokio::sync::mpsc::Sender<EventItem>,
1570    schema_plane_failed: Arc<std::sync::atomic::AtomicBool>,
1571    out: &mut Vec<zenoh_ext::AdvancedSubscriber<()>>,
1572) {
1573    use zenoh::Wait;
1574    use zenoh_ext::{AdvancedSubscriberBuilderExt, HistoryConfig};
1575
1576    let topic =
1577        dora_core::topics::zenoh_output_schema_topic(dataflow_id, source_node, source_output);
1578    let key = match zenoh::key_expr::KeyExpr::new(topic) {
1579        Ok(k) => k.into_owned(),
1580        Err(e) => {
1581            tracing::warn!(input = %input_id, "invalid @schema zenoh key ({e}); schema-once disabled for this input");
1582            schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1583            return;
1584        }
1585    };
1586    let input_id_cb = input_id.clone();
1587    let sub = session
1588        .declare_subscriber(key)
1589        .history(HistoryConfig::default().detect_late_publishers())
1590        .callback(move |sample| {
1591            // catch_unwind: a panic must not unwind through zenoh's IO worker.
1592            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1593                let buffer = zenoh_payload_to_buffer(sample.payload().clone());
1594                let hash = crate::node::fnv1a(buffer.as_slice());
1595                let mut decoder = decoder.lock().unwrap_or_else(|poison| {
1596                    let mut guard = poison.into_inner();
1597                    guard.reset();
1598                    guard
1599                });
1600                if let Err(e) = decoder.set_schema_raw(hash, buffer) {
1601                    tracing::warn!(input = %input_id_cb, "failed to prime decoder from @schema sample: {e}");
1602                }
1603            }));
1604            if result.is_err() {
1605                tracing::error!(input = %input_id_cb, "zenoh @schema subscriber callback panicked");
1606                let _ = tx.try_send(EventItem::FatalError(eyre!(
1607                    "zenoh @schema subscriber for input `{input_id_cb}` panicked"
1608                )));
1609            }
1610        })
1611        .wait();
1612    match sub {
1613        Ok(s) => out.push(s),
1614        Err(e) => {
1615            tracing::warn!(input = %input_id, "failed to declare @schema subscriber ({e}); schema-once disabled for this input");
1616            schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1617        }
1618    }
1619}
1620
1621/// How long a schema-once input may stay continuously undecodable — with the
1622/// `@schema` plane dead — before the node exits with a `FatalError`. Three
1623/// producer full-stream refresh intervals: the refresh re-primes the input
1624/// in-band, so if it hasn't healed after three periods the input is genuinely
1625/// dead (producer gone or data plane dropping every refresh), not just waiting
1626/// out the documented recovery window.
1627const SCHEMA_PLANE_FATAL_GRACE: Duration =
1628    crate::node::SCHEMA_ONCE_REFRESH_INTERVAL.saturating_mul(3);
1629
1630/// Whether the undecodable-input condition has persisted past
1631/// [`SCHEMA_PLANE_FATAL_GRACE`]. Records the start of the window on first call;
1632/// the caller clears `first_undecodable` on any successful decode.
1633fn schema_plane_fatal_due(first_undecodable: &mut Option<Instant>, now: Instant) -> bool {
1634    let start = *first_undecodable.get_or_insert(now);
1635    now.duration_since(start) >= SCHEMA_PLANE_FATAL_GRACE
1636}
1637
1638/// Decode a zenoh data sample in receipt order. A schema-less batch (the
1639/// `SCHEMA_HASH` parameter is present) decodes against the per-input decoder
1640/// primed from the output's `@schema` subtopic or in-band from an earlier full
1641/// stream — returning `Ok(None)` if that schema hasn't been received yet, in
1642/// which case the caller drops it. Other messages (large/SHM full streams,
1643/// daemon-path payloads) decode standalone — and additionally prime the decoder
1644/// in-band (see [`prime_in_band`]).
1645fn decode_zenoh_sample(
1646    decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1647    metadata: &dora_message::metadata::Metadata,
1648    payload: zenoh::bytes::ZBytes,
1649) -> eyre::Result<Option<arrow::array::ArrayData>> {
1650    use crate::arrow_utils::decode_arrow_ipc_zero_copy_raw;
1651    use dora_message::metadata::{SCHEMA_HASH, get_integer_param};
1652
1653    if payload.is_empty() {
1654        return Ok(Some(
1655            dora_arrow_convert::internal::into_array_ref(().into_arrow()).to_data(),
1656        ));
1657    }
1658    let buffer = zenoh_payload_to_buffer(payload);
1659    match get_integer_param(&metadata.parameters, SCHEMA_HASH) {
1660        Some(hash) => {
1661            tracing::debug!("received schema-less batch with SCHEMA_HASH={}", hash);
1662            decoder.decode_batch_raw(buffer, hash as u64)
1663        }
1664        None => {
1665            tracing::debug!("received full IPC stream (no SCHEMA_HASH)");
1666            // Service/action messages are excluded from schema-once (a server
1667            // multiplexes per-request schemas through one output); don't let
1668            // them churn the retained schema set.
1669            if !crate::node::carries_pattern_correlation(&metadata.parameters) {
1670                prime_in_band(decoder, &buffer);
1671            }
1672            decode_arrow_ipc_zero_copy_raw(buffer).map(Some)
1673        }
1674    }
1675}
1676
1677/// Prime the per-input decoder from the schema block of a full self-describing
1678/// stream received on the data topic.
1679///
1680/// The producer sends a full stream for the first message of an output (and
1681/// after every schema change, failed `@schema` publish, or periodic refresh —
1682/// see `publish_schema_once`). Since all data-plane puts share one publisher,
1683/// zenoh delivers them in order, so this priming happens strictly before the
1684/// schema-less batches that reference the schema — closing the QoS race where
1685/// an express batch overtakes the `@schema` plane's non-express schema, and
1686/// re-priming (within the refresh interval) any consumer whose `@schema`
1687/// history query missed the single schema emission.
1688fn prime_in_band(
1689    decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1690    buffer: &arrow::buffer::Buffer,
1691) {
1692    let Some((hash, schema)) =
1693        crate::arrow_utils::ipc_encode::schema_block_and_hash(buffer.as_slice())
1694    else {
1695        return;
1696    };
1697    // A known schema (live or retained) needs no eager re-prime: the full
1698    // stream decodes standalone, and `decode_batch` re-primes lazily from the
1699    // retained set when a schema-less batch actually references it.
1700    if decoder.knows_schema(hash) {
1701        return;
1702    }
1703    // Copy the schema block out of the payload: retaining a slice of an
1704    // SHM-backed buffer would pin the whole segment for the decoder's lifetime.
1705    let schema = arrow::buffer::Buffer::from(schema);
1706    if let Err(e) = decoder.set_schema_raw(hash, schema) {
1707        tracing::debug!("in-band schema priming failed: {e}");
1708    }
1709}
1710
1711/// Convert an optional [`DataMessage`] into an Arrow array, or return an
1712/// empty null array when no data is present.
1713pub fn data_to_arrow_array(
1714    data: Option<DataMessage>,
1715) -> eyre::Result<Arc<dyn arrow::array::Array>> {
1716    let data: eyre::Result<Option<RawData>> = match data {
1717        None => Ok(None),
1718        Some(DataMessage::Vec(v)) => Ok(Some(RawData::Vec(v))),
1719    };
1720
1721    data.and_then(|data| {
1722        let raw_data = data.unwrap_or(RawData::Empty);
1723        raw_data.into_arrow_array().map(arrow::array::make_array)
1724    })
1725}
1726
1727impl Stream for EventStream {
1728    type Item = Event;
1729
1730    fn poll_next(
1731        mut self: std::pin::Pin<&mut Self>,
1732        cx: &mut std::task::Context<'_>,
1733    ) -> std::task::Poll<Option<Self::Item>> {
1734        // Drain events that were buffered by pattern-aware helpers
1735        // (`recv_service_response`, `recv_action_result`) before
1736        // polling the underlying receiver. Mirrors the drain at the
1737        // top of `recv_async` so `StreamExt::next()` and `recv()`
1738        // return the same events in the same order
1739        // (dora-rs/adora#172).
1740        if let Some(event) = self.pending_passthrough.pop_front() {
1741            return std::task::Poll::Ready(Some(event));
1742        }
1743
1744        // Close the stream after a Stop event: zenoh subscriber threads
1745        // hold sender clones that would otherwise keep `receiver` open.
1746        if self.stop_received {
1747            return std::task::Poll::Ready(None);
1748        }
1749
1750        let poll = self
1751            .receiver
1752            .poll_recv(cx)
1753            .map(|item| item.map(Self::convert_event_item));
1754
1755        // Mirror recv_async(): run the first-message type check and stop
1756        // tracking on the Stream path too, via the shared helper so the two
1757        // paths stay in lockstep (dora-rs/adora#172, #174).
1758        if let std::task::Poll::Ready(Some(ref event)) = poll {
1759            self.note_produced_event(event);
1760        }
1761        poll
1762    }
1763}
1764
1765impl Drop for EventStream {
1766    fn drop(&mut self) {
1767        // Tear down the per-input zenoh callback subscribers under a deadline.
1768        // `Subscriber::drop` undeclares the subscription on the shared zenoh
1769        // session, which blocks indefinitely when zenoh's net runtime is
1770        // wedged (e.g. stuck retrying an unreachable scouted peer). Left
1771        // unbounded, that hangs the whole node in `EventStream`'s field-drop
1772        // sequence — before `DoraNode::Drop` (which already bounds its own
1773        // session teardown) even runs — so the daemon never sees the node
1774        // finish and the dataflow stalls until an outer timeout (dora-rs/dora#2425).
1775        // The callbacks use `try_send`, so undeclaring before `receiver` drops
1776        // cannot deadlock on a blocked callback.
1777        //
1778        // The `@schema` `AdvancedSubscriber`s (added with the Arrow IPC data
1779        // plane in #2366) undeclare on the same shared session and can wedge
1780        // the same way, so they must be torn down under the same deadline —
1781        // mirroring `DoraNode::Drop`, which drops both publisher maps inside
1782        // one guard. Left out, they would otherwise drop unbounded in the
1783        // implicit field-drop phase after this `Drop` body returns (#2583).
1784        let subscribers = std::mem::take(&mut self._zenoh_subscribers);
1785        let schema_subscribers = std::mem::take(&mut self._zenoh_schema_subscribers);
1786        let startup_acker = self.startup_acker.take();
1787        if !subscribers.is_empty() || !schema_subscribers.is_empty() || startup_acker.is_some() {
1788            let completed =
1789                teardown_with_timeout("zenoh-subscribers", ZENOH_TEARDOWN_TIMEOUT, move || {
1790                    drop(subscribers);
1791                    drop(schema_subscribers);
1792                    // Dropping the subscribers dropped their callbacks — the
1793                    // only senders into the acker's queue — so the acker
1794                    // thread exits (undeclaring its ack publishers, also
1795                    // bounded by this deadline) and can be joined.
1796                    if let Some(handle) = startup_acker {
1797                        let _ = handle.join();
1798                    }
1799                });
1800            if !completed {
1801                tracing::warn!(
1802                    "zenoh subscriber teardown timed out after {}s; continuing node shutdown",
1803                    ZENOH_TEARDOWN_TIMEOUT.as_secs()
1804                );
1805            }
1806        }
1807
1808        let request = Timestamped {
1809            inner: DaemonRequest::EventStreamDropped,
1810            timestamp: self.clock.new_timestamp(),
1811        };
1812        // Interrupt a testing-daemon `next_event` sleep before the blocking
1813        // close handshake so Drop cannot deadlock (dora-rs/dora#2855).
1814        if let Some(shutdown) = &self.testing_shutdown {
1815            shutdown.store(true, Ordering::Relaxed);
1816        }
1817        let result = self
1818            .close_channel
1819            .request(&request)
1820            .map_err(|e| eyre!(e))
1821            .wrap_err("failed to signal event stream closure to dora-daemon")
1822            .and_then(|r| match r {
1823                DaemonReply::Result(Ok(())) => Ok(()),
1824                DaemonReply::Result(Err(err)) => Err(eyre!("EventStreamClosed failed: {err}")),
1825                other => Err(eyre!("unexpected EventStreamClosed reply: {other:?}")),
1826            });
1827        if let Err(err) = result {
1828            tracing::warn!("{err:?}")
1829        }
1830
1831        if let Some(write_events_to) = self.write_events_to.take()
1832            && let Err(err) = write_events_to.write_out()
1833        {
1834            tracing::warn!(
1835                "failed to write out events for node {}: {err:?}",
1836                self.node_id
1837            );
1838        }
1839    }
1840}
1841
1842pub(crate) struct WriteEventsTo {
1843    node_id: NodeId,
1844    file: std::fs::File,
1845    events_buffer: Vec<serde_json::Value>,
1846    /// `None` while the recording is complete. Becomes `Some(...)` on
1847    /// the first `record_event` failure; subsequent failures bump the
1848    /// counter inside. Surfaced in `write_out()` as a top-level
1849    /// `recording_status` field so consumers (replay tools, audit
1850    /// pipelines) can detect partial recordings instead of silently
1851    /// treating a syntactically-valid file as complete (#1857).
1852    poisoned: Option<PoisonInfo>,
1853}
1854
1855#[derive(Debug)]
1856pub(crate) struct PoisonInfo {
1857    /// `events_buffer.len()` at the moment of the first failure — i.e.
1858    /// the number of events successfully recorded before the gap.
1859    first_failure_event_index: usize,
1860    /// Seconds since `EventStream::start_timestamp` at the first failure.
1861    first_failure_time_offset_secs: f64,
1862    /// `format!("{err:?}")` of the first `record_event()` error.
1863    first_failure_error: String,
1864    /// Count of subsequent failures after the first one.
1865    additional_failures: u64,
1866}
1867
1868impl WriteEventsTo {
1869    /// Mark the recording poisoned. First call captures the failure
1870    /// detail; later calls just bump `additional_failures`.
1871    fn mark_poisoned(&mut self, err: &eyre::Report, time_offset_secs: f64) {
1872        match &mut self.poisoned {
1873            None => {
1874                self.poisoned = Some(PoisonInfo {
1875                    first_failure_event_index: self.events_buffer.len(),
1876                    first_failure_time_offset_secs: time_offset_secs,
1877                    first_failure_error: format!("{err:?}"),
1878                    additional_failures: 0,
1879                });
1880            }
1881            Some(info) => {
1882                info.additional_failures += 1;
1883            }
1884        }
1885    }
1886
1887    fn write_out(self) -> eyre::Result<()> {
1888        use dora_message::integration_testing_format::RecordingStatus;
1889
1890        let Self {
1891            node_id,
1892            file,
1893            events_buffer,
1894            poisoned,
1895        } = self;
1896        let mut inputs_file = serde_json::Map::new();
1897        inputs_file.insert("id".into(), node_id.to_string().into());
1898        // Emit `recording_status` for clean recordings too, so consumers
1899        // can rely on its presence as a definitive signal rather than
1900        // having to treat "field absent" as ambiguous between "clean"
1901        // and "older format" (#1857). Serialized via the canonical
1902        // `RecordingStatus` enum in `dora-message` so the wire format
1903        // stays in lockstep with the consumer-side type. The wire
1904        // shape is unaffected by `IntegrationTestInput`'s
1905        // `Option<Box<RecordingStatus>>` storage choice — serde
1906        // transparently serializes through the `Box`.
1907        let recording_status = match poisoned {
1908            None => RecordingStatus::Clean,
1909            Some(info) => RecordingStatus::Poisoned {
1910                first_failure_event_index: info.first_failure_event_index,
1911                first_failure_time_offset_secs: info.first_failure_time_offset_secs,
1912                first_failure_error: info.first_failure_error,
1913                additional_failures: info.additional_failures,
1914            },
1915        };
1916        inputs_file.insert(
1917            "recording_status".into(),
1918            serde_json::to_value(&recording_status)
1919                .context("failed to serialize recording_status")?,
1920        );
1921        inputs_file.insert("events".into(), events_buffer.into());
1922
1923        serde_json::to_writer_pretty(file, &inputs_file)
1924            .context("failed to write events to file")?;
1925        Ok(())
1926    }
1927}
1928
1929#[cfg(test)]
1930impl EventStream {
1931    /// Test-only: inject an event into the passthrough buffer so we can
1932    /// verify that `is_empty`, `recv_async`, and `Stream::poll_next` all
1933    /// drain it correctly (dora-rs/adora#172).
1934    fn push_passthrough_for_testing(&mut self, event: Event) {
1935        self.pending_passthrough.push_back(event);
1936    }
1937
1938    /// Test-only: buffer an empty input directly in the scheduler and force
1939    /// scheduler mode, simulating an input the scheduler held back while
1940    /// prioritizing `Stop`. Used to verify `recv_async` drains buffered inputs
1941    /// after `Stop` instead of dropping them (dora-rs/dora#2027).
1942    fn push_scheduler_input_for_testing(&mut self, id: &str) {
1943        use crate::event_stream::thread::EventItem;
1944        use dora_message::{daemon_to_node::NodeEvent, metadata::Metadata};
1945        self.use_scheduler = true;
1946        let meta = Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
1947        self.scheduler.add_event(EventItem::NodeEvent {
1948            event: NodeEvent::Input {
1949                id: id.into(),
1950                metadata: std::sync::Arc::new(meta),
1951                data: None,
1952            },
1953        });
1954    }
1955
1956    /// Test-only: buffer a `Stop` directly in the scheduler (a NON_INPUT_EVENT)
1957    /// and force scheduler mode, to verify the post-Stop drain discards trailing
1958    /// control events instead of re-delivering a second `Stop` (dora-rs/dora#2027).
1959    fn push_scheduler_stop_for_testing(&mut self) {
1960        use crate::event_stream::thread::EventItem;
1961        use dora_message::daemon_to_node::NodeEvent;
1962        self.use_scheduler = true;
1963        self.scheduler.add_event(EventItem::NodeEvent {
1964            event: NodeEvent::Stop,
1965        });
1966    }
1967}
1968
1969#[cfg(test)]
1970mod tests {
1971    use super::*;
1972
1973    #[test]
1974    fn control_event_json_shape_and_key_order() {
1975        let clock = uhlc::HLC::default();
1976        let start = clock.new_timestamp();
1977
1978        // An id-bearing control event: keys in `type`, `id`, `time_offset_secs`
1979        // order (serde_json's `preserve_order` makes the order observable).
1980        let with_id = control_event_json(&clock, &start, "InputClosed", Some("cam".to_owned()));
1981        let obj = with_id.as_object().expect("object");
1982        assert_eq!(
1983            obj.keys().collect::<Vec<_>>(),
1984            vec!["type", "id", "time_offset_secs"]
1985        );
1986        assert_eq!(obj["type"], serde_json::json!("InputClosed"));
1987        assert_eq!(obj["id"], serde_json::json!("cam"));
1988        assert!(obj["time_offset_secs"].is_f64());
1989
1990        // A control event without an id omits the `id` field entirely.
1991        let without_id = control_event_json(&clock, &start, "AllInputsClosed", None);
1992        let obj = without_id.as_object().expect("object");
1993        assert_eq!(
1994            obj.keys().collect::<Vec<_>>(),
1995            vec!["type", "time_offset_secs"]
1996        );
1997        assert_eq!(obj["type"], serde_json::json!("AllInputsClosed"));
1998    }
1999
2000    #[test]
2001    fn convert_param_update() {
2002        let item = EventItem::NodeEvent {
2003            event: NodeEvent::ParamUpdate {
2004                key: "fps".into(),
2005                value_json: serde_json::to_vec(&serde_json::json!(60)).unwrap(),
2006            },
2007        };
2008        let event = EventStream::convert_event_item(item);
2009        match event {
2010            Event::ParamUpdate { key, value } => {
2011                assert_eq!(key, "fps");
2012                assert_eq!(value, serde_json::json!(60));
2013            }
2014            other => panic!("expected ParamUpdate, got {other:?}"),
2015        }
2016    }
2017
2018    /// Regression test for the daemon↔node wire protocol: `NodeEvent`
2019    /// is sent over TCP with postcard, so any field type that uses
2020    /// `Deserializer::deserialize_any` (like `serde_json::Value`)
2021    /// breaks the channel and kills the node at the next receive.
2022    /// `NodeEvent::ParamUpdate` carries its value as JSON-encoded
2023    /// bytes for that reason. This test pins the invariant so we
2024    /// don't regress back to a `deserialize_any` field.
2025    #[test]
2026    fn node_event_param_update_round_trips_through_postcard() {
2027        let cases = [
2028            serde_json::json!(42),
2029            serde_json::json!(1.5),
2030            serde_json::json!("hello"),
2031            serde_json::json!(null),
2032            serde_json::json!([1, 2, 3]),
2033            serde_json::json!({"nested": {"array": [true, false]}}),
2034        ];
2035        for value in cases {
2036            let event = NodeEvent::ParamUpdate {
2037                key: "rate".into(),
2038                value_json: serde_json::to_vec(&value).unwrap(),
2039            };
2040            let bytes = dora_message::encode(&event).expect("serialize");
2041            let back: NodeEvent = dora_message::decode(&bytes).expect("deserialize");
2042            match back {
2043                NodeEvent::ParamUpdate { key, value_json } => {
2044                    assert_eq!(key, "rate");
2045                    let decoded: serde_json::Value =
2046                        serde_json::from_slice(&value_json).expect("value_json is JSON");
2047                    assert_eq!(decoded, value);
2048                }
2049                other => panic!("expected ParamUpdate, got {other:?}"),
2050            }
2051        }
2052    }
2053
2054    // -- WriteEventsTo poisoned-state tests (#1857) ------------------------
2055    //
2056    // Build a `WriteEventsTo` against a tempfile, exercise the public
2057    // surface (push events / mark_poisoned / write_out), then parse the
2058    // resulting JSON and assert on the `recording_status` field shape.
2059    // No new dev-deps — uses std::env::temp_dir() + uuid (already a dep).
2060
2061    fn write_events_to_with_tempfile() -> (WriteEventsTo, std::path::PathBuf) {
2062        let path = std::env::temp_dir().join(format!(
2063            "dora-write-events-test-{}.json",
2064            uuid::Uuid::new_v4()
2065        ));
2066        let file = std::fs::File::create(&path).expect("create tempfile");
2067        let w = WriteEventsTo {
2068            node_id: "test-node".parse().unwrap(),
2069            file,
2070            events_buffer: Vec::new(),
2071            poisoned: None,
2072        };
2073        (w, path)
2074    }
2075
2076    fn read_back(path: &std::path::Path) -> serde_json::Value {
2077        let s = std::fs::read_to_string(path).expect("read back tempfile");
2078        std::fs::remove_file(path).ok();
2079        serde_json::from_str(&s).expect("output is valid JSON")
2080    }
2081
2082    #[test]
2083    fn write_events_clean_recording_emits_state_clean() {
2084        let (mut w, path) = write_events_to_with_tempfile();
2085        w.events_buffer.push(serde_json::json!({"type": "Stop"}));
2086        w.write_out().expect("write_out clean recording");
2087
2088        let v = read_back(&path);
2089        assert_eq!(v["recording_status"]["state"], "clean");
2090        assert_eq!(v["events"].as_array().unwrap().len(), 1);
2091        assert_eq!(v["id"], "test-node");
2092    }
2093
2094    #[test]
2095    fn write_events_poisoned_recording_emits_state_poisoned_with_first_failure() {
2096        let (mut w, path) = write_events_to_with_tempfile();
2097        // 2 events recorded cleanly, then a failure, then 1 more event
2098        w.events_buffer.push(serde_json::json!({"type": "Input"}));
2099        w.events_buffer.push(serde_json::json!({"type": "Input"}));
2100        w.mark_poisoned(&eyre!("arrow conversion failed: bad type"), 1.5);
2101        w.events_buffer.push(serde_json::json!({"type": "Stop"}));
2102        w.write_out().expect("write_out poisoned recording");
2103
2104        let v = read_back(&path);
2105        let status = &v["recording_status"];
2106        assert_eq!(status["state"], "poisoned");
2107        assert_eq!(status["first_failure_event_index"], 2);
2108        assert_eq!(status["first_failure_time_offset_secs"], 1.5);
2109        assert!(
2110            status["first_failure_error"]
2111                .as_str()
2112                .unwrap()
2113                .contains("arrow conversion failed: bad type")
2114        );
2115        assert_eq!(status["additional_failures"], 0);
2116        // `events` keeps the 2 clean + 1 post-failure-but-successful events.
2117        assert_eq!(v["events"].as_array().unwrap().len(), 3);
2118    }
2119
2120    #[test]
2121    fn write_events_multiple_failures_keep_first_and_count_rest() {
2122        let (mut w, path) = write_events_to_with_tempfile();
2123        w.mark_poisoned(&eyre!("first error"), 0.5);
2124        w.mark_poisoned(&eyre!("second error"), 1.0);
2125        w.mark_poisoned(&eyre!("third error"), 1.5);
2126        w.write_out().expect("write_out with multiple failures");
2127
2128        let v = read_back(&path);
2129        let status = &v["recording_status"];
2130        assert_eq!(status["state"], "poisoned");
2131        // First failure detail is preserved, NOT overwritten by later ones.
2132        assert_eq!(status["first_failure_event_index"], 0);
2133        assert_eq!(status["first_failure_time_offset_secs"], 0.5);
2134        assert!(
2135            status["first_failure_error"]
2136                .as_str()
2137                .unwrap()
2138                .contains("first error")
2139        );
2140        // Two additional failures after the first.
2141        assert_eq!(status["additional_failures"], 2);
2142    }
2143
2144    #[test]
2145    fn convert_param_deleted() {
2146        let item = EventItem::NodeEvent {
2147            event: NodeEvent::ParamDeleted { key: "fps".into() },
2148        };
2149        let event = EventStream::convert_event_item(item);
2150        match event {
2151            Event::ParamDeleted { key } => {
2152                assert_eq!(key, "fps");
2153            }
2154            other => panic!("expected ParamDeleted, got {other:?}"),
2155        }
2156    }
2157
2158    #[test]
2159    fn convert_stop_event() {
2160        let item = EventItem::NodeEvent {
2161            event: NodeEvent::Stop,
2162        };
2163        let event = EventStream::convert_event_item(item);
2164        assert!(matches!(event, Event::Stop(StopCause::Manual)));
2165    }
2166
2167    #[test]
2168    fn convert_all_inputs_closed() {
2169        let item = EventItem::NodeEvent {
2170            event: NodeEvent::AllInputsClosed,
2171        };
2172        let event = EventStream::convert_event_item(item);
2173        assert!(matches!(event, Event::Stop(StopCause::AllInputsClosed)));
2174    }
2175
2176    #[test]
2177    fn convert_input_closed() {
2178        let item = EventItem::NodeEvent {
2179            event: NodeEvent::InputClosed {
2180                id: "input_1".to_string().into(),
2181            },
2182        };
2183        let event = EventStream::convert_event_item(item);
2184        match event {
2185            Event::InputClosed { id } => assert_eq!(AsRef::<str>::as_ref(&id), "input_1"),
2186            other => panic!("expected InputClosed, got {other:?}"),
2187        }
2188    }
2189
2190    #[test]
2191    fn convert_node_restarted() {
2192        let item = EventItem::NodeEvent {
2193            event: NodeEvent::NodeRestarted {
2194                id: "upstream".to_string().into(),
2195            },
2196        };
2197        let event = EventStream::convert_event_item(item);
2198        match event {
2199            Event::NodeRestarted { id } => assert_eq!(AsRef::<str>::as_ref(&id), "upstream"),
2200            other => panic!("expected NodeRestarted, got {other:?}"),
2201        }
2202    }
2203
2204    // ---- dora-rs/adora#148: pattern-aware correlation classification ----
2205
2206    use arrow::array::new_empty_array;
2207    use arrow::datatypes::DataType as ArrowDataType;
2208    use dora_arrow_convert::internal::from_array_ref;
2209    use dora_message::metadata::{
2210        GOAL_ID, GOAL_STATUS, GOAL_STATUS_ABORTED, GOAL_STATUS_SUCCEEDED, Metadata,
2211        MetadataParameters, Parameter, REQUEST_ID,
2212    };
2213
2214    fn make_metadata(params: MetadataParameters) -> Metadata {
2215        Metadata::from_parameters(dora_core::uhlc::HLC::default().new_timestamp(), params)
2216    }
2217
2218    fn make_input_event(id: &str, params: MetadataParameters) -> Event {
2219        Event::Input {
2220            id: id.into(),
2221            metadata: make_metadata(params),
2222            data: from_array_ref(new_empty_array(&ArrowDataType::Null)),
2223        }
2224    }
2225
2226    fn request_id_params(id: &str) -> MetadataParameters {
2227        let mut p = MetadataParameters::new();
2228        p.insert(REQUEST_ID.into(), Parameter::String(id.to_string()));
2229        p
2230    }
2231
2232    fn goal_params(goal_id: &str, status: Option<&str>) -> MetadataParameters {
2233        let mut p = MetadataParameters::new();
2234        p.insert(GOAL_ID.into(), Parameter::String(goal_id.to_string()));
2235        if let Some(s) = status {
2236            p.insert(GOAL_STATUS.into(), Parameter::String(s.to_string()));
2237        }
2238        p
2239    }
2240
2241    fn is_request_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2242        move |event: &Event| match event {
2243            Event::Input { metadata, .. } => {
2244                dora_message::metadata::get_string_param(&metadata.parameters, REQUEST_ID)
2245                    == Some(needle)
2246            }
2247            _ => false,
2248        }
2249    }
2250
2251    fn is_action_result_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2252        move |event: &Event| match event {
2253            Event::Input { metadata, .. } => {
2254                let p = &metadata.parameters;
2255                dora_message::metadata::get_string_param(p, GOAL_ID) == Some(needle)
2256                    && matches!(
2257                        dora_message::metadata::get_string_param(p, GOAL_STATUS),
2258                        Some(GOAL_STATUS_SUCCEEDED)
2259                            | Some(GOAL_STATUS_ABORTED)
2260                            | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
2261                    )
2262            }
2263            _ => false,
2264        }
2265    }
2266
2267    #[test]
2268    fn classify_matching_request_id_returns_match() {
2269        let server = NodeId::from("calc".to_string());
2270        let event = make_input_event("response", request_id_params("req-42"));
2271        assert_eq!(
2272            classify_correlation_event(&event, &server, is_request_match("req-42")),
2273            CorrelationOutcome::Match
2274        );
2275    }
2276
2277    #[test]
2278    fn classify_different_request_id_is_passthrough() {
2279        let server = NodeId::from("calc".to_string());
2280        let event = make_input_event("response", request_id_params("req-99"));
2281        assert_eq!(
2282            classify_correlation_event(&event, &server, is_request_match("req-42")),
2283            CorrelationOutcome::Passthrough
2284        );
2285    }
2286
2287    #[test]
2288    fn classify_expected_server_restart_returns_server_restarted() {
2289        let server = NodeId::from("calc".to_string());
2290        let event = Event::NodeRestarted { id: server.clone() };
2291        assert_eq!(
2292            classify_correlation_event(&event, &server, is_request_match("req-42")),
2293            CorrelationOutcome::ServerRestarted
2294        );
2295    }
2296
2297    #[test]
2298    fn classify_unrelated_node_restart_is_passthrough() {
2299        let server = NodeId::from("calc".to_string());
2300        let event = Event::NodeRestarted {
2301            id: NodeId::from("other".to_string()),
2302        };
2303        assert_eq!(
2304            classify_correlation_event(&event, &server, is_request_match("req-42")),
2305            CorrelationOutcome::Passthrough
2306        );
2307    }
2308
2309    #[test]
2310    fn classify_stop_returns_stream_ended() {
2311        let server = NodeId::from("calc".to_string());
2312        let event = Event::Stop(StopCause::Manual);
2313        assert_eq!(
2314            classify_correlation_event(&event, &server, is_request_match("req-42")),
2315            CorrelationOutcome::StreamEnded
2316        );
2317    }
2318
2319    #[test]
2320    fn classify_error_returns_stream_error() {
2321        let server = NodeId::from("calc".to_string());
2322        let event = Event::Error("boom".to_string());
2323        assert_eq!(
2324            classify_correlation_event(&event, &server, is_request_match("req-42")),
2325            CorrelationOutcome::StreamError
2326        );
2327    }
2328
2329    #[test]
2330    fn classify_unrelated_input_is_passthrough() {
2331        let server = NodeId::from("calc".to_string());
2332        let event = make_input_event("sensor", MetadataParameters::new());
2333        assert_eq!(
2334            classify_correlation_event(&event, &server, is_request_match("req-42")),
2335            CorrelationOutcome::Passthrough
2336        );
2337    }
2338
2339    #[test]
2340    fn classify_param_update_is_passthrough() {
2341        // Runtime parameter updates must survive a helper wait.
2342        let server = NodeId::from("calc".to_string());
2343        let event = Event::ParamUpdate {
2344            key: "threshold".to_string(),
2345            value: serde_json::json!(0.85),
2346        };
2347        assert_eq!(
2348            classify_correlation_event(&event, &server, is_request_match("req-42")),
2349            CorrelationOutcome::Passthrough
2350        );
2351    }
2352
2353    #[test]
2354    fn classify_action_result_terminal_succeeded_matches() {
2355        let server = NodeId::from("nav".to_string());
2356        let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_SUCCEEDED)));
2357        assert_eq!(
2358            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2359            CorrelationOutcome::Match
2360        );
2361    }
2362
2363    #[test]
2364    fn classify_action_result_terminal_aborted_matches() {
2365        let server = NodeId::from("nav".to_string());
2366        let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_ABORTED)));
2367        assert_eq!(
2368            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2369            CorrelationOutcome::Match
2370        );
2371    }
2372
2373    #[test]
2374    fn classify_action_feedback_without_terminal_status_is_passthrough() {
2375        // Intermediate feedback (no terminal goal_status) should pass
2376        // through so the caller's main loop can observe it.
2377        let server = NodeId::from("nav".to_string());
2378        let event = make_input_event("feedback", goal_params("goal-1", None));
2379        assert_eq!(
2380            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2381            CorrelationOutcome::Passthrough
2382        );
2383    }
2384
2385    #[test]
2386    fn classify_action_result_for_different_goal_is_passthrough() {
2387        let server = NodeId::from("nav".to_string());
2388        let event = make_input_event("result", goal_params("goal-2", Some(GOAL_STATUS_SUCCEEDED)));
2389        assert_eq!(
2390            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2391            CorrelationOutcome::Passthrough
2392        );
2393    }
2394
2395    // ---- dora-rs/adora#172: pending_passthrough integration ----
2396
2397    use crate::integration_testing::{
2398        IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
2399        integration_testing_format::{IncomingEvent, TimedIncomingEvent},
2400    };
2401
2402    /// Create a minimal EventStream via the testing path.
2403    fn test_event_stream() -> (crate::DoraNode, EventStream) {
2404        let events = vec![TimedIncomingEvent {
2405            time_offset_secs: 0.0,
2406            event: IncomingEvent::Stop,
2407        }];
2408        let inputs = TestingInput::Input(IntegrationTestInput::new(
2409            "test-node".parse().unwrap(),
2410            events,
2411        ));
2412        let (tx, _rx) = crate::integration_testing::output_channel();
2413        let outputs = TestingOutput::ToChannel(tx);
2414        let options = TestingOptions {
2415            skip_output_time_offsets: true,
2416        };
2417        crate::DoraNode::init_testing(inputs, outputs, options).unwrap()
2418    }
2419
2420    /// #2956: outputs sent through `TestingOutput::ToChannel` must reach the
2421    /// receiver, in order, when drained after the node has finished — the
2422    /// documented usage pattern, and previously untested (every other
2423    /// `ToChannel` test here drops the receiver).
2424    ///
2425    /// Plain `#[test]` on purpose: the testing bridge uses
2426    /// `blocking_send`/`blocking_recv` on the request channel, which panic
2427    /// inside a tokio runtime.
2428    #[test]
2429    fn to_channel_delivers_outputs_in_order() {
2430        use arrow::array::Int32Array;
2431
2432        let events = vec![TimedIncomingEvent {
2433            time_offset_secs: 0.0,
2434            event: IncomingEvent::Stop,
2435        }];
2436        let inputs = TestingInput::Input(IntegrationTestInput::new(
2437            "test-node".parse().unwrap(),
2438            events,
2439        ));
2440        let (tx, mut rx) = crate::integration_testing::output_channel();
2441        let outputs = TestingOutput::ToChannel(tx);
2442        let options = TestingOptions {
2443            skip_output_time_offsets: true,
2444        };
2445        let (mut node, _events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
2446
2447        for i in 0..3 {
2448            node.send_output(
2449                "out".parse().unwrap(),
2450                Default::default(),
2451                dora_arrow_convert::internal::from_array_ref(std::sync::Arc::new(
2452                    Int32Array::from(vec![i]),
2453                )),
2454            )
2455            .unwrap();
2456        }
2457
2458        let received = crate::integration_testing::drain_outputs(&mut rx);
2459
2460        assert_eq!(received.len(), 3, "every sent output should be delivered");
2461        for (i, output) in received.iter().enumerate() {
2462            assert_eq!(output.get("id").and_then(|v| v.as_str()), Some("out"));
2463            assert_eq!(
2464                output.get("data"),
2465                Some(&serde_json::json!([i as i32])),
2466                "outputs should arrive in send order"
2467            );
2468        }
2469    }
2470
2471    #[test]
2472    fn is_empty_reflects_pending_passthrough() {
2473        let (_node, mut events) = test_event_stream();
2474        // Drain the initial Stop event so the stream is empty.
2475        let _ = events.recv();
2476        assert!(events.is_empty(), "should be empty after draining");
2477
2478        // Inject a passthrough event — is_empty must now return false.
2479        events.push_passthrough_for_testing(Event::ParamDeleted {
2480            key: "k".to_string(),
2481        });
2482        assert!(
2483            !events.is_empty(),
2484            "should not be empty with pending passthrough"
2485        );
2486    }
2487
2488    #[test]
2489    fn stream_poll_next_drains_pending_passthrough() {
2490        use futures::StreamExt;
2491        let (_node, mut events) = test_event_stream();
2492        // Drain the initial Stop event.
2493        let _ = events.recv();
2494
2495        // Inject a passthrough event.
2496        events.push_passthrough_for_testing(Event::ParamUpdate {
2497            key: "threshold".to_string(),
2498            value: serde_json::json!(42),
2499        });
2500
2501        // StreamExt::next() should return the passthrough event, not
2502        // block waiting on the underlying receiver.
2503        let next = futures::executor::block_on(events.next());
2504        match next {
2505            Some(Event::ParamUpdate { key, value }) => {
2506                assert_eq!(key, "threshold");
2507                assert_eq!(value, serde_json::json!(42));
2508            }
2509            other => panic!("expected ParamUpdate from passthrough, got {other:?}"),
2510        }
2511    }
2512
2513    #[test]
2514    fn recv_async_drains_pending_passthrough_before_receiver() {
2515        let (_node, mut events) = test_event_stream();
2516
2517        // Inject a passthrough event BEFORE the Stop in the receiver.
2518        events.push_passthrough_for_testing(Event::ParamDeleted {
2519            key: "x".to_string(),
2520        });
2521
2522        // First recv should return the passthrough event.
2523        let first = events.recv();
2524        assert!(
2525            matches!(first, Some(Event::ParamDeleted { .. })),
2526            "expected passthrough ParamDeleted first, got {first:?}"
2527        );
2528
2529        // Second recv should return the Stop from the receiver.
2530        let second = events.recv();
2531        assert!(
2532            matches!(second, Some(Event::Stop(_))),
2533            "expected Stop second, got {second:?}"
2534        );
2535    }
2536
2537    /// Regression: a pattern-aware wait (`recv_service_response`) must make
2538    /// progress when a non-matching event arrives *before* the correlated
2539    /// response.
2540    ///
2541    /// The wait loop buffers every non-matching event into
2542    /// `pending_passthrough` so the caller's own event loop can still see it.
2543    /// Before the fix the loop pumped the stream via `recv_async`, which
2544    /// drains `pending_passthrough` first — so it kept re-serving the buffered
2545    /// non-matching event, re-buffering it, and spinning forever without ever
2546    /// reading the response off the receiver. Every such call pinned a CPU core
2547    /// and returned `Timeout`. The fix pumps via `recv_from_stream`, which
2548    /// bypasses the passthrough buffer.
2549    #[test]
2550    fn recv_service_response_matches_after_non_matching_event() {
2551        // Delivered FIFO (integration tests disable the reordering scheduler):
2552        // the non-matching "sensor" input, then the correlated "response".
2553        let events = vec![
2554            TimedIncomingEvent {
2555                time_offset_secs: 0.0,
2556                event: IncomingEvent::Input {
2557                    id: "sensor".parse().unwrap(),
2558                    metadata: None,
2559                    data: None,
2560                },
2561            },
2562            TimedIncomingEvent {
2563                time_offset_secs: 0.0,
2564                event: IncomingEvent::Input {
2565                    id: "response".parse().unwrap(),
2566                    metadata: Some(request_id_params("req-1")),
2567                    data: None,
2568                },
2569            },
2570            TimedIncomingEvent {
2571                time_offset_secs: 0.0,
2572                event: IncomingEvent::Stop,
2573            },
2574        ];
2575        let inputs = TestingInput::Input(IntegrationTestInput::new(
2576            "test-node".parse().unwrap(),
2577            events,
2578        ));
2579        let (tx, _rx) = crate::integration_testing::output_channel();
2580        let outputs = TestingOutput::ToChannel(tx);
2581        let options = TestingOptions {
2582            skip_output_time_offsets: true,
2583        };
2584        let (_node, mut events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
2585
2586        let server = NodeId::from("calc".to_string());
2587        let response = futures::executor::block_on(events.recv_service_response(
2588            "req-1",
2589            &server,
2590            Duration::from_secs(5),
2591        ));
2592        match response {
2593            Ok(Event::Input { id, .. }) => assert_eq!(id.as_str(), "response"),
2594            other => panic!("expected the correlated response Input, got {other:?}"),
2595        }
2596
2597        // The non-matching "sensor" input must not be lost — it is replayed
2598        // to the caller's own event loop after the wait returns.
2599        let buffered = events.recv();
2600        assert!(
2601            matches!(&buffered, Some(Event::Input { id, .. }) if id.as_str() == "sensor"),
2602            "expected the buffered non-matching 'sensor' input, got {buffered:?}"
2603        );
2604    }
2605
2606    /// Regression: a pattern-aware wait must find its correlated response even
2607    /// when a *previous* wait already buffered it.
2608    ///
2609    /// This is the pipelined / out-of-order case: two requests are in flight,
2610    /// and `req-2`'s response arrives before `req-1`'s. The wait for `req-1`
2611    /// buffers `resp-2` into `pending_passthrough` as non-matching, then
2612    /// returns `resp-1`. The subsequent wait for `req-2` must return the
2613    /// already-buffered `resp-2` — the wait loop pumps `recv_from_stream`,
2614    /// which never reads `pending_passthrough`, so `wait_for_correlation`
2615    /// scans the buffer for a match before reading the stream. Without that
2616    /// scan the buffered response is invisible and the wait wrongly times out.
2617    #[test]
2618    fn recv_service_response_matches_buffered_response_from_prior_wait() {
2619        // Delivered FIFO: `resp-2` arrives before `resp-1`, then `Stop`.
2620        let events = vec![
2621            TimedIncomingEvent {
2622                time_offset_secs: 0.0,
2623                event: IncomingEvent::Input {
2624                    id: "response".parse().unwrap(),
2625                    metadata: Some(request_id_params("req-2")),
2626                    data: None,
2627                },
2628            },
2629            TimedIncomingEvent {
2630                time_offset_secs: 0.0,
2631                event: IncomingEvent::Input {
2632                    id: "response".parse().unwrap(),
2633                    metadata: Some(request_id_params("req-1")),
2634                    data: None,
2635                },
2636            },
2637            TimedIncomingEvent {
2638                time_offset_secs: 0.0,
2639                event: IncomingEvent::Stop,
2640            },
2641        ];
2642        let inputs = TestingInput::Input(IntegrationTestInput::new(
2643            "test-node".parse().unwrap(),
2644            events,
2645        ));
2646        let (tx, _rx) = crate::integration_testing::output_channel();
2647        let outputs = TestingOutput::ToChannel(tx);
2648        let options = TestingOptions {
2649            skip_output_time_offsets: true,
2650        };
2651        let (_node, mut events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
2652
2653        let server = NodeId::from("calc".to_string());
2654        let request_id_of = |event: &Event| match event {
2655            Event::Input { metadata, .. } => dora_message::metadata::get_string_param(
2656                &metadata.parameters,
2657                dora_message::metadata::REQUEST_ID,
2658            )
2659            .map(str::to_owned),
2660            _ => None,
2661        };
2662
2663        // Wait for `req-1`: reads `resp-2` (buffered as non-matching), then
2664        // returns `resp-1`.
2665        let first = futures::executor::block_on(events.recv_service_response(
2666            "req-1",
2667            &server,
2668            Duration::from_secs(5),
2669        ));
2670        match &first {
2671            Ok(event) => assert_eq!(request_id_of(event).as_deref(), Some("req-1")),
2672            other => panic!("expected the req-1 response, got {other:?}"),
2673        }
2674
2675        // Wait for `req-2`: `resp-2` is already in `pending_passthrough`, so
2676        // this must return it from the buffer rather than time out.
2677        let second = futures::executor::block_on(events.recv_service_response(
2678            "req-2",
2679            &server,
2680            Duration::from_secs(5),
2681        ));
2682        match &second {
2683            Ok(event) => assert_eq!(request_id_of(event).as_deref(), Some("req-2")),
2684            other => panic!("expected the buffered req-2 response, got {other:?}"),
2685        }
2686    }
2687
2688    /// After a `Stop` event is delivered, subsequent `recv` calls must
2689    /// return `None` so the node can exit cleanly even when zenoh
2690    /// subscriber threads still hold clones of the event channel
2691    /// sender (which would otherwise keep the receiver open).
2692    #[test]
2693    fn recv_returns_none_after_stop() {
2694        let (_node, mut events) = test_event_stream();
2695
2696        // First recv delivers the seeded Stop.
2697        let first = events.recv();
2698        assert!(matches!(first, Some(Event::Stop(_))));
2699
2700        // Second recv must return None even though the underlying
2701        // receiver may still have live senders.
2702        let second = events.recv();
2703        assert!(
2704            second.is_none(),
2705            "recv must return None after Stop, got {second:?}"
2706        );
2707    }
2708
2709    /// #2027: the scheduler gives `Stop` (a NON_INPUT_EVENT) strict priority
2710    /// over buffered inputs, so an input enqueued before `Stop` is still in the
2711    /// scheduler when `Stop` is delivered. `recv` must drain that input before
2712    /// closing rather than dropping it silently (the previous `return None`
2713    /// after `stop_received` lost it).
2714    #[test]
2715    fn recv_drains_buffered_scheduler_inputs_after_stop() {
2716        let (_node, mut events) = test_event_stream();
2717
2718        // Deliver the seeded Stop (sets `stop_received`).
2719        assert!(matches!(events.recv(), Some(Event::Stop(_))));
2720
2721        // Simulate the input the scheduler held back behind the prioritized Stop.
2722        events.push_scheduler_input_for_testing("cam");
2723
2724        let drained = events.recv();
2725        assert!(
2726            matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
2727            "buffered input must be drained after Stop, got {drained:?}"
2728        );
2729
2730        // Once the scheduler is empty the stream closes.
2731        assert!(
2732            events.recv().is_none(),
2733            "stream must close after draining buffered inputs"
2734        );
2735    }
2736
2737    /// #2027 review (P2): the post-Stop drain must deliver buffered *inputs*
2738    /// only. A non-input control event buffered behind Stop (e.g. a second
2739    /// `Stop`) must NOT be re-delivered to a loop-until-`None` caller.
2740    #[test]
2741    fn recv_after_stop_skips_trailing_control_events() {
2742        let (_node, mut events) = test_event_stream();
2743
2744        // Deliver the seeded Stop (sets `stop_received`).
2745        assert!(matches!(events.recv(), Some(Event::Stop(_))));
2746
2747        // Buffer a trailing Stop AND a real input behind it. The scheduler
2748        // prioritizes the Stop (NON_INPUT), so the drain meets it first.
2749        events.push_scheduler_stop_for_testing();
2750        events.push_scheduler_input_for_testing("cam");
2751
2752        // The drain must skip the trailing Stop and return only the input...
2753        let drained = events.recv();
2754        assert!(
2755            matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
2756            "expected the buffered input, not a re-delivered Stop, got {drained:?}"
2757        );
2758        // ...then close (no second Stop ever surfaces).
2759        assert!(events.recv().is_none(), "stream must close after the input");
2760    }
2761
2762    /// The zenoh receive path is Arrow-IPC-only. An empty payload is a
2763    /// metadata-only message and maps to the unit array; a non-empty payload is
2764    /// a self-describing IPC stream and round-trips to its original array (with
2765    /// no type sidecar involved).
2766    #[test]
2767    fn zenoh_payload_ipc_roundtrip_and_empty_is_unit() {
2768        use crate::arrow_utils::ipc_encode::{
2769            InputDecoder, encode_ipc_into_data, ipc_fast_path_len_data,
2770        };
2771        use arrow::array::{Array, Int32Array};
2772
2773        // A standalone full stream (no SCHEMA_HASH parameter) decodes directly.
2774        let metadata =
2775            dora_message::metadata::Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
2776        let mut decoder = InputDecoder::new();
2777
2778        // Empty payload -> unit array (metadata-only message).
2779        let unit = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::new())
2780            .unwrap()
2781            .unwrap();
2782        assert_eq!(
2783            unit.data_type(),
2784            &arrow_schema::DataType::Null,
2785            "empty payload maps to the unit array"
2786        );
2787
2788        // Non-empty IPC payload round-trips to the original array.
2789        let data = Int32Array::from(vec![10, 20, 30]).into_data();
2790        let len = ipc_fast_path_len_data(&data).expect("primitive is fast-path eligible");
2791        let mut buf = vec![0u8; len];
2792        encode_ipc_into_data(&data, &mut buf).unwrap();
2793
2794        let decoded = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::from(buf))
2795            .unwrap()
2796            .unwrap();
2797        assert_eq!(decoded.data_type(), &arrow_schema::DataType::Int32);
2798        assert_eq!(&decoded, &data);
2799    }
2800
2801    /// A full self-describing stream on the data topic must prime the per-input
2802    /// decoder in-band: the schema-less batches that follow decode against it
2803    /// without any `@schema`-plane delivery. This is what makes the producer's
2804    /// "full stream until the schema is confirmed published" strategy race-free
2805    /// — data-plane puts share one publisher, so zenoh preserves their order,
2806    /// while the separate `@schema` plane can lose the race with an express
2807    /// batch (dora-rs/dora#2366 review: first-message QoS race).
2808    #[test]
2809    fn full_stream_primes_decoder_in_band_for_schema_less_batches() {
2810        use crate::arrow_utils::ipc_encode::{
2811            InputDecoder, batch_fast_path_len_data, encode_batch_into_data, encode_ipc_into_data,
2812            ipc_fast_path_len_data, schema_block_len,
2813        };
2814        use arrow::array::{Array, Int32Array};
2815        use dora_message::metadata::{Metadata, Parameter, SCHEMA_HASH};
2816
2817        let hlc = dora_core::uhlc::HLC::default();
2818        let mut decoder = InputDecoder::new();
2819
2820        // Message 1: full stream (no SCHEMA_HASH), as the producer sends while
2821        // the schema is not yet confirmed published on the `@schema` plane.
2822        let first = Int32Array::from(vec![1, 2]).into_data();
2823        let mut full = vec![0u8; ipc_fast_path_len_data(&first).unwrap()];
2824        encode_ipc_into_data(&first, &mut full).unwrap();
2825        let block = schema_block_len(&full).unwrap();
2826        let hash = dora_message::metadata::fnv1a(&full[..block]);
2827
2828        let plain = Metadata::new(hlc.new_timestamp());
2829        let got = decode_zenoh_sample(&mut decoder, &plain, zenoh::bytes::ZBytes::from(full))
2830            .unwrap()
2831            .unwrap();
2832        assert_eq!(&got, &first);
2833
2834        // Message 2: schema-less batch tagged with the schema hash. No
2835        // `set_schema` call happened — decoding must succeed purely from the
2836        // in-band priming above.
2837        let second = Int32Array::from(vec![3]).into_data();
2838        let mut batch = vec![0u8; batch_fast_path_len_data(&second).unwrap()];
2839        encode_batch_into_data(&second, &mut batch).unwrap();
2840        let mut tagged = Metadata::new(hlc.new_timestamp());
2841        tagged
2842            .parameters
2843            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2844
2845        let got = decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
2846            .unwrap()
2847            .expect("schema-less batch must decode against the in-band-primed decoder");
2848        assert_eq!(&got, &second);
2849    }
2850
2851    /// Service/action full streams (pattern-correlated) are excluded from
2852    /// schema-once and must NOT prime the decoder in-band: a server multiplexes
2853    /// per-request schemas through one output, and letting those prime the
2854    /// decoder would churn the retained schema set for no benefit.
2855    #[test]
2856    fn pattern_correlated_full_stream_does_not_prime_in_band() {
2857        use crate::arrow_utils::ipc_encode::{
2858            InputDecoder, batch_fast_path_len_data, encode_batch_into_data, encode_ipc_into_data,
2859            ipc_fast_path_len_data, schema_block_len,
2860        };
2861        use arrow::array::{Array, Int32Array};
2862        use dora_message::metadata::{Metadata, Parameter, REQUEST_ID, SCHEMA_HASH};
2863
2864        let hlc = dora_core::uhlc::HLC::default();
2865        let mut decoder = InputDecoder::new();
2866
2867        let reply = Int32Array::from(vec![7]).into_data();
2868        let mut full = vec![0u8; ipc_fast_path_len_data(&reply).unwrap()];
2869        encode_ipc_into_data(&reply, &mut full).unwrap();
2870        let block = schema_block_len(&full).unwrap();
2871        let hash = dora_message::metadata::fnv1a(&full[..block]);
2872
2873        // A service reply (request_id) decodes standalone…
2874        let mut service = Metadata::new(hlc.new_timestamp());
2875        service
2876            .parameters
2877            .insert(REQUEST_ID.to_string(), Parameter::String("req-1".into()));
2878        let got = decode_zenoh_sample(&mut decoder, &service, zenoh::bytes::ZBytes::from(full))
2879            .unwrap()
2880            .unwrap();
2881        assert_eq!(&got, &reply);
2882
2883        // …but must not have primed the decoder for its schema hash.
2884        let batch_array = Int32Array::from(vec![8]).into_data();
2885        let mut batch = vec![0u8; batch_fast_path_len_data(&batch_array).unwrap()];
2886        encode_batch_into_data(&batch_array, &mut batch).unwrap();
2887        let mut tagged = Metadata::new(hlc.new_timestamp());
2888        tagged
2889            .parameters
2890            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2891        assert!(
2892            decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
2893                .unwrap()
2894                .is_none(),
2895            "a pattern-correlated stream must not prime the schema-once decoder"
2896        );
2897    }
2898
2899    /// Internal wire-protocol keys (`_schema_hash`, `_framing`) must be
2900    /// stripped from the metadata handed to user code — on both the zenoh and
2901    /// the daemon receive paths. A forwarded `_schema_hash` would otherwise
2902    /// ride onto a large/service output (which does not overwrite it) and make
2903    /// receivers hash-mismatch and silently drop the message
2904    /// (dora-rs/dora#2366 review).
2905    #[test]
2906    fn internal_wire_keys_are_stripped_from_user_visible_metadata() {
2907        use dora_message::metadata::{
2908            FRAMING, FRAMING_ARROW_IPC, Metadata, Parameter, SCHEMA_HASH,
2909        };
2910
2911        let hlc = dora_core::uhlc::HLC::default();
2912        let mut metadata = Metadata::new(hlc.new_timestamp());
2913        metadata
2914            .parameters
2915            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(42));
2916        metadata.parameters.insert(
2917            FRAMING.to_string(),
2918            Parameter::String(FRAMING_ARROW_IPC.to_string()),
2919        );
2920        metadata
2921            .parameters
2922            .insert("user_key".to_string(), Parameter::Integer(7));
2923
2924        // Zenoh receive path.
2925        let zenoh_item = EventItem::ZenohInput {
2926            id: DataId::from("in".to_string()),
2927            metadata: Arc::new(metadata.clone()),
2928            data: {
2929                use arrow::array::Array;
2930                arrow::array::Int32Array::from(vec![1]).into_data()
2931            },
2932        };
2933        let Event::Input {
2934            metadata: user_metadata,
2935            ..
2936        } = EventStream::convert_event_item(zenoh_item)
2937        else {
2938            panic!("expected an input event");
2939        };
2940        assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
2941        assert!(!user_metadata.parameters.contains_key(FRAMING));
2942        assert_eq!(
2943            user_metadata.parameters.get("user_key"),
2944            Some(&Parameter::Integer(7)),
2945            "user-provided keys must survive the strip"
2946        );
2947
2948        // Daemon receive path.
2949        let daemon_item = EventItem::NodeEvent {
2950            event: dora_message::daemon_to_node::NodeEvent::Input {
2951                id: DataId::from("in".to_string()),
2952                metadata: Arc::new(metadata),
2953                data: None,
2954            },
2955        };
2956        let Event::Input {
2957            metadata: user_metadata,
2958            ..
2959        } = EventStream::convert_event_item(daemon_item)
2960        else {
2961            panic!("expected an input event");
2962        };
2963        assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
2964        assert!(!user_metadata.parameters.contains_key(FRAMING));
2965    }
2966
2967    /// A zenoh-delivered input must be serializable into the same recording
2968    /// JSON shape as a daemon-path input, so `write_events_to` recordings do
2969    /// not silently drop inputs that take the direct zenoh data plane.
2970    #[test]
2971    fn zenoh_input_serializes_into_recording_json() {
2972        use crate::daemon_connection::node_integration_testing::convert_arrow_input_to_json;
2973
2974        let hlc = dora_core::uhlc::HLC::default();
2975        let start = hlc.new_timestamp();
2976        let metadata = Metadata::new(hlc.new_timestamp());
2977        let array: arrow::array::ArrayRef =
2978            std::sync::Arc::new(arrow::array::Int32Array::from(vec![1, 2, 3]));
2979
2980        let json = convert_arrow_input_to_json(
2981            &DataId::from("in".to_string()),
2982            &metadata,
2983            array,
2984            start,
2985            true,
2986        )
2987        .expect("zenoh input must serialize");
2988
2989        assert_eq!(json["id"], "in");
2990        assert!(json.contains_key("data"), "recorded event must carry data");
2991        assert!(
2992            json.contains_key("data_type"),
2993            "recorded event must carry data_type"
2994        );
2995        assert_eq!(
2996            json["data"].as_array().map(|a| a.len()),
2997            Some(3),
2998            "all array elements must be recorded"
2999        );
3000    }
3001
3002    /// A zenoh input can carry a remote HLC timestamp that predates this node's
3003    /// `start_timestamp`. The recording must clamp the offset to zero instead of
3004    /// underflowing the NTP64 subtraction (a debug panic that would kill the
3005    /// event loop, or a release wraparound to a garbage offset).
3006    #[test]
3007    fn zenoh_input_with_earlier_timestamp_does_not_underflow() {
3008        use crate::daemon_connection::node_integration_testing::convert_arrow_input_to_json;
3009
3010        let hlc = dora_core::uhlc::HLC::default();
3011        // `input_ts` is created first, so it is strictly before `start`.
3012        let input_ts = hlc.new_timestamp();
3013        let start = hlc.new_timestamp();
3014        let metadata = Metadata::new(input_ts);
3015        let array: arrow::array::ArrayRef =
3016            std::sync::Arc::new(arrow::array::Int32Array::from(vec![1]));
3017
3018        // `skip_output_time_offsets = false` exercises the time-offset path.
3019        let json = convert_arrow_input_to_json(
3020            &DataId::from("in".to_string()),
3021            &metadata,
3022            array,
3023            start,
3024            false,
3025        )
3026        .expect("recording an earlier-timestamped input must not fail");
3027        assert_eq!(
3028            json["time_offset_secs"], 0.0,
3029            "an input predating start must clamp to a zero offset"
3030        );
3031    }
3032
3033    /// The schema-plane FatalError only fires after the grace window: the
3034    /// producer's periodic full-stream refresh heals an unprimed input in-band,
3035    /// so a node with a dead `@schema` plane must not be killed on the first
3036    /// dropped batch when it would recover within seconds.
3037    #[test]
3038    fn schema_plane_fatal_waits_out_the_grace_window() {
3039        let start = Instant::now();
3040        let mut first = None;
3041
3042        // First undecodable batch starts the window — not fatal yet.
3043        assert!(!schema_plane_fatal_due(&mut first, start));
3044        assert_eq!(first, Some(start));
3045        // Still inside the grace window — not fatal.
3046        assert!(!schema_plane_fatal_due(
3047            &mut first,
3048            start + SCHEMA_PLANE_FATAL_GRACE / 2
3049        ));
3050        // Past the window — fatal.
3051        assert!(schema_plane_fatal_due(
3052            &mut first,
3053            start + SCHEMA_PLANE_FATAL_GRACE
3054        ));
3055
3056        // A successful decode clears the window (caller side); the next drop
3057        // starts a fresh one.
3058        let mut first = None;
3059        let later = start + SCHEMA_PLANE_FATAL_GRACE * 2;
3060        assert!(!schema_plane_fatal_due(&mut first, later));
3061        assert_eq!(first, Some(later));
3062    }
3063
3064    /// Same invariant as `recv_returns_none_after_stop`, verified via
3065    /// the `Stream` impl (`StreamExt::next`).
3066    #[test]
3067    fn stream_returns_none_after_stop() {
3068        use futures::StreamExt;
3069        let (_node, mut events) = test_event_stream();
3070
3071        let first = futures::executor::block_on(events.next());
3072        assert!(matches!(first, Some(Event::Stop(_))));
3073
3074        let second = futures::executor::block_on(events.next());
3075        assert!(
3076            second.is_none(),
3077            "Stream::next must yield None after Stop, got {second:?}"
3078        );
3079    }
3080}