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, events are discarded to keep memory bounded. For
993    /// `drop_oldest` inputs the cap is `queue_size` (clamped to at least 1); an overflow normally
994    /// evicts the oldest queued event, but correlated service/action messages and the `Stop` event
995    /// are preserved where possible, so the evicted event may instead be a newer one (or the
996    /// incoming event itself). For `backpressure` inputs the hard safety cap is
997    /// `max(10 × queue_size, 100)`.
998    /// This method returns a map from input ID to the number of messages dropped
999    /// since the last call.
1000    pub fn drain_drop_counts(&mut self) -> HashMap<DataId, u64> {
1001        self.scheduler.drain_drop_counts()
1002    }
1003
1004    fn add_event(&mut self, event: EventItem) {
1005        // Event recording is observability-only (writes to the optional
1006        // `write_events_to` log). A write failure must not panic the event
1007        // loop — drop the log line and continue scheduling.
1008        if let Err(err) = self.record_event(&event) {
1009            tracing::warn!(
1010                node = %self.node_id,
1011                "failed to record event to write_events_to log: {err:?}"
1012            );
1013            // Mark the recording poisoned so consumers can detect events
1014            // are missing from the final JSON. `write_out()` surfaces this
1015            // as a top-level `recording_status` field (#1857).
1016            if let Some(write_events_to) = self.write_events_to.as_mut() {
1017                let time_offset_secs = self
1018                    .clock
1019                    .new_timestamp()
1020                    .get_diff_duration(&self.start_timestamp)
1021                    .as_secs_f64();
1022                write_events_to.mark_poisoned(&err, time_offset_secs);
1023            }
1024        }
1025        self.scheduler.add_event(event);
1026    }
1027
1028    fn record_event(&mut self, event: &EventItem) -> eyre::Result<()> {
1029        if let Some(write_events_to) = &mut self.write_events_to {
1030            let event_json = match event {
1031                EventItem::NodeEvent { event, .. } => match event {
1032                    NodeEvent::Stop => Some(control_event_json(
1033                        &self.clock,
1034                        &self.start_timestamp,
1035                        "Stop",
1036                        None,
1037                    )),
1038                    NodeEvent::Reload { .. } => None,
1039                    NodeEvent::Input { id, metadata, data } => {
1040                        let mut event_json = convert_output_to_json(
1041                            id,
1042                            metadata,
1043                            data,
1044                            self.start_timestamp,
1045                            false,
1046                        )?;
1047                        event_json.insert("type".into(), "Input".into());
1048                        Some(event_json.into())
1049                    }
1050                    NodeEvent::InputClosed { id } => Some(control_event_json(
1051                        &self.clock,
1052                        &self.start_timestamp,
1053                        "InputClosed",
1054                        Some(id.to_string()),
1055                    )),
1056                    NodeEvent::InputRecovered { id } => Some(control_event_json(
1057                        &self.clock,
1058                        &self.start_timestamp,
1059                        "InputRecovered",
1060                        Some(id.to_string()),
1061                    )),
1062                    NodeEvent::NodeRestarted { id } => Some(control_event_json(
1063                        &self.clock,
1064                        &self.start_timestamp,
1065                        "NodeRestarted",
1066                        Some(id.to_string()),
1067                    )),
1068                    NodeEvent::AllInputsClosed => Some(control_event_json(
1069                        &self.clock,
1070                        &self.start_timestamp,
1071                        "AllInputsClosed",
1072                        None,
1073                    )),
1074                    _ => None,
1075                },
1076                // Zenoh-delivered inputs surface to the user as `Event::Input`
1077                // exactly like the daemon-path `NodeEvent::Input` above, but
1078                // bypass the daemon, so neither this recorder nor the daemon
1079                // would otherwise capture them — silently dropping inputs from
1080                // the `write_events_to` recording. Record them here too.
1081                EventItem::ZenohInput { id, metadata, data } => {
1082                    let array = arrow::array::make_array(data.clone());
1083                    let mut event_json = convert_arrow_input_to_json(
1084                        id,
1085                        metadata,
1086                        array,
1087                        self.start_timestamp,
1088                        false,
1089                    )?;
1090                    event_json.insert("type".into(), "Input".into());
1091                    Some(event_json.into())
1092                }
1093                _ => None,
1094            };
1095            if let Some(event_json) = event_json {
1096                write_events_to.events_buffer.push(event_json);
1097            }
1098        }
1099        Ok(())
1100    }
1101
1102    /// Receives the next buffered [`Event`] (if any) without blocking, using an
1103    /// [`EventScheduler`] for fairness.
1104    ///
1105    /// Returns [`TryRecvError::Empty`] if no event is available right now.
1106    /// Returns [`TryRecvError::Closed`] once the event stream is closed.
1107    ///
1108    /// This method never blocks and is safe to use in asynchronous contexts.
1109    ///
1110    /// ## Event Reordering
1111    ///
1112    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
1113    /// events might be returned in a different order than they occurred. For details, check the
1114    /// documentation of the [`EventScheduler`] struct.
1115    ///
1116    /// If you want to receive the events in their original chronological order, use the
1117    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
1118    /// ([`EventStream`] implements the [`Stream`] trait).
1119    pub fn try_recv(&mut self) -> Result<Event, TryRecvError> {
1120        match self.recv_async().now_or_never() {
1121            Some(Some(event)) => Ok(event),
1122            Some(None) => Err(TryRecvError::Closed),
1123            None => Err(TryRecvError::Empty),
1124        }
1125    }
1126
1127    /// Receives all buffered [`Event`]s without blocking, using an [`EventScheduler`] for fairness.
1128    ///
1129    /// Return `Some(Vec::new())` if no events are ready.
1130    /// Returns [`None`] once the event stream is closed and no events are buffered anymore.
1131    ///
1132    /// This method never blocks and is safe to use in asynchronous contexts.
1133    ///
1134    /// This method is equivalent to repeatedly calling [`try_recv`][Self::try_recv]. See its docs
1135    /// for details on event reordering.
1136    pub fn drain(&mut self) -> Option<Vec<Event>> {
1137        let mut events = Vec::new();
1138        loop {
1139            match self.try_recv() {
1140                Ok(event) => events.push(event),
1141                Err(TryRecvError::Empty) => break,
1142                Err(TryRecvError::Closed) => {
1143                    if events.is_empty() {
1144                        return None;
1145                    } else {
1146                        break;
1147                    }
1148                }
1149            }
1150        }
1151        Some(events)
1152    }
1153
1154    /// Receives the next incoming [`Event`] asynchronously with a timeout.
1155    ///
1156    /// Returns a [`Event::Error`] if no event was received within the given duration.
1157    ///
1158    /// Returns [`None`] once the event stream is closed.
1159    ///
1160    /// ## Event Reordering
1161    ///
1162    /// This method uses an [`EventScheduler`] internally to **reorder events**. This means that the
1163    /// events might be returned in a different order than they occurred. For details, check the
1164    /// documentation of the [`EventScheduler`] struct.
1165    ///
1166    /// If you want to receive the events in their original chronological order, use the
1167    /// [`StreamExt::next`](futures::StreamExt::next) method with a custom timeout future instead
1168    /// ([`EventStream`] implements the [`Stream`] trait).
1169    pub async fn recv_async_timeout(&mut self, dur: Duration) -> Option<Event> {
1170        match select(Delay::new(dur), pin!(self.recv_async())).await {
1171            Either::Left((_elapsed, _)) => Some(Self::convert_event_item(EventItem::TimeoutError(
1172                eyre!("Receiver timed out"),
1173            ))),
1174            Either::Right((event, _)) => event,
1175        }
1176    }
1177
1178    /// Waits for a service response carrying `request_id` in its metadata.
1179    ///
1180    /// Drives the event loop internally and returns the matching
1181    /// [`Event::Input`] as soon as it arrives. Non-matching events are
1182    /// buffered and replayed on the next call to `recv()` / `recv_async()`,
1183    /// so your main event loop does not lose intermediate events.
1184    ///
1185    /// Terminal conditions return a [`PatternError`]:
1186    ///
1187    /// - `Timeout` — `timeout` elapsed before any matching response arrived.
1188    /// - `ServerRestarted(expected_server)` — the expected server node
1189    ///   restarted, which means its in-flight `request_id` correlation
1190    ///   was orphaned. The caller should retry against the new instance.
1191    /// - `StreamEnded` — the event stream closed (dataflow stopping)
1192    ///   before a response arrived. The terminal `Stop` event is still
1193    ///   returned to the caller's next `recv()`.
1194    /// - `StreamError` — an upstream error event surfaced during the wait.
1195    ///
1196    /// # Example
1197    ///
1198    /// ```ignore
1199    /// let request_id = node.send_service_request(...)?;
1200    /// match events
1201    ///     .recv_service_response(&request_id, &server_id, Duration::from_secs(5))
1202    ///     .await
1203    /// {
1204    ///     Ok(Event::Input { data, .. }) => handle_response(data),
1205    ///     Err(PatternError::Timeout) => fallback_path(),
1206    ///     Err(PatternError::ServerRestarted(_)) => retry_with_new_instance(),
1207    ///     Err(e) => return Err(e.into()),
1208    ///     _ => unreachable!(),
1209    /// }
1210    /// ```
1211    pub async fn recv_service_response(
1212        &mut self,
1213        request_id: &str,
1214        expected_server: &NodeId,
1215        timeout: Duration,
1216    ) -> Result<Event, PatternError> {
1217        self.wait_for_correlation(
1218            timeout,
1219            expected_server,
1220            |event, request_id| match event {
1221                Event::Input { metadata, .. } => {
1222                    dora_message::metadata::get_string_param(
1223                        &metadata.parameters,
1224                        dora_message::metadata::REQUEST_ID,
1225                    ) == Some(request_id)
1226                }
1227                _ => false,
1228            },
1229            request_id,
1230        )
1231        .await
1232    }
1233
1234    /// Waits for a terminal action result (`goal_status` ∈
1235    /// {`succeeded`, `aborted`, `canceled`}) with a matching `goal_id`.
1236    ///
1237    /// Semantics mirror [`recv_service_response`](Self::recv_service_response)
1238    /// but match on `goal_id` + a terminal `goal_status` instead of
1239    /// `request_id`. Intermediate feedback events (matching `goal_id`
1240    /// without a terminal `goal_status`) are stashed for the caller's
1241    /// main loop — use `recv()` separately if you need to observe them.
1242    pub async fn recv_action_result(
1243        &mut self,
1244        goal_id: &str,
1245        expected_server: &NodeId,
1246        timeout: Duration,
1247    ) -> Result<Event, PatternError> {
1248        self.wait_for_correlation(
1249            timeout,
1250            expected_server,
1251            |event, goal_id| match event {
1252                Event::Input { metadata, .. } => {
1253                    let matches_goal = dora_message::metadata::get_string_param(
1254                        &metadata.parameters,
1255                        dora_message::metadata::GOAL_ID,
1256                    ) == Some(goal_id);
1257                    if !matches_goal {
1258                        return false;
1259                    }
1260                    matches!(
1261                        dora_message::metadata::get_string_param(
1262                            &metadata.parameters,
1263                            dora_message::metadata::GOAL_STATUS,
1264                        ),
1265                        Some(dora_message::metadata::GOAL_STATUS_SUCCEEDED)
1266                            | Some(dora_message::metadata::GOAL_STATUS_ABORTED)
1267                            | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
1268                    )
1269                }
1270                _ => false,
1271            },
1272            goal_id,
1273        )
1274        .await
1275    }
1276
1277    /// Core loop for the pattern-aware helpers. Waits up to `timeout`
1278    /// for an event that satisfies `is_match(event, needle)`. Buffers
1279    /// every non-matching event so the caller's main event loop can
1280    /// still see them via `recv()`.
1281    async fn wait_for_correlation<F>(
1282        &mut self,
1283        timeout: Duration,
1284        expected_server: &NodeId,
1285        is_match: F,
1286        needle: &str,
1287    ) -> Result<Event, PatternError>
1288    where
1289        F: Fn(&Event, &str) -> bool,
1290    {
1291        // A previous pattern-aware wait may already have buffered the event
1292        // we are now looking for. With pipelined requests, the response to
1293        // `req-2` can arrive — and be classified non-matching, so buffered
1294        // into `pending_passthrough` — *during* the wait for `req-1`. The loop
1295        // below pumps `recv_from_stream`, which never reads
1296        // `pending_passthrough`, so without this scan that already-buffered
1297        // response would be invisible and the wait would wrongly time out.
1298        // Extract a buffered match in place (preserving the order of the
1299        // remaining events for the caller's own `recv()`/`recv_async()`).
1300        //
1301        // Only a `Match` is pulled from the buffer: a buffered `Stop` is
1302        // already handled by the `stop_received` short-circuit in
1303        // `recv_from_stream`, and a buffered `NodeRestarted` was reported to
1304        // the caller when it was first seen, so it is left for the caller's
1305        // own event loop rather than re-surfaced here.
1306        if let Some(pos) = self
1307            .pending_passthrough
1308            .iter()
1309            .position(|event| is_match(event, needle))
1310            && let Some(event) = self.pending_passthrough.remove(pos)
1311        {
1312            return Ok(event);
1313        }
1314
1315        let deadline = std::time::Instant::now() + timeout;
1316        loop {
1317            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1318            if remaining.is_zero() {
1319                return Err(PatternError::Timeout);
1320            }
1321            // Pump the stream via `recv_from_stream`, NOT `recv_async`: the
1322            // latter drains `pending_passthrough` first, which would re-hand us
1323            // the very events we buffer below and livelock the loop (see
1324            // `recv_from_stream`'s docs).
1325            let event = match select(Delay::new(remaining), pin!(self.recv_from_stream())).await {
1326                Either::Left((_elapsed, _)) => return Err(PatternError::Timeout),
1327                Either::Right((None, _)) => return Err(PatternError::StreamEnded),
1328                Either::Right((Some(e), _)) => e,
1329            };
1330
1331            match classify_correlation_event(&event, expected_server, |e| is_match(e, needle)) {
1332                CorrelationOutcome::Match => return Ok(event),
1333                CorrelationOutcome::ServerRestarted => {
1334                    self.pending_passthrough.push_back(event);
1335                    return Err(PatternError::ServerRestarted(expected_server.to_string()));
1336                }
1337                CorrelationOutcome::StreamEnded => {
1338                    self.pending_passthrough.push_back(event);
1339                    return Err(PatternError::StreamEnded);
1340                }
1341                CorrelationOutcome::StreamError => {
1342                    if let Event::Error(err) = event {
1343                        return Err(PatternError::StreamError(err));
1344                    }
1345                    unreachable!("StreamError only returned for Event::Error");
1346                }
1347                CorrelationOutcome::Passthrough => {
1348                    self.pending_passthrough.push_back(event);
1349                }
1350            }
1351        }
1352    }
1353}
1354
1355/// Build the JSON for a "control" event that carries only a type tag, an
1356/// optional input/node id, and the elapsed time offset since the node started.
1357///
1358/// Shared by the `Stop` / `InputClosed` / `InputRecovered` / `NodeRestarted` /
1359/// `AllInputsClosed` arms of [`EventStream::record_event`], which differ only in
1360/// the `"type"` string and whether an `"id"` field is present. A free function
1361/// (rather than a `&self` method) so it can take the `clock` and
1362/// `start_timestamp` fields by reference while `record_event` holds a mutable
1363/// borrow of the sibling `write_events_to` field.
1364fn control_event_json(
1365    clock: &uhlc::HLC,
1366    start_timestamp: &uhlc::Timestamp,
1367    ty: &str,
1368    id: Option<String>,
1369) -> serde_json::Value {
1370    let time_offset = clock.new_timestamp().get_diff_duration(start_timestamp);
1371    // Build the map explicitly (rather than via `json!`) so the key order
1372    // matches the previous per-arm literals byte-for-byte under serde_json's
1373    // `preserve_order`: `type`, then the optional `id`, then `time_offset_secs`.
1374    let mut event_json = serde_json::Map::new();
1375    event_json.insert("type".to_owned(), ty.into());
1376    if let Some(id) = id {
1377        event_json.insert("id".to_owned(), serde_json::Value::String(id));
1378    }
1379    event_json.insert(
1380        "time_offset_secs".to_owned(),
1381        time_offset.as_secs_f64().into(),
1382    );
1383    serde_json::Value::Object(event_json)
1384}
1385
1386/// Outcome of classifying a single event during a pattern-aware wait.
1387/// Separated from `wait_for_correlation` so the decision logic can be
1388/// unit-tested without a live `EventStream`.
1389#[derive(Debug, PartialEq, Eq)]
1390enum CorrelationOutcome {
1391    /// The event satisfies the caller's predicate — return it.
1392    Match,
1393    /// `Event::NodeRestarted { id }` where `id == expected_server`.
1394    ServerRestarted,
1395    /// `Event::Stop(_)` — the dataflow is shutting down.
1396    StreamEnded,
1397    /// `Event::Error(_)` — the stream surfaced an error.
1398    StreamError,
1399    /// Unrelated event — buffer it and keep waiting.
1400    Passthrough,
1401}
1402
1403fn classify_correlation_event<F>(
1404    event: &Event,
1405    expected_server: &NodeId,
1406    is_match: F,
1407) -> CorrelationOutcome
1408where
1409    F: Fn(&Event) -> bool,
1410{
1411    if is_match(event) {
1412        return CorrelationOutcome::Match;
1413    }
1414    match event {
1415        Event::NodeRestarted { id } if id == expected_server => CorrelationOutcome::ServerRestarted,
1416        Event::Stop(_) => CorrelationOutcome::StreamEnded,
1417        Event::Error(_) => CorrelationOutcome::StreamError,
1418        _ => CorrelationOutcome::Passthrough,
1419    }
1420}
1421
1422impl EventStream {
1423    fn convert_event_item(item: EventItem) -> Event {
1424        match item {
1425            EventItem::NodeEvent { event } => match event {
1426                NodeEvent::Stop => Event::Stop(event::StopCause::Manual),
1427                NodeEvent::Reload { operator_id } => Event::Reload { operator_id },
1428                NodeEvent::InputClosed { id } => Event::InputClosed { id },
1429                NodeEvent::InputRecovered { id } => Event::InputRecovered { id },
1430                NodeEvent::NodeRestarted { id } => Event::NodeRestarted { id },
1431                NodeEvent::Input { id, metadata, data } => {
1432                    let data_inner = data.map(Arc::unwrap_or_clone);
1433                    let result = data_to_arrow_array(data_inner);
1434                    match result {
1435                        Ok(data) => {
1436                            let mut metadata = Arc::unwrap_or_clone(metadata);
1437                            dora_message::metadata::strip_internal_parameters(
1438                                &mut metadata.parameters,
1439                            );
1440                            Event::Input {
1441                                id,
1442                                metadata,
1443                                data: dora_arrow_convert::internal::from_array_ref(data),
1444                            }
1445                        }
1446                        Err(err) => Event::Error(format!("{err:?}")),
1447                    }
1448                }
1449                NodeEvent::AllInputsClosed => Event::Stop(event::StopCause::AllInputsClosed),
1450                NodeEvent::ParamUpdate { key, value_json } => {
1451                    match serde_json::from_slice(&value_json) {
1452                        Ok(value) => Event::ParamUpdate { key, value },
1453                        Err(err) => Event::Error(format!(
1454                            "failed to deserialize ParamUpdate value for `{key}`: {err}"
1455                        )),
1456                    }
1457                }
1458                NodeEvent::ParamDeleted { key } => Event::ParamDeleted { key },
1459                NodeEvent::NodeFailed {
1460                    affected_input_ids,
1461                    error,
1462                    source_node_id,
1463                } => Event::NodeFailed {
1464                    affected_input_ids,
1465                    error,
1466                    source_node_id,
1467                },
1468                other => {
1469                    tracing::warn!("ignoring unrecognized NodeEvent variant: {other:?}");
1470                    Event::Error(format!("unrecognized node event: {other:?}"))
1471                }
1472            },
1473
1474            EventItem::ZenohInput { id, metadata, data } => {
1475                let mut metadata = Arc::unwrap_or_clone(metadata);
1476                dora_message::metadata::strip_internal_parameters(&mut metadata.parameters);
1477                Event::Input {
1478                    id,
1479                    metadata,
1480                    // Already decoded in the subscriber callback (receipt order).
1481                    data: dora_arrow_convert::internal::from_array_data(data),
1482                }
1483            }
1484
1485            EventItem::FatalError(err) => {
1486                Event::Error(format!("fatal event stream error: {err:?}"))
1487            }
1488            EventItem::TimeoutError(err) => {
1489                Event::Error(format!("Timeout event stream error: {err:?}"))
1490            }
1491        }
1492    }
1493}
1494
1495/// No event is available right now or the event stream has been closed.
1496#[derive(Debug)]
1497pub enum TryRecvError {
1498    /// No new event is available right now.
1499    Empty,
1500    /// The event stream has been closed.
1501    Closed,
1502}
1503
1504/// Convert a zenoh `ZBytes` payload into an Arrow array without copying
1505/// for contiguous buffers (e.g. Zenoh SHM).
1506///
1507/// For `Cow::Borrowed` payloads (SHM), the Arrow `Buffer` is backed by
1508/// the original `ZBytes` allocation via `Buffer::from_custom_allocation`,
1509/// achieving true zero-copy. For `Cow::Owned` (normal network path),
1510/// copy into Dora's aligned buffer type before reconstructing Arrow arrays.
1511/// Newtype that owns a Zenoh [`ZBytes`](zenoh::bytes::ZBytes) payload so it can
1512/// back an Arrow `Buffer` via `Buffer::from_custom_allocation`. Keeping the
1513/// `ZBytes` alive keeps the underlying SHM mapping (or heap buffer) valid for
1514/// the lifetime of the zero-copy Arrow buffer.
1515#[allow(dead_code)] // field kept alive to own the zenoh buffer
1516struct ZBytesAllocation(zenoh::bytes::ZBytes);
1517// SAFETY: the wrapped `ZBytes` is only used to keep the backing allocation
1518// alive; the bytes are treated as immutable for the Buffer's lifetime.
1519unsafe impl Sync for ZBytesAllocation {}
1520unsafe impl Send for ZBytesAllocation {}
1521impl std::panic::RefUnwindSafe for ZBytesAllocation {}
1522
1523/// Convert a zenoh payload to an Arrow array (dora-rs/adora#132).
1524///
1525/// Every data-plane payload is a self-describing Arrow IPC stream, so the
1526/// decode needs no type sidecar. An empty payload is a metadata-only message
1527/// and maps to the unit array.
1528/// Wrap a zenoh payload as an Arrow `Buffer` — aliasing the zenoh SHM mapping
1529/// for borrowed payloads (zero-copy), owning the materialized `Vec` otherwise.
1530fn zenoh_payload_to_buffer(payload: zenoh::bytes::ZBytes) -> arrow::buffer::Buffer {
1531    use std::ptr::NonNull;
1532    match payload.to_bytes() {
1533        std::borrow::Cow::Borrowed(slice) => {
1534            let ptr =
1535                NonNull::new(slice.as_ptr() as *mut u8).expect("zenoh SHM payload ptr is null");
1536            let len = slice.len();
1537            // SAFETY: `ptr` points into the SHM region owned by `payload`;
1538            // moving `payload` into the Arc keeps the region mapped for the
1539            // lifetime of the Buffer.
1540            unsafe {
1541                arrow::buffer::Buffer::from_custom_allocation(
1542                    ptr,
1543                    len,
1544                    Arc::new(ZBytesAllocation(payload)),
1545                )
1546            }
1547        }
1548        std::borrow::Cow::Owned(vec) => arrow::buffer::Buffer::from_vec(vec),
1549    }
1550}
1551
1552/// Declare the `@schema` AdvancedSubscriber for an input: its callback primes
1553/// `decoder` from the schema published on the output's schema subtopic. The
1554/// history query fetches the cached schema on join; `detect_late_publishers`
1555/// re-queries a producer that appears after this subscriber.
1556///
1557/// On failure, `schema_plane_failed` is set so the data subscriber surfaces a
1558/// `FatalError` if the input stays undecodable past
1559/// [`SCHEMA_PLANE_FATAL_GRACE`]: a failed declare means a degraded zenoh
1560/// session, so exit loudly rather than limp along on the in-band full-stream
1561/// refresh alone — but give that refresh (which fully heals the input) its
1562/// chance first instead of killing a node that would recover within seconds.
1563/// It never blocks the data subscriber.
1564#[allow(clippy::too_many_arguments)]
1565fn declare_schema_subscriber(
1566    session: &zenoh::Session,
1567    dataflow_id: DataflowId,
1568    source_node: &NodeId,
1569    source_output: &DataId,
1570    input_id: &DataId,
1571    decoder: Arc<std::sync::Mutex<crate::arrow_utils::ipc_encode::InputDecoder>>,
1572    tx: tokio::sync::mpsc::Sender<EventItem>,
1573    schema_plane_failed: Arc<std::sync::atomic::AtomicBool>,
1574    out: &mut Vec<zenoh_ext::AdvancedSubscriber<()>>,
1575) {
1576    use zenoh::Wait;
1577    use zenoh_ext::{AdvancedSubscriberBuilderExt, HistoryConfig};
1578
1579    let topic =
1580        dora_core::topics::zenoh_output_schema_topic(dataflow_id, source_node, source_output);
1581    let key = match zenoh::key_expr::KeyExpr::new(topic) {
1582        Ok(k) => k.into_owned(),
1583        Err(e) => {
1584            tracing::warn!(input = %input_id, "invalid @schema zenoh key ({e}); schema-once disabled for this input");
1585            schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1586            return;
1587        }
1588    };
1589    let input_id_cb = input_id.clone();
1590    let sub = session
1591        .declare_subscriber(key)
1592        .history(HistoryConfig::default().detect_late_publishers())
1593        .callback(move |sample| {
1594            // catch_unwind: a panic must not unwind through zenoh's IO worker.
1595            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1596                let buffer = zenoh_payload_to_buffer(sample.payload().clone());
1597                let hash = crate::node::fnv1a(buffer.as_slice());
1598                let mut decoder = decoder.lock().unwrap_or_else(|poison| {
1599                    let mut guard = poison.into_inner();
1600                    guard.reset();
1601                    guard
1602                });
1603                if let Err(e) = decoder.set_schema_raw(hash, buffer) {
1604                    tracing::warn!(input = %input_id_cb, "failed to prime decoder from @schema sample: {e}");
1605                }
1606            }));
1607            if result.is_err() {
1608                tracing::error!(input = %input_id_cb, "zenoh @schema subscriber callback panicked");
1609                let _ = tx.try_send(EventItem::FatalError(eyre!(
1610                    "zenoh @schema subscriber for input `{input_id_cb}` panicked"
1611                )));
1612            }
1613        })
1614        .wait();
1615    match sub {
1616        Ok(s) => out.push(s),
1617        Err(e) => {
1618            tracing::warn!(input = %input_id, "failed to declare @schema subscriber ({e}); schema-once disabled for this input");
1619            schema_plane_failed.store(true, std::sync::atomic::Ordering::Relaxed);
1620        }
1621    }
1622}
1623
1624/// How long a schema-once input may stay continuously undecodable — with the
1625/// `@schema` plane dead — before the node exits with a `FatalError`. Three
1626/// producer full-stream refresh intervals: the refresh re-primes the input
1627/// in-band, so if it hasn't healed after three periods the input is genuinely
1628/// dead (producer gone or data plane dropping every refresh), not just waiting
1629/// out the documented recovery window.
1630const SCHEMA_PLANE_FATAL_GRACE: Duration =
1631    crate::node::SCHEMA_ONCE_REFRESH_INTERVAL.saturating_mul(3);
1632
1633/// Whether the undecodable-input condition has persisted past
1634/// [`SCHEMA_PLANE_FATAL_GRACE`]. Records the start of the window on first call;
1635/// the caller clears `first_undecodable` on any successful decode.
1636fn schema_plane_fatal_due(first_undecodable: &mut Option<Instant>, now: Instant) -> bool {
1637    let start = *first_undecodable.get_or_insert(now);
1638    now.duration_since(start) >= SCHEMA_PLANE_FATAL_GRACE
1639}
1640
1641/// Decode a zenoh data sample in receipt order. A schema-less batch (the
1642/// `SCHEMA_HASH` parameter is present) decodes against the per-input decoder
1643/// primed from the output's `@schema` subtopic or in-band from an earlier full
1644/// stream — returning `Ok(None)` if that schema hasn't been received yet, in
1645/// which case the caller drops it. Other messages (large/SHM full streams,
1646/// daemon-path payloads) decode standalone — and additionally prime the decoder
1647/// in-band (see [`prime_in_band`]).
1648fn decode_zenoh_sample(
1649    decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1650    metadata: &dora_message::metadata::Metadata,
1651    payload: zenoh::bytes::ZBytes,
1652) -> eyre::Result<Option<arrow::array::ArrayData>> {
1653    use crate::arrow_utils::decode_arrow_ipc_zero_copy_raw;
1654    use dora_message::metadata::{SCHEMA_HASH, get_integer_param};
1655
1656    if payload.is_empty() {
1657        return Ok(Some(
1658            dora_arrow_convert::internal::into_array_ref(().into_arrow()).to_data(),
1659        ));
1660    }
1661    let buffer = zenoh_payload_to_buffer(payload);
1662    match get_integer_param(&metadata.parameters, SCHEMA_HASH) {
1663        Some(hash) => {
1664            tracing::debug!("received schema-less batch with SCHEMA_HASH={}", hash);
1665            decoder.decode_batch_raw(buffer, hash as u64)
1666        }
1667        None => {
1668            tracing::debug!("received full IPC stream (no SCHEMA_HASH)");
1669            // Service/action messages are excluded from schema-once (a server
1670            // multiplexes per-request schemas through one output); don't let
1671            // them churn the retained schema set.
1672            if !crate::node::carries_pattern_correlation(&metadata.parameters) {
1673                prime_in_band(decoder, &buffer);
1674            }
1675            decode_arrow_ipc_zero_copy_raw(buffer).map(Some)
1676        }
1677    }
1678}
1679
1680/// Prime the per-input decoder from the schema block of a full self-describing
1681/// stream received on the data topic.
1682///
1683/// The producer sends a full stream for the first message of an output (and
1684/// after every schema change, failed `@schema` publish, or periodic refresh —
1685/// see `publish_schema_once`). Since all data-plane puts share one publisher,
1686/// zenoh delivers them in order, so this priming happens strictly before the
1687/// schema-less batches that reference the schema — closing the QoS race where
1688/// an express batch overtakes the `@schema` plane's non-express schema, and
1689/// re-priming (within the refresh interval) any consumer whose `@schema`
1690/// history query missed the single schema emission.
1691fn prime_in_band(
1692    decoder: &mut crate::arrow_utils::ipc_encode::InputDecoder,
1693    buffer: &arrow::buffer::Buffer,
1694) {
1695    let Some((hash, schema)) =
1696        crate::arrow_utils::ipc_encode::schema_block_and_hash(buffer.as_slice())
1697    else {
1698        return;
1699    };
1700    // A known schema (live or retained) needs no eager re-prime: the full
1701    // stream decodes standalone, and `decode_batch` re-primes lazily from the
1702    // retained set when a schema-less batch actually references it.
1703    if decoder.knows_schema(hash) {
1704        return;
1705    }
1706    // Copy the schema block out of the payload: retaining a slice of an
1707    // SHM-backed buffer would pin the whole segment for the decoder's lifetime.
1708    let schema = arrow::buffer::Buffer::from(schema);
1709    if let Err(e) = decoder.set_schema_raw(hash, schema) {
1710        tracing::debug!("in-band schema priming failed: {e}");
1711    }
1712}
1713
1714/// Convert an optional [`DataMessage`] into an Arrow array, or return an
1715/// empty null array when no data is present.
1716pub fn data_to_arrow_array(
1717    data: Option<DataMessage>,
1718) -> eyre::Result<Arc<dyn arrow::array::Array>> {
1719    // `DataMessage` has a single infallible variant, so the conversion cannot
1720    // fail; the only fallible step is `into_arrow_array`.
1721    let raw_data = data.map_or(RawData::Empty, |DataMessage::Vec(v)| RawData::Vec(v));
1722    raw_data.into_arrow_array().map(arrow::array::make_array)
1723}
1724
1725impl Stream for EventStream {
1726    type Item = Event;
1727
1728    fn poll_next(
1729        mut self: std::pin::Pin<&mut Self>,
1730        cx: &mut std::task::Context<'_>,
1731    ) -> std::task::Poll<Option<Self::Item>> {
1732        // Drain events that were buffered by pattern-aware helpers
1733        // (`recv_service_response`, `recv_action_result`) before
1734        // polling the underlying receiver. Mirrors the drain at the
1735        // top of `recv_async` so `StreamExt::next()` and `recv()`
1736        // return the same events in the same order
1737        // (dora-rs/adora#172).
1738        if let Some(event) = self.pending_passthrough.pop_front() {
1739            return std::task::Poll::Ready(Some(event));
1740        }
1741
1742        // Close the stream after a Stop event: zenoh subscriber threads
1743        // hold sender clones that would otherwise keep `receiver` open.
1744        if self.stop_received {
1745            return std::task::Poll::Ready(None);
1746        }
1747
1748        let poll = self
1749            .receiver
1750            .poll_recv(cx)
1751            .map(|item| item.map(Self::convert_event_item));
1752
1753        // Mirror recv_async(): run the first-message type check and stop
1754        // tracking on the Stream path too, via the shared helper so the two
1755        // paths stay in lockstep (dora-rs/adora#172, #174).
1756        if let std::task::Poll::Ready(Some(ref event)) = poll {
1757            self.note_produced_event(event);
1758        }
1759        poll
1760    }
1761}
1762
1763impl Drop for EventStream {
1764    fn drop(&mut self) {
1765        // Tear down the per-input zenoh callback subscribers under a deadline.
1766        // `Subscriber::drop` undeclares the subscription on the shared zenoh
1767        // session, which blocks indefinitely when zenoh's net runtime is
1768        // wedged (e.g. stuck retrying an unreachable scouted peer). Left
1769        // unbounded, that hangs the whole node in `EventStream`'s field-drop
1770        // sequence — before `DoraNode::Drop` (which already bounds its own
1771        // session teardown) even runs — so the daemon never sees the node
1772        // finish and the dataflow stalls until an outer timeout (dora-rs/dora#2425).
1773        // The callbacks use `try_send`, so undeclaring before `receiver` drops
1774        // cannot deadlock on a blocked callback.
1775        //
1776        // The `@schema` `AdvancedSubscriber`s (added with the Arrow IPC data
1777        // plane in #2366) undeclare on the same shared session and can wedge
1778        // the same way, so they must be torn down under the same deadline —
1779        // mirroring `DoraNode::Drop`, which drops both publisher maps inside
1780        // one guard. Left out, they would otherwise drop unbounded in the
1781        // implicit field-drop phase after this `Drop` body returns (#2583).
1782        let subscribers = std::mem::take(&mut self._zenoh_subscribers);
1783        let schema_subscribers = std::mem::take(&mut self._zenoh_schema_subscribers);
1784        let startup_acker = self.startup_acker.take();
1785        if !subscribers.is_empty() || !schema_subscribers.is_empty() || startup_acker.is_some() {
1786            let completed =
1787                teardown_with_timeout("zenoh-subscribers", ZENOH_TEARDOWN_TIMEOUT, move || {
1788                    drop(subscribers);
1789                    drop(schema_subscribers);
1790                    // Dropping the subscribers dropped their callbacks — the
1791                    // only senders into the acker's queue — so the acker
1792                    // thread exits (undeclaring its ack publishers, also
1793                    // bounded by this deadline) and can be joined.
1794                    if let Some(handle) = startup_acker {
1795                        let _ = handle.join();
1796                    }
1797                });
1798            if !completed {
1799                tracing::warn!(
1800                    "zenoh subscriber teardown timed out after {}s; continuing node shutdown",
1801                    ZENOH_TEARDOWN_TIMEOUT.as_secs()
1802                );
1803            }
1804        }
1805
1806        let request = Timestamped {
1807            inner: DaemonRequest::EventStreamDropped,
1808            timestamp: self.clock.new_timestamp(),
1809        };
1810        // Interrupt a testing-daemon `next_event` sleep before the blocking
1811        // close handshake so Drop cannot deadlock (dora-rs/dora#2855).
1812        if let Some(shutdown) = &self.testing_shutdown {
1813            shutdown.store(true, Ordering::Relaxed);
1814        }
1815        let result = self
1816            .close_channel
1817            .request(&request)
1818            .map_err(|e| eyre!(e))
1819            .wrap_err("failed to signal event stream closure to dora-daemon")
1820            .and_then(|r| match r {
1821                DaemonReply::Result(Ok(())) => Ok(()),
1822                DaemonReply::Result(Err(err)) => Err(eyre!("EventStreamClosed failed: {err}")),
1823                other => Err(eyre!("unexpected EventStreamClosed reply: {other:?}")),
1824            });
1825        if let Err(err) = result {
1826            tracing::warn!("{err:?}")
1827        }
1828
1829        if let Some(write_events_to) = self.write_events_to.take()
1830            && let Err(err) = write_events_to.write_out()
1831        {
1832            tracing::warn!(
1833                "failed to write out events for node {}: {err:?}",
1834                self.node_id
1835            );
1836        }
1837    }
1838}
1839
1840pub(crate) struct WriteEventsTo {
1841    node_id: NodeId,
1842    file: std::fs::File,
1843    events_buffer: Vec<serde_json::Value>,
1844    /// `None` while the recording is complete. Becomes `Some(...)` on
1845    /// the first `record_event` failure; subsequent failures bump the
1846    /// counter inside. Surfaced in `write_out()` as a top-level
1847    /// `recording_status` field so consumers (replay tools, audit
1848    /// pipelines) can detect partial recordings instead of silently
1849    /// treating a syntactically-valid file as complete (#1857).
1850    poisoned: Option<PoisonInfo>,
1851}
1852
1853#[derive(Debug)]
1854pub(crate) struct PoisonInfo {
1855    /// `events_buffer.len()` at the moment of the first failure — i.e.
1856    /// the number of events successfully recorded before the gap.
1857    first_failure_event_index: usize,
1858    /// Seconds since `EventStream::start_timestamp` at the first failure.
1859    first_failure_time_offset_secs: f64,
1860    /// `format!("{err:?}")` of the first `record_event()` error.
1861    first_failure_error: String,
1862    /// Count of subsequent failures after the first one.
1863    additional_failures: u64,
1864}
1865
1866impl WriteEventsTo {
1867    /// Mark the recording poisoned. First call captures the failure
1868    /// detail; later calls just bump `additional_failures`.
1869    fn mark_poisoned(&mut self, err: &eyre::Report, time_offset_secs: f64) {
1870        match &mut self.poisoned {
1871            None => {
1872                self.poisoned = Some(PoisonInfo {
1873                    first_failure_event_index: self.events_buffer.len(),
1874                    first_failure_time_offset_secs: time_offset_secs,
1875                    first_failure_error: format!("{err:?}"),
1876                    additional_failures: 0,
1877                });
1878            }
1879            Some(info) => {
1880                info.additional_failures += 1;
1881            }
1882        }
1883    }
1884
1885    fn write_out(self) -> eyre::Result<()> {
1886        use dora_message::integration_testing_format::RecordingStatus;
1887
1888        let Self {
1889            node_id,
1890            file,
1891            events_buffer,
1892            poisoned,
1893        } = self;
1894        let mut inputs_file = serde_json::Map::new();
1895        inputs_file.insert("id".into(), node_id.to_string().into());
1896        // Emit `recording_status` for clean recordings too, so consumers
1897        // can rely on its presence as a definitive signal rather than
1898        // having to treat "field absent" as ambiguous between "clean"
1899        // and "older format" (#1857). Serialized via the canonical
1900        // `RecordingStatus` enum in `dora-message` so the wire format
1901        // stays in lockstep with the consumer-side type. The wire
1902        // shape is unaffected by `IntegrationTestInput`'s
1903        // `Option<Box<RecordingStatus>>` storage choice — serde
1904        // transparently serializes through the `Box`.
1905        let recording_status = match poisoned {
1906            None => RecordingStatus::Clean,
1907            Some(info) => RecordingStatus::Poisoned {
1908                first_failure_event_index: info.first_failure_event_index,
1909                first_failure_time_offset_secs: info.first_failure_time_offset_secs,
1910                first_failure_error: info.first_failure_error,
1911                additional_failures: info.additional_failures,
1912            },
1913        };
1914        inputs_file.insert(
1915            "recording_status".into(),
1916            serde_json::to_value(&recording_status)
1917                .context("failed to serialize recording_status")?,
1918        );
1919        inputs_file.insert("events".into(), events_buffer.into());
1920
1921        serde_json::to_writer_pretty(file, &inputs_file)
1922            .context("failed to write events to file")?;
1923        Ok(())
1924    }
1925}
1926
1927#[cfg(test)]
1928impl EventStream {
1929    /// Test-only: inject an event into the passthrough buffer so we can
1930    /// verify that `is_empty`, `recv_async`, and `Stream::poll_next` all
1931    /// drain it correctly (dora-rs/adora#172).
1932    fn push_passthrough_for_testing(&mut self, event: Event) {
1933        self.pending_passthrough.push_back(event);
1934    }
1935
1936    /// Test-only: buffer an empty input directly in the scheduler and force
1937    /// scheduler mode, simulating an input the scheduler held back while
1938    /// prioritizing `Stop`. Used to verify `recv_async` drains buffered inputs
1939    /// after `Stop` instead of dropping them (dora-rs/dora#2027).
1940    fn push_scheduler_input_for_testing(&mut self, id: &str) {
1941        use crate::event_stream::thread::EventItem;
1942        use dora_message::{daemon_to_node::NodeEvent, metadata::Metadata};
1943        self.use_scheduler = true;
1944        let meta = Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
1945        self.scheduler.add_event(EventItem::NodeEvent {
1946            event: NodeEvent::Input {
1947                id: id.into(),
1948                metadata: std::sync::Arc::new(meta),
1949                data: None,
1950            },
1951        });
1952    }
1953
1954    /// Test-only: buffer a `Stop` directly in the scheduler (a NON_INPUT_EVENT)
1955    /// and force scheduler mode, to verify the post-Stop drain discards trailing
1956    /// control events instead of re-delivering a second `Stop` (dora-rs/dora#2027).
1957    fn push_scheduler_stop_for_testing(&mut self) {
1958        use crate::event_stream::thread::EventItem;
1959        use dora_message::daemon_to_node::NodeEvent;
1960        self.use_scheduler = true;
1961        self.scheduler.add_event(EventItem::NodeEvent {
1962            event: NodeEvent::Stop,
1963        });
1964    }
1965}
1966
1967#[cfg(test)]
1968mod tests {
1969    use super::*;
1970
1971    #[test]
1972    fn control_event_json_shape_and_key_order() {
1973        let clock = uhlc::HLC::default();
1974        let start = clock.new_timestamp();
1975
1976        // An id-bearing control event: keys in `type`, `id`, `time_offset_secs`
1977        // order (serde_json's `preserve_order` makes the order observable).
1978        let with_id = control_event_json(&clock, &start, "InputClosed", Some("cam".to_owned()));
1979        let obj = with_id.as_object().expect("object");
1980        assert_eq!(
1981            obj.keys().collect::<Vec<_>>(),
1982            vec!["type", "id", "time_offset_secs"]
1983        );
1984        assert_eq!(obj["type"], serde_json::json!("InputClosed"));
1985        assert_eq!(obj["id"], serde_json::json!("cam"));
1986        assert!(obj["time_offset_secs"].is_f64());
1987
1988        // A control event without an id omits the `id` field entirely.
1989        let without_id = control_event_json(&clock, &start, "AllInputsClosed", None);
1990        let obj = without_id.as_object().expect("object");
1991        assert_eq!(
1992            obj.keys().collect::<Vec<_>>(),
1993            vec!["type", "time_offset_secs"]
1994        );
1995        assert_eq!(obj["type"], serde_json::json!("AllInputsClosed"));
1996    }
1997
1998    #[test]
1999    fn convert_param_update() {
2000        let item = EventItem::NodeEvent {
2001            event: NodeEvent::ParamUpdate {
2002                key: "fps".into(),
2003                value_json: serde_json::to_vec(&serde_json::json!(60)).unwrap(),
2004            },
2005        };
2006        let event = EventStream::convert_event_item(item);
2007        match event {
2008            Event::ParamUpdate { key, value } => {
2009                assert_eq!(key, "fps");
2010                assert_eq!(value, serde_json::json!(60));
2011            }
2012            other => panic!("expected ParamUpdate, got {other:?}"),
2013        }
2014    }
2015
2016    /// Regression test for the daemon↔node wire protocol: `NodeEvent`
2017    /// is sent over TCP with postcard, so any field type that uses
2018    /// `Deserializer::deserialize_any` (like `serde_json::Value`)
2019    /// breaks the channel and kills the node at the next receive.
2020    /// `NodeEvent::ParamUpdate` carries its value as JSON-encoded
2021    /// bytes for that reason. This test pins the invariant so we
2022    /// don't regress back to a `deserialize_any` field.
2023    #[test]
2024    fn node_event_param_update_round_trips_through_postcard() {
2025        let cases = [
2026            serde_json::json!(42),
2027            serde_json::json!(1.5),
2028            serde_json::json!("hello"),
2029            serde_json::json!(null),
2030            serde_json::json!([1, 2, 3]),
2031            serde_json::json!({"nested": {"array": [true, false]}}),
2032        ];
2033        for value in cases {
2034            let event = NodeEvent::ParamUpdate {
2035                key: "rate".into(),
2036                value_json: serde_json::to_vec(&value).unwrap(),
2037            };
2038            let bytes = dora_message::encode(&event).expect("serialize");
2039            let back: NodeEvent = dora_message::decode(&bytes).expect("deserialize");
2040            match back {
2041                NodeEvent::ParamUpdate { key, value_json } => {
2042                    assert_eq!(key, "rate");
2043                    let decoded: serde_json::Value =
2044                        serde_json::from_slice(&value_json).expect("value_json is JSON");
2045                    assert_eq!(decoded, value);
2046                }
2047                other => panic!("expected ParamUpdate, got {other:?}"),
2048            }
2049        }
2050    }
2051
2052    // -- WriteEventsTo poisoned-state tests (#1857) ------------------------
2053    //
2054    // Build a `WriteEventsTo` against a tempfile, exercise the public
2055    // surface (push events / mark_poisoned / write_out), then parse the
2056    // resulting JSON and assert on the `recording_status` field shape.
2057    // No new dev-deps — uses std::env::temp_dir() + uuid (already a dep).
2058
2059    fn write_events_to_with_tempfile() -> (WriteEventsTo, std::path::PathBuf) {
2060        let path = std::env::temp_dir().join(format!(
2061            "dora-write-events-test-{}.json",
2062            uuid::Uuid::new_v4()
2063        ));
2064        let file = std::fs::File::create(&path).expect("create tempfile");
2065        let w = WriteEventsTo {
2066            node_id: "test-node".parse().unwrap(),
2067            file,
2068            events_buffer: Vec::new(),
2069            poisoned: None,
2070        };
2071        (w, path)
2072    }
2073
2074    fn read_back(path: &std::path::Path) -> serde_json::Value {
2075        let s = std::fs::read_to_string(path).expect("read back tempfile");
2076        std::fs::remove_file(path).ok();
2077        serde_json::from_str(&s).expect("output is valid JSON")
2078    }
2079
2080    #[test]
2081    fn write_events_clean_recording_emits_state_clean() {
2082        let (mut w, path) = write_events_to_with_tempfile();
2083        w.events_buffer.push(serde_json::json!({"type": "Stop"}));
2084        w.write_out().expect("write_out clean recording");
2085
2086        let v = read_back(&path);
2087        assert_eq!(v["recording_status"]["state"], "clean");
2088        assert_eq!(v["events"].as_array().unwrap().len(), 1);
2089        assert_eq!(v["id"], "test-node");
2090    }
2091
2092    #[test]
2093    fn write_events_poisoned_recording_emits_state_poisoned_with_first_failure() {
2094        let (mut w, path) = write_events_to_with_tempfile();
2095        // 2 events recorded cleanly, then a failure, then 1 more event
2096        w.events_buffer.push(serde_json::json!({"type": "Input"}));
2097        w.events_buffer.push(serde_json::json!({"type": "Input"}));
2098        w.mark_poisoned(&eyre!("arrow conversion failed: bad type"), 1.5);
2099        w.events_buffer.push(serde_json::json!({"type": "Stop"}));
2100        w.write_out().expect("write_out poisoned recording");
2101
2102        let v = read_back(&path);
2103        let status = &v["recording_status"];
2104        assert_eq!(status["state"], "poisoned");
2105        assert_eq!(status["first_failure_event_index"], 2);
2106        assert_eq!(status["first_failure_time_offset_secs"], 1.5);
2107        assert!(
2108            status["first_failure_error"]
2109                .as_str()
2110                .unwrap()
2111                .contains("arrow conversion failed: bad type")
2112        );
2113        assert_eq!(status["additional_failures"], 0);
2114        // `events` keeps the 2 clean + 1 post-failure-but-successful events.
2115        assert_eq!(v["events"].as_array().unwrap().len(), 3);
2116    }
2117
2118    #[test]
2119    fn write_events_multiple_failures_keep_first_and_count_rest() {
2120        let (mut w, path) = write_events_to_with_tempfile();
2121        w.mark_poisoned(&eyre!("first error"), 0.5);
2122        w.mark_poisoned(&eyre!("second error"), 1.0);
2123        w.mark_poisoned(&eyre!("third error"), 1.5);
2124        w.write_out().expect("write_out with multiple failures");
2125
2126        let v = read_back(&path);
2127        let status = &v["recording_status"];
2128        assert_eq!(status["state"], "poisoned");
2129        // First failure detail is preserved, NOT overwritten by later ones.
2130        assert_eq!(status["first_failure_event_index"], 0);
2131        assert_eq!(status["first_failure_time_offset_secs"], 0.5);
2132        assert!(
2133            status["first_failure_error"]
2134                .as_str()
2135                .unwrap()
2136                .contains("first error")
2137        );
2138        // Two additional failures after the first.
2139        assert_eq!(status["additional_failures"], 2);
2140    }
2141
2142    #[test]
2143    fn convert_param_deleted() {
2144        let item = EventItem::NodeEvent {
2145            event: NodeEvent::ParamDeleted { key: "fps".into() },
2146        };
2147        let event = EventStream::convert_event_item(item);
2148        match event {
2149            Event::ParamDeleted { key } => {
2150                assert_eq!(key, "fps");
2151            }
2152            other => panic!("expected ParamDeleted, got {other:?}"),
2153        }
2154    }
2155
2156    #[test]
2157    fn convert_stop_event() {
2158        let item = EventItem::NodeEvent {
2159            event: NodeEvent::Stop,
2160        };
2161        let event = EventStream::convert_event_item(item);
2162        assert!(matches!(event, Event::Stop(StopCause::Manual)));
2163    }
2164
2165    #[test]
2166    fn convert_all_inputs_closed() {
2167        let item = EventItem::NodeEvent {
2168            event: NodeEvent::AllInputsClosed,
2169        };
2170        let event = EventStream::convert_event_item(item);
2171        assert!(matches!(event, Event::Stop(StopCause::AllInputsClosed)));
2172    }
2173
2174    #[test]
2175    fn convert_input_closed() {
2176        let item = EventItem::NodeEvent {
2177            event: NodeEvent::InputClosed {
2178                id: "input_1".to_string().into(),
2179            },
2180        };
2181        let event = EventStream::convert_event_item(item);
2182        match event {
2183            Event::InputClosed { id } => assert_eq!(AsRef::<str>::as_ref(&id), "input_1"),
2184            other => panic!("expected InputClosed, got {other:?}"),
2185        }
2186    }
2187
2188    #[test]
2189    fn convert_node_restarted() {
2190        let item = EventItem::NodeEvent {
2191            event: NodeEvent::NodeRestarted {
2192                id: "upstream".to_string().into(),
2193            },
2194        };
2195        let event = EventStream::convert_event_item(item);
2196        match event {
2197            Event::NodeRestarted { id } => assert_eq!(AsRef::<str>::as_ref(&id), "upstream"),
2198            other => panic!("expected NodeRestarted, got {other:?}"),
2199        }
2200    }
2201
2202    // ---- dora-rs/adora#148: pattern-aware correlation classification ----
2203
2204    use arrow::array::new_empty_array;
2205    use arrow::datatypes::DataType as ArrowDataType;
2206    use dora_arrow_convert::internal::from_array_ref;
2207    use dora_message::metadata::{
2208        GOAL_ID, GOAL_STATUS, GOAL_STATUS_ABORTED, GOAL_STATUS_SUCCEEDED, Metadata,
2209        MetadataParameters, Parameter, REQUEST_ID,
2210    };
2211
2212    fn make_metadata(params: MetadataParameters) -> Metadata {
2213        Metadata::from_parameters(dora_core::uhlc::HLC::default().new_timestamp(), params)
2214    }
2215
2216    fn make_input_event(id: &str, params: MetadataParameters) -> Event {
2217        Event::Input {
2218            id: id.into(),
2219            metadata: make_metadata(params),
2220            data: from_array_ref(new_empty_array(&ArrowDataType::Null)),
2221        }
2222    }
2223
2224    fn request_id_params(id: &str) -> MetadataParameters {
2225        let mut p = MetadataParameters::new();
2226        p.insert(REQUEST_ID.into(), Parameter::String(id.to_string()));
2227        p
2228    }
2229
2230    fn goal_params(goal_id: &str, status: Option<&str>) -> MetadataParameters {
2231        let mut p = MetadataParameters::new();
2232        p.insert(GOAL_ID.into(), Parameter::String(goal_id.to_string()));
2233        if let Some(s) = status {
2234            p.insert(GOAL_STATUS.into(), Parameter::String(s.to_string()));
2235        }
2236        p
2237    }
2238
2239    fn is_request_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2240        move |event: &Event| match event {
2241            Event::Input { metadata, .. } => {
2242                dora_message::metadata::get_string_param(&metadata.parameters, REQUEST_ID)
2243                    == Some(needle)
2244            }
2245            _ => false,
2246        }
2247    }
2248
2249    fn is_action_result_match(needle: &str) -> impl Fn(&Event) -> bool + '_ {
2250        move |event: &Event| match event {
2251            Event::Input { metadata, .. } => {
2252                let p = &metadata.parameters;
2253                dora_message::metadata::get_string_param(p, GOAL_ID) == Some(needle)
2254                    && matches!(
2255                        dora_message::metadata::get_string_param(p, GOAL_STATUS),
2256                        Some(GOAL_STATUS_SUCCEEDED)
2257                            | Some(GOAL_STATUS_ABORTED)
2258                            | Some(dora_message::metadata::GOAL_STATUS_CANCELED)
2259                    )
2260            }
2261            _ => false,
2262        }
2263    }
2264
2265    #[test]
2266    fn classify_matching_request_id_returns_match() {
2267        let server = NodeId::from("calc".to_string());
2268        let event = make_input_event("response", request_id_params("req-42"));
2269        assert_eq!(
2270            classify_correlation_event(&event, &server, is_request_match("req-42")),
2271            CorrelationOutcome::Match
2272        );
2273    }
2274
2275    #[test]
2276    fn classify_different_request_id_is_passthrough() {
2277        let server = NodeId::from("calc".to_string());
2278        let event = make_input_event("response", request_id_params("req-99"));
2279        assert_eq!(
2280            classify_correlation_event(&event, &server, is_request_match("req-42")),
2281            CorrelationOutcome::Passthrough
2282        );
2283    }
2284
2285    #[test]
2286    fn classify_expected_server_restart_returns_server_restarted() {
2287        let server = NodeId::from("calc".to_string());
2288        let event = Event::NodeRestarted { id: server.clone() };
2289        assert_eq!(
2290            classify_correlation_event(&event, &server, is_request_match("req-42")),
2291            CorrelationOutcome::ServerRestarted
2292        );
2293    }
2294
2295    #[test]
2296    fn classify_unrelated_node_restart_is_passthrough() {
2297        let server = NodeId::from("calc".to_string());
2298        let event = Event::NodeRestarted {
2299            id: NodeId::from("other".to_string()),
2300        };
2301        assert_eq!(
2302            classify_correlation_event(&event, &server, is_request_match("req-42")),
2303            CorrelationOutcome::Passthrough
2304        );
2305    }
2306
2307    #[test]
2308    fn classify_stop_returns_stream_ended() {
2309        let server = NodeId::from("calc".to_string());
2310        let event = Event::Stop(StopCause::Manual);
2311        assert_eq!(
2312            classify_correlation_event(&event, &server, is_request_match("req-42")),
2313            CorrelationOutcome::StreamEnded
2314        );
2315    }
2316
2317    #[test]
2318    fn classify_error_returns_stream_error() {
2319        let server = NodeId::from("calc".to_string());
2320        let event = Event::Error("boom".to_string());
2321        assert_eq!(
2322            classify_correlation_event(&event, &server, is_request_match("req-42")),
2323            CorrelationOutcome::StreamError
2324        );
2325    }
2326
2327    #[test]
2328    fn classify_unrelated_input_is_passthrough() {
2329        let server = NodeId::from("calc".to_string());
2330        let event = make_input_event("sensor", MetadataParameters::new());
2331        assert_eq!(
2332            classify_correlation_event(&event, &server, is_request_match("req-42")),
2333            CorrelationOutcome::Passthrough
2334        );
2335    }
2336
2337    #[test]
2338    fn classify_param_update_is_passthrough() {
2339        // Runtime parameter updates must survive a helper wait.
2340        let server = NodeId::from("calc".to_string());
2341        let event = Event::ParamUpdate {
2342            key: "threshold".to_string(),
2343            value: serde_json::json!(0.85),
2344        };
2345        assert_eq!(
2346            classify_correlation_event(&event, &server, is_request_match("req-42")),
2347            CorrelationOutcome::Passthrough
2348        );
2349    }
2350
2351    #[test]
2352    fn classify_action_result_terminal_succeeded_matches() {
2353        let server = NodeId::from("nav".to_string());
2354        let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_SUCCEEDED)));
2355        assert_eq!(
2356            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2357            CorrelationOutcome::Match
2358        );
2359    }
2360
2361    #[test]
2362    fn classify_action_result_terminal_aborted_matches() {
2363        let server = NodeId::from("nav".to_string());
2364        let event = make_input_event("result", goal_params("goal-1", Some(GOAL_STATUS_ABORTED)));
2365        assert_eq!(
2366            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2367            CorrelationOutcome::Match
2368        );
2369    }
2370
2371    #[test]
2372    fn classify_action_feedback_without_terminal_status_is_passthrough() {
2373        // Intermediate feedback (no terminal goal_status) should pass
2374        // through so the caller's main loop can observe it.
2375        let server = NodeId::from("nav".to_string());
2376        let event = make_input_event("feedback", goal_params("goal-1", None));
2377        assert_eq!(
2378            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2379            CorrelationOutcome::Passthrough
2380        );
2381    }
2382
2383    #[test]
2384    fn classify_action_result_for_different_goal_is_passthrough() {
2385        let server = NodeId::from("nav".to_string());
2386        let event = make_input_event("result", goal_params("goal-2", Some(GOAL_STATUS_SUCCEEDED)));
2387        assert_eq!(
2388            classify_correlation_event(&event, &server, is_action_result_match("goal-1")),
2389            CorrelationOutcome::Passthrough
2390        );
2391    }
2392
2393    // ---- dora-rs/adora#172: pending_passthrough integration ----
2394
2395    use crate::integration_testing::{
2396        IntegrationTestInput, TestingInput, TestingOptions, TestingOutput,
2397        integration_testing_format::{IncomingEvent, TimedIncomingEvent},
2398    };
2399
2400    /// Create a minimal EventStream via the testing path.
2401    fn test_event_stream() -> (crate::DoraNode, EventStream) {
2402        let events = vec![TimedIncomingEvent {
2403            time_offset_secs: 0.0,
2404            event: IncomingEvent::Stop,
2405        }];
2406        let inputs = TestingInput::Input(IntegrationTestInput::new(
2407            "test-node".parse().unwrap(),
2408            events,
2409        ));
2410        let (tx, _rx) = crate::integration_testing::output_channel();
2411        let outputs = TestingOutput::ToChannel(tx);
2412        let options = TestingOptions {
2413            skip_output_time_offsets: true,
2414        };
2415        crate::DoraNode::init_testing(inputs, outputs, options).unwrap()
2416    }
2417
2418    /// #2956: outputs sent through `TestingOutput::ToChannel` must reach the
2419    /// receiver, in order, when drained after the node has finished — the
2420    /// documented usage pattern, and previously untested (every other
2421    /// `ToChannel` test here drops the receiver).
2422    ///
2423    /// Plain `#[test]` on purpose: the testing bridge uses
2424    /// `blocking_send`/`blocking_recv` on the request channel, which panic
2425    /// inside a tokio runtime.
2426    #[test]
2427    fn to_channel_delivers_outputs_in_order() {
2428        use arrow::array::Int32Array;
2429
2430        let events = vec![TimedIncomingEvent {
2431            time_offset_secs: 0.0,
2432            event: IncomingEvent::Stop,
2433        }];
2434        let inputs = TestingInput::Input(IntegrationTestInput::new(
2435            "test-node".parse().unwrap(),
2436            events,
2437        ));
2438        let (tx, mut rx) = crate::integration_testing::output_channel();
2439        let outputs = TestingOutput::ToChannel(tx);
2440        let options = TestingOptions {
2441            skip_output_time_offsets: true,
2442        };
2443        let (mut node, _events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
2444
2445        for i in 0..3 {
2446            node.send_output(
2447                "out".parse().unwrap(),
2448                Default::default(),
2449                dora_arrow_convert::internal::from_array_ref(std::sync::Arc::new(
2450                    Int32Array::from(vec![i]),
2451                )),
2452            )
2453            .unwrap();
2454        }
2455
2456        let received = crate::integration_testing::drain_outputs(&mut rx);
2457
2458        assert_eq!(received.len(), 3, "every sent output should be delivered");
2459        for (i, output) in received.iter().enumerate() {
2460            assert_eq!(output.get("id").and_then(|v| v.as_str()), Some("out"));
2461            assert_eq!(
2462                output.get("data"),
2463                Some(&serde_json::json!([i as i32])),
2464                "outputs should arrive in send order"
2465            );
2466        }
2467    }
2468
2469    #[test]
2470    fn is_empty_reflects_pending_passthrough() {
2471        let (_node, mut events) = test_event_stream();
2472        // Drain the initial Stop event so the stream is empty.
2473        let _ = events.recv();
2474        assert!(events.is_empty(), "should be empty after draining");
2475
2476        // Inject a passthrough event — is_empty must now return false.
2477        events.push_passthrough_for_testing(Event::ParamDeleted {
2478            key: "k".to_string(),
2479        });
2480        assert!(
2481            !events.is_empty(),
2482            "should not be empty with pending passthrough"
2483        );
2484    }
2485
2486    #[test]
2487    fn stream_poll_next_drains_pending_passthrough() {
2488        use futures::StreamExt;
2489        let (_node, mut events) = test_event_stream();
2490        // Drain the initial Stop event.
2491        let _ = events.recv();
2492
2493        // Inject a passthrough event.
2494        events.push_passthrough_for_testing(Event::ParamUpdate {
2495            key: "threshold".to_string(),
2496            value: serde_json::json!(42),
2497        });
2498
2499        // StreamExt::next() should return the passthrough event, not
2500        // block waiting on the underlying receiver.
2501        let next = futures::executor::block_on(events.next());
2502        match next {
2503            Some(Event::ParamUpdate { key, value }) => {
2504                assert_eq!(key, "threshold");
2505                assert_eq!(value, serde_json::json!(42));
2506            }
2507            other => panic!("expected ParamUpdate from passthrough, got {other:?}"),
2508        }
2509    }
2510
2511    #[test]
2512    fn recv_async_drains_pending_passthrough_before_receiver() {
2513        let (_node, mut events) = test_event_stream();
2514
2515        // Inject a passthrough event BEFORE the Stop in the receiver.
2516        events.push_passthrough_for_testing(Event::ParamDeleted {
2517            key: "x".to_string(),
2518        });
2519
2520        // First recv should return the passthrough event.
2521        let first = events.recv();
2522        assert!(
2523            matches!(first, Some(Event::ParamDeleted { .. })),
2524            "expected passthrough ParamDeleted first, got {first:?}"
2525        );
2526
2527        // Second recv should return the Stop from the receiver.
2528        let second = events.recv();
2529        assert!(
2530            matches!(second, Some(Event::Stop(_))),
2531            "expected Stop second, got {second:?}"
2532        );
2533    }
2534
2535    /// Regression: a pattern-aware wait (`recv_service_response`) must make
2536    /// progress when a non-matching event arrives *before* the correlated
2537    /// response.
2538    ///
2539    /// The wait loop buffers every non-matching event into
2540    /// `pending_passthrough` so the caller's own event loop can still see it.
2541    /// Before the fix the loop pumped the stream via `recv_async`, which
2542    /// drains `pending_passthrough` first — so it kept re-serving the buffered
2543    /// non-matching event, re-buffering it, and spinning forever without ever
2544    /// reading the response off the receiver. Every such call pinned a CPU core
2545    /// and returned `Timeout`. The fix pumps via `recv_from_stream`, which
2546    /// bypasses the passthrough buffer.
2547    #[test]
2548    fn recv_service_response_matches_after_non_matching_event() {
2549        // Delivered FIFO (integration tests disable the reordering scheduler):
2550        // the non-matching "sensor" input, then the correlated "response".
2551        let events = vec![
2552            TimedIncomingEvent {
2553                time_offset_secs: 0.0,
2554                event: IncomingEvent::Input {
2555                    id: "sensor".parse().unwrap(),
2556                    metadata: None,
2557                    data: None,
2558                },
2559            },
2560            TimedIncomingEvent {
2561                time_offset_secs: 0.0,
2562                event: IncomingEvent::Input {
2563                    id: "response".parse().unwrap(),
2564                    metadata: Some(request_id_params("req-1")),
2565                    data: None,
2566                },
2567            },
2568            TimedIncomingEvent {
2569                time_offset_secs: 0.0,
2570                event: IncomingEvent::Stop,
2571            },
2572        ];
2573        let inputs = TestingInput::Input(IntegrationTestInput::new(
2574            "test-node".parse().unwrap(),
2575            events,
2576        ));
2577        let (tx, _rx) = crate::integration_testing::output_channel();
2578        let outputs = TestingOutput::ToChannel(tx);
2579        let options = TestingOptions {
2580            skip_output_time_offsets: true,
2581        };
2582        let (_node, mut events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
2583
2584        let server = NodeId::from("calc".to_string());
2585        let response = futures::executor::block_on(events.recv_service_response(
2586            "req-1",
2587            &server,
2588            Duration::from_secs(5),
2589        ));
2590        match response {
2591            Ok(Event::Input { id, .. }) => assert_eq!(id.as_str(), "response"),
2592            other => panic!("expected the correlated response Input, got {other:?}"),
2593        }
2594
2595        // The non-matching "sensor" input must not be lost — it is replayed
2596        // to the caller's own event loop after the wait returns.
2597        let buffered = events.recv();
2598        assert!(
2599            matches!(&buffered, Some(Event::Input { id, .. }) if id.as_str() == "sensor"),
2600            "expected the buffered non-matching 'sensor' input, got {buffered:?}"
2601        );
2602    }
2603
2604    /// Regression: a pattern-aware wait must find its correlated response even
2605    /// when a *previous* wait already buffered it.
2606    ///
2607    /// This is the pipelined / out-of-order case: two requests are in flight,
2608    /// and `req-2`'s response arrives before `req-1`'s. The wait for `req-1`
2609    /// buffers `resp-2` into `pending_passthrough` as non-matching, then
2610    /// returns `resp-1`. The subsequent wait for `req-2` must return the
2611    /// already-buffered `resp-2` — the wait loop pumps `recv_from_stream`,
2612    /// which never reads `pending_passthrough`, so `wait_for_correlation`
2613    /// scans the buffer for a match before reading the stream. Without that
2614    /// scan the buffered response is invisible and the wait wrongly times out.
2615    #[test]
2616    fn recv_service_response_matches_buffered_response_from_prior_wait() {
2617        // Delivered FIFO: `resp-2` arrives before `resp-1`, then `Stop`.
2618        let events = vec![
2619            TimedIncomingEvent {
2620                time_offset_secs: 0.0,
2621                event: IncomingEvent::Input {
2622                    id: "response".parse().unwrap(),
2623                    metadata: Some(request_id_params("req-2")),
2624                    data: None,
2625                },
2626            },
2627            TimedIncomingEvent {
2628                time_offset_secs: 0.0,
2629                event: IncomingEvent::Input {
2630                    id: "response".parse().unwrap(),
2631                    metadata: Some(request_id_params("req-1")),
2632                    data: None,
2633                },
2634            },
2635            TimedIncomingEvent {
2636                time_offset_secs: 0.0,
2637                event: IncomingEvent::Stop,
2638            },
2639        ];
2640        let inputs = TestingInput::Input(IntegrationTestInput::new(
2641            "test-node".parse().unwrap(),
2642            events,
2643        ));
2644        let (tx, _rx) = crate::integration_testing::output_channel();
2645        let outputs = TestingOutput::ToChannel(tx);
2646        let options = TestingOptions {
2647            skip_output_time_offsets: true,
2648        };
2649        let (_node, mut events) = crate::DoraNode::init_testing(inputs, outputs, options).unwrap();
2650
2651        let server = NodeId::from("calc".to_string());
2652        let request_id_of = |event: &Event| match event {
2653            Event::Input { metadata, .. } => dora_message::metadata::get_string_param(
2654                &metadata.parameters,
2655                dora_message::metadata::REQUEST_ID,
2656            )
2657            .map(str::to_owned),
2658            _ => None,
2659        };
2660
2661        // Wait for `req-1`: reads `resp-2` (buffered as non-matching), then
2662        // returns `resp-1`.
2663        let first = futures::executor::block_on(events.recv_service_response(
2664            "req-1",
2665            &server,
2666            Duration::from_secs(5),
2667        ));
2668        match &first {
2669            Ok(event) => assert_eq!(request_id_of(event).as_deref(), Some("req-1")),
2670            other => panic!("expected the req-1 response, got {other:?}"),
2671        }
2672
2673        // Wait for `req-2`: `resp-2` is already in `pending_passthrough`, so
2674        // this must return it from the buffer rather than time out.
2675        let second = futures::executor::block_on(events.recv_service_response(
2676            "req-2",
2677            &server,
2678            Duration::from_secs(5),
2679        ));
2680        match &second {
2681            Ok(event) => assert_eq!(request_id_of(event).as_deref(), Some("req-2")),
2682            other => panic!("expected the buffered req-2 response, got {other:?}"),
2683        }
2684    }
2685
2686    /// After a `Stop` event is delivered, subsequent `recv` calls must
2687    /// return `None` so the node can exit cleanly even when zenoh
2688    /// subscriber threads still hold clones of the event channel
2689    /// sender (which would otherwise keep the receiver open).
2690    #[test]
2691    fn recv_returns_none_after_stop() {
2692        let (_node, mut events) = test_event_stream();
2693
2694        // First recv delivers the seeded Stop.
2695        let first = events.recv();
2696        assert!(matches!(first, Some(Event::Stop(_))));
2697
2698        // Second recv must return None even though the underlying
2699        // receiver may still have live senders.
2700        let second = events.recv();
2701        assert!(
2702            second.is_none(),
2703            "recv must return None after Stop, got {second:?}"
2704        );
2705    }
2706
2707    /// #2027: the scheduler gives `Stop` (a NON_INPUT_EVENT) strict priority
2708    /// over buffered inputs, so an input enqueued before `Stop` is still in the
2709    /// scheduler when `Stop` is delivered. `recv` must drain that input before
2710    /// closing rather than dropping it silently (the previous `return None`
2711    /// after `stop_received` lost it).
2712    #[test]
2713    fn recv_drains_buffered_scheduler_inputs_after_stop() {
2714        let (_node, mut events) = test_event_stream();
2715
2716        // Deliver the seeded Stop (sets `stop_received`).
2717        assert!(matches!(events.recv(), Some(Event::Stop(_))));
2718
2719        // Simulate the input the scheduler held back behind the prioritized Stop.
2720        events.push_scheduler_input_for_testing("cam");
2721
2722        let drained = events.recv();
2723        assert!(
2724            matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
2725            "buffered input must be drained after Stop, got {drained:?}"
2726        );
2727
2728        // Once the scheduler is empty the stream closes.
2729        assert!(
2730            events.recv().is_none(),
2731            "stream must close after draining buffered inputs"
2732        );
2733    }
2734
2735    /// #2027 review (P2): the post-Stop drain must deliver buffered *inputs*
2736    /// only. A non-input control event buffered behind Stop (e.g. a second
2737    /// `Stop`) must NOT be re-delivered to a loop-until-`None` caller.
2738    #[test]
2739    fn recv_after_stop_skips_trailing_control_events() {
2740        let (_node, mut events) = test_event_stream();
2741
2742        // Deliver the seeded Stop (sets `stop_received`).
2743        assert!(matches!(events.recv(), Some(Event::Stop(_))));
2744
2745        // Buffer a trailing Stop AND a real input behind it. The scheduler
2746        // prioritizes the Stop (NON_INPUT), so the drain meets it first.
2747        events.push_scheduler_stop_for_testing();
2748        events.push_scheduler_input_for_testing("cam");
2749
2750        // The drain must skip the trailing Stop and return only the input...
2751        let drained = events.recv();
2752        assert!(
2753            matches!(&drained, Some(Event::Input { id, .. }) if id.as_str() == "cam"),
2754            "expected the buffered input, not a re-delivered Stop, got {drained:?}"
2755        );
2756        // ...then close (no second Stop ever surfaces).
2757        assert!(events.recv().is_none(), "stream must close after the input");
2758    }
2759
2760    /// The zenoh receive path is Arrow-IPC-only. An empty payload is a
2761    /// metadata-only message and maps to the unit array; a non-empty payload is
2762    /// a self-describing IPC stream and round-trips to its original array (with
2763    /// no type sidecar involved).
2764    #[test]
2765    fn zenoh_payload_ipc_roundtrip_and_empty_is_unit() {
2766        use crate::arrow_utils::ipc_encode::{
2767            InputDecoder, encode_ipc_into_data, ipc_fast_path_len_data,
2768        };
2769        use arrow::array::{Array, Int32Array};
2770
2771        // A standalone full stream (no SCHEMA_HASH parameter) decodes directly.
2772        let metadata =
2773            dora_message::metadata::Metadata::new(dora_core::uhlc::HLC::default().new_timestamp());
2774        let mut decoder = InputDecoder::new();
2775
2776        // Empty payload -> unit array (metadata-only message).
2777        let unit = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::new())
2778            .unwrap()
2779            .unwrap();
2780        assert_eq!(
2781            unit.data_type(),
2782            &arrow_schema::DataType::Null,
2783            "empty payload maps to the unit array"
2784        );
2785
2786        // Non-empty IPC payload round-trips to the original array.
2787        let data = Int32Array::from(vec![10, 20, 30]).into_data();
2788        let len = ipc_fast_path_len_data(&data).expect("primitive is fast-path eligible");
2789        let mut buf = vec![0u8; len];
2790        encode_ipc_into_data(&data, &mut buf).unwrap();
2791
2792        let decoded = decode_zenoh_sample(&mut decoder, &metadata, zenoh::bytes::ZBytes::from(buf))
2793            .unwrap()
2794            .unwrap();
2795        assert_eq!(decoded.data_type(), &arrow_schema::DataType::Int32);
2796        assert_eq!(&decoded, &data);
2797    }
2798
2799    /// A full self-describing stream on the data topic must prime the per-input
2800    /// decoder in-band: the schema-less batches that follow decode against it
2801    /// without any `@schema`-plane delivery. This is what makes the producer's
2802    /// "full stream until the schema is confirmed published" strategy race-free
2803    /// — data-plane puts share one publisher, so zenoh preserves their order,
2804    /// while the separate `@schema` plane can lose the race with an express
2805    /// batch (dora-rs/dora#2366 review: first-message QoS race).
2806    #[test]
2807    fn full_stream_primes_decoder_in_band_for_schema_less_batches() {
2808        use crate::arrow_utils::ipc_encode::{
2809            InputDecoder, batch_fast_path_len_data, encode_batch_into_data, encode_ipc_into_data,
2810            ipc_fast_path_len_data, schema_block_len,
2811        };
2812        use arrow::array::{Array, Int32Array};
2813        use dora_message::metadata::{Metadata, Parameter, SCHEMA_HASH};
2814
2815        let hlc = dora_core::uhlc::HLC::default();
2816        let mut decoder = InputDecoder::new();
2817
2818        // Message 1: full stream (no SCHEMA_HASH), as the producer sends while
2819        // the schema is not yet confirmed published on the `@schema` plane.
2820        let first = Int32Array::from(vec![1, 2]).into_data();
2821        let mut full = vec![0u8; ipc_fast_path_len_data(&first).unwrap()];
2822        encode_ipc_into_data(&first, &mut full).unwrap();
2823        let block = schema_block_len(&full).unwrap();
2824        let hash = dora_message::metadata::fnv1a(&full[..block]);
2825
2826        let plain = Metadata::new(hlc.new_timestamp());
2827        let got = decode_zenoh_sample(&mut decoder, &plain, zenoh::bytes::ZBytes::from(full))
2828            .unwrap()
2829            .unwrap();
2830        assert_eq!(&got, &first);
2831
2832        // Message 2: schema-less batch tagged with the schema hash. No
2833        // `set_schema` call happened — decoding must succeed purely from the
2834        // in-band priming above.
2835        let second = Int32Array::from(vec![3]).into_data();
2836        let mut batch = vec![0u8; batch_fast_path_len_data(&second).unwrap()];
2837        encode_batch_into_data(&second, &mut batch).unwrap();
2838        let mut tagged = Metadata::new(hlc.new_timestamp());
2839        tagged
2840            .parameters
2841            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2842
2843        let got = decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
2844            .unwrap()
2845            .expect("schema-less batch must decode against the in-band-primed decoder");
2846        assert_eq!(&got, &second);
2847    }
2848
2849    /// Service/action full streams (pattern-correlated) are excluded from
2850    /// schema-once and must NOT prime the decoder in-band: a server multiplexes
2851    /// per-request schemas through one output, and letting those prime the
2852    /// decoder would churn the retained schema set for no benefit.
2853    #[test]
2854    fn pattern_correlated_full_stream_does_not_prime_in_band() {
2855        use crate::arrow_utils::ipc_encode::{
2856            InputDecoder, batch_fast_path_len_data, encode_batch_into_data, encode_ipc_into_data,
2857            ipc_fast_path_len_data, schema_block_len,
2858        };
2859        use arrow::array::{Array, Int32Array};
2860        use dora_message::metadata::{Metadata, Parameter, REQUEST_ID, SCHEMA_HASH};
2861
2862        let hlc = dora_core::uhlc::HLC::default();
2863        let mut decoder = InputDecoder::new();
2864
2865        let reply = Int32Array::from(vec![7]).into_data();
2866        let mut full = vec![0u8; ipc_fast_path_len_data(&reply).unwrap()];
2867        encode_ipc_into_data(&reply, &mut full).unwrap();
2868        let block = schema_block_len(&full).unwrap();
2869        let hash = dora_message::metadata::fnv1a(&full[..block]);
2870
2871        // A service reply (request_id) decodes standalone…
2872        let mut service = Metadata::new(hlc.new_timestamp());
2873        service
2874            .parameters
2875            .insert(REQUEST_ID.to_string(), Parameter::String("req-1".into()));
2876        let got = decode_zenoh_sample(&mut decoder, &service, zenoh::bytes::ZBytes::from(full))
2877            .unwrap()
2878            .unwrap();
2879        assert_eq!(&got, &reply);
2880
2881        // …but must not have primed the decoder for its schema hash.
2882        let batch_array = Int32Array::from(vec![8]).into_data();
2883        let mut batch = vec![0u8; batch_fast_path_len_data(&batch_array).unwrap()];
2884        encode_batch_into_data(&batch_array, &mut batch).unwrap();
2885        let mut tagged = Metadata::new(hlc.new_timestamp());
2886        tagged
2887            .parameters
2888            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(hash as i64));
2889        assert!(
2890            decode_zenoh_sample(&mut decoder, &tagged, zenoh::bytes::ZBytes::from(batch))
2891                .unwrap()
2892                .is_none(),
2893            "a pattern-correlated stream must not prime the schema-once decoder"
2894        );
2895    }
2896
2897    /// Internal wire-protocol keys (`_schema_hash`, `_framing`) must be
2898    /// stripped from the metadata handed to user code — on both the zenoh and
2899    /// the daemon receive paths. A forwarded `_schema_hash` would otherwise
2900    /// ride onto a large/service output (which does not overwrite it) and make
2901    /// receivers hash-mismatch and silently drop the message
2902    /// (dora-rs/dora#2366 review).
2903    #[test]
2904    fn internal_wire_keys_are_stripped_from_user_visible_metadata() {
2905        use dora_message::metadata::{
2906            FRAMING, FRAMING_ARROW_IPC, Metadata, Parameter, SCHEMA_HASH,
2907        };
2908
2909        let hlc = dora_core::uhlc::HLC::default();
2910        let mut metadata = Metadata::new(hlc.new_timestamp());
2911        metadata
2912            .parameters
2913            .insert(SCHEMA_HASH.to_string(), Parameter::Integer(42));
2914        metadata.parameters.insert(
2915            FRAMING.to_string(),
2916            Parameter::String(FRAMING_ARROW_IPC.to_string()),
2917        );
2918        metadata
2919            .parameters
2920            .insert("user_key".to_string(), Parameter::Integer(7));
2921
2922        // Zenoh receive path.
2923        let zenoh_item = EventItem::ZenohInput {
2924            id: DataId::from("in".to_string()),
2925            metadata: Arc::new(metadata.clone()),
2926            data: {
2927                use arrow::array::Array;
2928                arrow::array::Int32Array::from(vec![1]).into_data()
2929            },
2930        };
2931        let Event::Input {
2932            metadata: user_metadata,
2933            ..
2934        } = EventStream::convert_event_item(zenoh_item)
2935        else {
2936            panic!("expected an input event");
2937        };
2938        assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
2939        assert!(!user_metadata.parameters.contains_key(FRAMING));
2940        assert_eq!(
2941            user_metadata.parameters.get("user_key"),
2942            Some(&Parameter::Integer(7)),
2943            "user-provided keys must survive the strip"
2944        );
2945
2946        // Daemon receive path.
2947        let daemon_item = EventItem::NodeEvent {
2948            event: dora_message::daemon_to_node::NodeEvent::Input {
2949                id: DataId::from("in".to_string()),
2950                metadata: Arc::new(metadata),
2951                data: None,
2952            },
2953        };
2954        let Event::Input {
2955            metadata: user_metadata,
2956            ..
2957        } = EventStream::convert_event_item(daemon_item)
2958        else {
2959            panic!("expected an input event");
2960        };
2961        assert!(!user_metadata.parameters.contains_key(SCHEMA_HASH));
2962        assert!(!user_metadata.parameters.contains_key(FRAMING));
2963    }
2964
2965    /// A zenoh-delivered input must be serializable into the same recording
2966    /// JSON shape as a daemon-path input, so `write_events_to` recordings do
2967    /// not silently drop inputs that take the direct zenoh data plane.
2968    #[test]
2969    fn zenoh_input_serializes_into_recording_json() {
2970        use crate::daemon_connection::node_integration_testing::convert_arrow_input_to_json;
2971
2972        let hlc = dora_core::uhlc::HLC::default();
2973        let start = hlc.new_timestamp();
2974        let metadata = Metadata::new(hlc.new_timestamp());
2975        let array: arrow::array::ArrayRef =
2976            std::sync::Arc::new(arrow::array::Int32Array::from(vec![1, 2, 3]));
2977
2978        let json = convert_arrow_input_to_json(
2979            &DataId::from("in".to_string()),
2980            &metadata,
2981            array,
2982            start,
2983            true,
2984        )
2985        .expect("zenoh input must serialize");
2986
2987        assert_eq!(json["id"], "in");
2988        assert!(json.contains_key("data"), "recorded event must carry data");
2989        assert!(
2990            json.contains_key("data_type"),
2991            "recorded event must carry data_type"
2992        );
2993        assert_eq!(
2994            json["data"].as_array().map(|a| a.len()),
2995            Some(3),
2996            "all array elements must be recorded"
2997        );
2998    }
2999
3000    /// A zenoh input can carry a remote HLC timestamp that predates this node's
3001    /// `start_timestamp`. The recording must clamp the offset to zero instead of
3002    /// underflowing the NTP64 subtraction (a debug panic that would kill the
3003    /// event loop, or a release wraparound to a garbage offset).
3004    #[test]
3005    fn zenoh_input_with_earlier_timestamp_does_not_underflow() {
3006        use crate::daemon_connection::node_integration_testing::convert_arrow_input_to_json;
3007
3008        let hlc = dora_core::uhlc::HLC::default();
3009        // `input_ts` is created first, so it is strictly before `start`.
3010        let input_ts = hlc.new_timestamp();
3011        let start = hlc.new_timestamp();
3012        let metadata = Metadata::new(input_ts);
3013        let array: arrow::array::ArrayRef =
3014            std::sync::Arc::new(arrow::array::Int32Array::from(vec![1]));
3015
3016        // `skip_output_time_offsets = false` exercises the time-offset path.
3017        let json = convert_arrow_input_to_json(
3018            &DataId::from("in".to_string()),
3019            &metadata,
3020            array,
3021            start,
3022            false,
3023        )
3024        .expect("recording an earlier-timestamped input must not fail");
3025        assert_eq!(
3026            json["time_offset_secs"], 0.0,
3027            "an input predating start must clamp to a zero offset"
3028        );
3029    }
3030
3031    /// The schema-plane FatalError only fires after the grace window: the
3032    /// producer's periodic full-stream refresh heals an unprimed input in-band,
3033    /// so a node with a dead `@schema` plane must not be killed on the first
3034    /// dropped batch when it would recover within seconds.
3035    #[test]
3036    fn schema_plane_fatal_waits_out_the_grace_window() {
3037        let start = Instant::now();
3038        let mut first = None;
3039
3040        // First undecodable batch starts the window — not fatal yet.
3041        assert!(!schema_plane_fatal_due(&mut first, start));
3042        assert_eq!(first, Some(start));
3043        // Still inside the grace window — not fatal.
3044        assert!(!schema_plane_fatal_due(
3045            &mut first,
3046            start + SCHEMA_PLANE_FATAL_GRACE / 2
3047        ));
3048        // Past the window — fatal.
3049        assert!(schema_plane_fatal_due(
3050            &mut first,
3051            start + SCHEMA_PLANE_FATAL_GRACE
3052        ));
3053
3054        // A successful decode clears the window (caller side); the next drop
3055        // starts a fresh one.
3056        let mut first = None;
3057        let later = start + SCHEMA_PLANE_FATAL_GRACE * 2;
3058        assert!(!schema_plane_fatal_due(&mut first, later));
3059        assert_eq!(first, Some(later));
3060    }
3061
3062    /// Same invariant as `recv_returns_none_after_stop`, verified via
3063    /// the `Stream` impl (`StreamExt::next`).
3064    #[test]
3065    fn stream_returns_none_after_stop() {
3066        use futures::StreamExt;
3067        let (_node, mut events) = test_event_stream();
3068
3069        let first = futures::executor::block_on(events.next());
3070        assert!(matches!(first, Some(Event::Stop(_))));
3071
3072        let second = futures::executor::block_on(events.next());
3073        assert!(
3074            second.is_none(),
3075            "Stream::next must yield None after Stop, got {second:?}"
3076        );
3077    }
3078}