Skip to main content

dora_runtime_api/
lib.rs

1//! **Internal to dora — not a public API.**
2//!
3//! This crate is published to crates.io only because cargo requires every
4//! dependency of a published crate to be published; `dora-node-api` and
5//! `dora-cli` depend on it. It is not covered by dora's 1.0 stability
6//! guarantee and may change in any release, including a patch.
7//!
8//! Depend on it directly at your own risk. See the "Stability scope at 1.0"
9//! section of `docs/api-rust.md`.
10//!
11#![warn(unsafe_op_in_unsafe_fn)]
12
13use dora_core::{
14    config::{DataId, OperatorId},
15    descriptor::OperatorConfig,
16};
17use dora_message::daemon_to_node::{NodeConfig, RuntimeConfig};
18use dora_node_api::{DoraNode, Event, StopCause};
19use dora_tracing::TracingBuilder;
20use eyre::{Context, Result, bail};
21use futures::{Stream, StreamExt};
22use futures_concurrency::stream::Merge;
23use std::{
24    collections::{BTreeMap, BTreeSet, HashMap, VecDeque},
25    mem,
26    sync::{Arc, Mutex, OnceLock},
27    time::{Duration, Instant},
28};
29use tokio::{
30    runtime::Builder,
31    sync::{mpsc, oneshot},
32};
33use tokio_stream::wrappers::ReceiverStream;
34
35mod channel;
36mod operator;
37
38pub use operator::{
39    OperatorEvent, OperatorRunner, RunnerGuard, RuntimeHandle, SharedAllocator, StopReason,
40};
41
42/// Entry point for a runtime process.
43///
44/// Reads the `DORA_RUNTIME_CONFIG` env var, builds the tokio runtime, runs the
45/// language-neutral event loop on a spawned thread, and calls
46/// [`OperatorRunner::run_operator`] on the main thread. Each per-language
47/// backend calls this with its own runner.
48pub fn main(runner: impl OperatorRunner) -> eyre::Result<()> {
49    let config: RuntimeConfig = {
50        let raw = std::env::var("DORA_RUNTIME_CONFIG")
51            .wrap_err("env variable DORA_RUNTIME_CONFIG must be set")?;
52        serde_yaml::from_str(&raw).context("failed to deserialize runtime config")?
53    };
54    let RuntimeConfig {
55        node: config,
56        operators,
57    } = config;
58    let node_id = config.node_id.clone();
59    #[cfg(feature = "tracing")]
60    {
61        TracingBuilder::new(node_id.as_ref())
62            .with_stdout("warn", false)
63            .build()
64            .wrap_err("failed to set up tracing subscriber")?;
65    }
66
67    let dataflow_descriptor = serde_yaml::from_value(config.dataflow_descriptor.clone())
68        .context("failed to parse dataflow descriptor")?;
69
70    let operator_definition = if operators.is_empty() {
71        bail!("no operators");
72    } else if operators.len() > 1 {
73        bail!("multiple operators are not supported");
74    } else {
75        let mut ops = operators;
76        ops.remove(0)
77    };
78
79    let (operator_events_tx, events) = mpsc::channel(1);
80    let operator_id = operator_definition.id.clone();
81    let operator_events = ReceiverStream::new(events).map(move |event| RuntimeEvent::Operator {
82        id: operator_id.clone(),
83        event,
84    });
85
86    // Use multi-thread scheduler (with 1 worker) because Zenoh requires it
87    // for distributed cross-daemon communication. current_thread panics when
88    // Zenoh tries to spawn background tasks during session init.
89    let tokio_runtime = Builder::new_multi_thread()
90        .worker_threads(1)
91        .enable_all()
92        .build()
93        .wrap_err("Could not build a tokio runtime.")?;
94
95    let mut operator_channels = HashMap::new();
96    let queue_sizes = queue_sizes(&operator_definition.config);
97    let (operator_channel, incoming_events) = channel::channel(tokio_runtime.handle(), queue_sizes);
98    operator_channels.insert(operator_definition.id.clone(), operator_channel);
99
100    tracing::info!("spawning main task");
101    let operator_config = [(
102        operator_definition.id.clone(),
103        operator_definition.config.clone(),
104    )]
105    .into_iter()
106    .collect();
107    let (init_done_tx, init_done) = oneshot::channel();
108    // Filled by `run` as soon as the node exists; operators encode their outputs
109    // through it (see `SharedAllocator`, dora-rs/dora#2742).
110    let allocator: SharedAllocator = Arc::new(OnceLock::new());
111    let run_allocator = allocator.clone();
112    let main_task = std::thread::spawn(move || -> Result<()> {
113        tokio_runtime.block_on(run(
114            operator_config,
115            config,
116            operator_events,
117            operator_channels,
118            init_done,
119            run_allocator,
120        ))
121    });
122
123    let operator_id = operator_definition.id.clone();
124    // Hold the backend's guard until *after* the main event loop has joined
125    // below. The shared-library backend returns the loaded `.so` here. Since
126    // dora-rs/dora#2742 the loop no longer holds Arrow arrays exported by the
127    // operator, but values whose vtable lives in the `.so` can still be in
128    // flight (an `OperatorEvent::Panic` payload). Unloading it earlier dangles
129    // those. See [`RunnerGuard`].
130    let _operator_guard = runner
131        .run_operator(
132            &node_id,
133            operator_definition,
134            incoming_events,
135            RuntimeHandle::new(operator_events_tx, allocator),
136            init_done_tx,
137            &dataflow_descriptor,
138        )
139        .wrap_err_with(|| format!("failed to run operator {operator_id}"))?;
140
141    match main_task.join() {
142        Ok(result) => result.wrap_err("main task failed")?,
143        Err(panic) => std::panic::resume_unwind(panic),
144    }
145
146    // `_operator_guard` drops here (unloading the `.so` for the shared-library
147    // backend), after the main loop has joined and released everything the
148    // operator handed it.
149    Ok(())
150}
151
152fn queue_sizes(
153    config: &OperatorConfig,
154) -> std::collections::BTreeMap<DataId, (usize, dora_message::config::QueuePolicy)> {
155    let mut sizes = BTreeMap::new();
156    for (input_id, input) in &config.inputs {
157        let queue_size = input
158            .queue_size
159            .unwrap_or(dora_message::config::DEFAULT_QUEUE_SIZE);
160        let policy = input.queue_policy.unwrap_or_default();
161        sizes.insert(input_id.clone(), (queue_size, policy));
162    }
163    sizes
164}
165
166#[tracing::instrument(skip(operator_events, operator_channels, allocator), level = "trace")]
167async fn run(
168    operators: HashMap<OperatorId, OperatorConfig>,
169    config: NodeConfig,
170    operator_events: impl Stream<Item = RuntimeEvent> + Unpin,
171    mut operator_channels: HashMap<OperatorId, flume::Sender<Event>>,
172    init_done: oneshot::Receiver<Result<()>>,
173    allocator: SharedAllocator,
174) -> eyre::Result<()> {
175    // Start the OTLP metrics exporter only when an endpoint is configured, and
176    // spawn it as a background task. `run_metrics_monitor` is an `async fn`, so
177    // its returned future does nothing until polled; previously the future was
178    // bound to a `_meter_provider` local and dropped without ever being awaited
179    // or spawned, so the `metrics` feature silently exported nothing. The future
180    // also never resolves (the process observer runs for the node's lifetime),
181    // so it must be spawned rather than awaited inline. Mirrors the gating and
182    // spawning used by the node API (`apis/rust/node/src/node/mod.rs`).
183    #[cfg(feature = "metrics")]
184    if let Ok(endpoint) = std::env::var("DORA_OTLP_ENDPOINT") {
185        use dora_metrics::run_metrics_monitor;
186
187        let meter_id = config.node_id.to_string();
188        tokio::spawn(async move {
189            if let Err(e) = run_metrics_monitor(meter_id, &endpoint)
190                .await
191                .wrap_err("metrics monitor exited unexpectedly")
192            {
193                tracing::warn!("metrics monitor failed: {e:#}");
194            }
195        });
196    }
197    init_done
198        .await
199        .wrap_err("the `init_done` channel was closed unexpectedly")?
200        .wrap_err("failed to init an operator")?;
201    tracing::info!("All operators are ready, starting runtime");
202
203    let (mut node, mut daemon_events) = DoraNode::init(config)?;
204    // Publish the allocator before any input can reach an operator, so an
205    // operator's first `send_output` already has somewhere to encode into.
206    let _ = allocator.set(node.sample_allocator());
207    let (daemon_events_tx, daemon_event_stream) = flume::bounded(1);
208    tokio::task::spawn_blocking(move || {
209        while let Some(event) = daemon_events.recv() {
210            if daemon_events_tx.send(RuntimeEvent::Event(event)).is_err() {
211                break;
212            }
213        }
214    });
215    let mut events = (operator_events, daemon_event_stream.into_stream()).merge();
216
217    let mut open_operator_inputs: HashMap<_, BTreeSet<_>> = operators
218        .iter()
219        .map(|(id, config)| (id, config.inputs.keys().collect()))
220        .collect();
221
222    // Diagnostic watchdog (dora-rs/dora#2742): warn when the main loop stops
223    // making progress *while handling* an event, naming the stuck event. On
224    // Windows a wedged operator can't be soft-killed (`CTRL_BREAK_EVENT` cannot
225    // interrupt native code the way Unix `SIGTERM` does), so the only symptom is
226    // a silent grace-period force-kill with no clue where it parked.
227    let activity = Arc::new(Mutex::new(LoopActivity::Idle));
228    {
229        let activity = activity.clone();
230        let node_id = node.id().to_string();
231        tokio::spawn(async move {
232            let mut ticker = tokio::time::interval(Duration::from_secs(2));
233            ticker.tick().await; // first tick fires immediately
234            loop {
235                ticker.tick().await;
236                let stalled = match &*lock(&activity) {
237                    LoopActivity::Handling { since, what }
238                        if since.elapsed() > Duration::from_secs(3) =>
239                    {
240                        Some((since.elapsed(), *what))
241                    }
242                    _ => None,
243                };
244                if let Some((elapsed, what)) = stalled {
245                    tracing::warn!(
246                        "runtime `{node_id}` main loop stalled for {:.0}s while handling {what} \
247                         (dora-rs/dora#2742 diagnostic)",
248                        elapsed.as_secs_f32()
249                    );
250                }
251            }
252        });
253    }
254
255    // Events pulled off the stream while a send was in flight (see
256    // `await_send_watching_for_stop`); they are handled before the stream is
257    // polled again so ordering is preserved.
258    let mut pending: VecDeque<RuntimeEvent> = VecDeque::new();
259
260    loop {
261        *lock(&activity) = LoopActivity::Idle;
262        let next = match pending.pop_front() {
263            buffered @ Some(_) => buffered,
264            None => events.next().await,
265        };
266        let Some(event) = next else { break };
267        *lock(&activity) = LoopActivity::Handling {
268            since: Instant::now(),
269            what: describe_runtime_event(&event),
270        };
271        match event {
272            RuntimeEvent::Operator {
273                id: operator_id,
274                event,
275            } => match event {
276                OperatorEvent::Error(err) => {
277                    bail!(err.wrap_err(format!(
278                        "operator {}/{operator_id} raised an error",
279                        node.id()
280                    )))
281                }
282                OperatorEvent::Panic(payload) => {
283                    let message = payload
284                        .downcast_ref::<&str>()
285                        .map(|s| s.to_string())
286                        .or_else(|| payload.downcast_ref::<String>().cloned())
287                        .unwrap_or_else(|| format!("{payload:?}"));
288                    bail!("operator {operator_id} panicked: {message}");
289                }
290                OperatorEvent::Finished { reason } => {
291                    if let StopReason::ExplicitStopAll = reason {
292                        bail!(
293                            "operator {operator_id} requested StopAll, which is not yet implemented"
294                        );
295                    }
296
297                    let Some(config) = operators.get(&operator_id) else {
298                        tracing::warn!(
299                            "received Finished event for unknown operator `{operator_id}`"
300                        );
301                        continue;
302                    };
303                    let outputs = config
304                        .outputs
305                        .iter()
306                        .map(|output_id| operator_output_id(&operator_id, output_id))
307                        .collect();
308                    let result;
309                    (node, result) = tokio::task::spawn_blocking(move || {
310                        let result = node.close_outputs(outputs);
311                        (node, result)
312                    })
313                    .await
314                    .wrap_err("failed to wait for close_outputs task")?;
315                    result.wrap_err("failed to close outputs of finished operator")?;
316
317                    operator_channels.remove(&operator_id);
318
319                    if operator_channels.is_empty() {
320                        break;
321                    }
322                }
323                OperatorEvent::Output {
324                    output_id,
325                    parameters,
326                    encoded,
327                } => {
328                    let output_id = operator_output_id(&operator_id, &output_id);
329                    let mut send = tokio::task::spawn_blocking(move || {
330                        let result = node.send_output_encoded(output_id, parameters, encoded);
331                        (node, result)
332                    });
333                    let result;
334                    (node, result) = await_send_watching_for_stop(
335                        &mut send,
336                        &mut events,
337                        &mut pending,
338                        &mut operator_channels,
339                    )
340                    .await
341                    .wrap_err("failed to wait for send_output task")?;
342                    result.wrap_err("failed to send node output")?;
343                }
344            },
345            RuntimeEvent::Event(Event::Stop(cause)) => {
346                forward_stop(&mut operator_channels, &cause).await;
347            }
348            RuntimeEvent::Event(Event::Reload {
349                operator_id: Some(operator_id),
350            }) => {
351                let Some(operator_channel) = operator_channels.get(&operator_id) else {
352                    tracing::warn!("received Reload event for unknown operator `{operator_id}`");
353                    continue;
354                };
355                let _ = operator_channel
356                    .send_async(Event::Reload {
357                        operator_id: Some(operator_id),
358                    })
359                    .await;
360            }
361            RuntimeEvent::Event(Event::Reload { operator_id: None }) => {
362                tracing::warn!("Reloading runtime nodes is not supported");
363            }
364            RuntimeEvent::Event(Event::Input { id, metadata, data }) => {
365                let Some((operator_id, input_id)) = id.as_str().split_once('/') else {
366                    tracing::warn!("received non-operator input {id}");
367                    continue;
368                };
369                let operator_id = OperatorId::from(operator_id.to_owned());
370                let input_id = DataId::from(input_id.to_owned());
371                let Some(operator_channel) = operator_channels.get(&operator_id) else {
372                    tracing::warn!("received input {id} for unknown operator");
373                    continue;
374                };
375
376                if let Err(err) = operator_channel
377                    .send_async(Event::Input {
378                        id: input_id,
379                        metadata,
380                        data,
381                    })
382                    .await
383                    .wrap_err_with(|| {
384                        // `id` is the full `operator/input` DataId; use it (and the
385                        // still-owned `operator_id`) here so `input_id` can be moved
386                        // into the event above without a per-message clone.
387                        format!("failed to send input `{id}` to operator `{operator_id}`")
388                    })
389                {
390                    tracing::warn!("{err}");
391                }
392            }
393            RuntimeEvent::Event(Event::InputClosed { id }) => {
394                let Some((operator_id, input_id)) = id.as_str().split_once('/') else {
395                    tracing::warn!("received InputClosed event for non-operator input {id}");
396                    continue;
397                };
398                let operator_id = OperatorId::from(operator_id.to_owned());
399                let input_id = DataId::from(input_id.to_owned());
400
401                let Some(operator_channel) = operator_channels.get(&operator_id) else {
402                    tracing::warn!("received input {id} for unknown operator");
403                    continue;
404                };
405                if let Err(err) = operator_channel
406                    .send_async(Event::InputClosed {
407                        id: input_id.clone(),
408                    })
409                    .await
410                    .wrap_err_with(|| {
411                        format!(
412                            "failed to send InputClosed({input_id}) to operator `{operator_id}`"
413                        )
414                    })
415                {
416                    tracing::warn!("{err}");
417                }
418
419                if let Some(open_inputs) = open_operator_inputs.get_mut(&operator_id) {
420                    open_inputs.remove(&input_id);
421                    if open_inputs.is_empty() {
422                        // all inputs of the node were closed -> close its event channel
423                        tracing::trace!(
424                            "all inputs of operator {}/{operator_id} were closed -> closing event channel",
425                            node.id()
426                        );
427                        open_operator_inputs.remove(&operator_id);
428                        operator_channels.remove(&operator_id);
429                    }
430                }
431            }
432            RuntimeEvent::Event(Event::Error(err)) => eyre::bail!("received error event: {err}"),
433            RuntimeEvent::Event(other) => {
434                tracing::warn!("received unknown event `{other:?}`");
435            }
436        }
437    }
438
439    mem::drop(events);
440
441    Ok(())
442}
443
444/// Forward `Stop` to every operator and close their event channels.
445///
446/// Diagnostic (dora-rs/dora#2742): a logged "received Stop" with no matching
447/// "forwarded Stop" means the forward blocked on a full operator channel — i.e.
448/// the operator is parked in its own `on_event` and never draining. The `warn!`
449/// level is deliberate: the runtime's default stdout filter is `warn`
450/// (`with_stdout("warn", …)`), so anything quieter never reaches a nightly log —
451/// which is what made the original wedge invisible.
452async fn forward_stop(
453    operator_channels: &mut HashMap<OperatorId, flume::Sender<Event>>,
454    cause: &StopCause,
455) {
456    tracing::warn!(
457        "runtime received Stop; forwarding to {} operator(s) (dora-rs/dora#2742 diagnostic)",
458        operator_channels.len()
459    );
460    for (id, channel) in operator_channels.drain() {
461        let _ = channel.send_async(Event::Stop(cause.clone())).await;
462        tracing::warn!("forwarded Stop to operator `{id}` (dora-rs/dora#2742 diagnostic)");
463    }
464}
465
466/// Wait for an in-flight output send without going deaf to `Stop`
467/// (dora-rs/dora#2742).
468///
469/// The main loop is the only consumer of the merged operator/daemon event
470/// stream, so awaiting a send inline meant the node could not observe `Stop` —
471/// and could not let its operators start winding down — until the send
472/// returned. This keeps consuming the stream: `Stop` is forwarded to the
473/// operators immediately, everything else is buffered for the caller to handle,
474/// in order, once the send completes.
475///
476/// This does **not** rescue a send that never returns: the node still owes the
477/// daemon an exit and will be force-killed at the grace period. Bailing out of a
478/// wedged send is not possible from here — the `DoraNode` lives inside the
479/// blocking task, and `Runtime::drop` waits forever for `spawn_blocking` work,
480/// so a real escape needs `shutdown_background()` at the `main()` layer.
481async fn await_send_watching_for_stop<S, T>(
482    send: &mut tokio::task::JoinHandle<T>,
483    events: &mut S,
484    pending: &mut VecDeque<RuntimeEvent>,
485    operator_channels: &mut HashMap<OperatorId, flume::Sender<Event>>,
486) -> Result<T, tokio::task::JoinError>
487where
488    S: Stream<Item = RuntimeEvent> + Unpin,
489{
490    /// Stop pulling events into memory once this many are buffered. Each
491    /// buffered `Event::Input` pins its payload (a mapped shared-memory region
492    /// above the zero-copy threshold), so this is deliberately small: it only
493    /// has to cover the handful of events that can arrive while one send is in
494    /// flight. Beyond it the events simply stay in the stream.
495    const MAX_BUFFERED: usize = 4;
496
497    let mut stop_seen = false;
498    loop {
499        // Once `Stop` is forwarded there is nothing left to watch for, so stop
500        // consuming and just wait the send out.
501        let watching = !stop_seen && pending.len() < MAX_BUFFERED;
502        tokio::select! {
503            biased;
504            joined = &mut *send => return joined,
505            event = events.next(), if watching => {
506                match event {
507                    Some(RuntimeEvent::Event(Event::Stop(cause))) => {
508                        forward_stop(operator_channels, &cause).await;
509                        stop_seen = true;
510                    }
511                    Some(other) => pending.push_back(other),
512                    // The stream ended, so no `Stop` can arrive.
513                    None => stop_seen = true,
514                }
515            }
516        }
517    }
518}
519
520fn operator_output_id(operator_id: &OperatorId, output_id: &DataId) -> DataId {
521    DataId::from(format!("{operator_id}/{output_id}"))
522}
523
524#[derive(Debug)]
525enum RuntimeEvent {
526    Operator {
527        id: OperatorId,
528        event: OperatorEvent,
529    },
530    Event(Event),
531}
532
533/// What the runtime's main loop is currently doing, for the stall watchdog.
534///
535/// Diagnostic for dora-rs/dora#2742: on Windows a wedged operator cannot be
536/// interrupted by the daemon's soft-kill (`CTRL_BREAK_EVENT`, unlike Unix
537/// `SIGTERM`), so the node runs to the force-kill and the failure shows up only
538/// as a grace-period kill with no clue where it parked. The watchdog names the
539/// event whose handling has stopped making progress.
540enum LoopActivity {
541    Idle,
542    Handling { since: Instant, what: &'static str },
543}
544
545/// Which *kind* of runtime event the main loop is handling, for the stall
546/// watchdog. Returns a `&'static str` (no per-event allocation on the hot path);
547/// the kind alone distinguishes the two wedge sites that matter — a stalled
548/// `an operator output` is a blocked daemon send, a stalled `an operator input`
549/// is a blocked forward to a parked operator.
550fn describe_runtime_event(event: &RuntimeEvent) -> &'static str {
551    match event {
552        RuntimeEvent::Operator { event, .. } => match event {
553            OperatorEvent::Output { .. } => "an operator output",
554            _ => "an operator lifecycle event",
555        },
556        RuntimeEvent::Event(event) => match event {
557            Event::Input { .. } => "an operator input",
558            Event::InputClosed { .. } => "an input-closed event",
559            Event::Stop(_) => "a stop event",
560            Event::Reload { .. } => "a reload event",
561            _ => "an event",
562        },
563    }
564}
565
566/// Lock a mutex, recovering the guard even if a previous holder panicked. The
567/// watchdog state is pure diagnostics, so a poisoned lock must not take the
568/// runtime down with it.
569fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
570    mutex
571        .lock()
572        .unwrap_or_else(std::sync::PoisonError::into_inner)
573}
574
575#[cfg(test)]
576mod stop_responsiveness_tests {
577    use super::*;
578    use futures::stream;
579
580    fn operator_channel() -> (
581        HashMap<OperatorId, flume::Sender<Event>>,
582        flume::Receiver<Event>,
583    ) {
584        let (tx, rx) = flume::unbounded();
585        let mut channels = HashMap::new();
586        channels.insert(OperatorId::from("op".to_string()), tx);
587        (channels, rx)
588    }
589
590    /// The ordinary case: nothing else happens, the send completes, and no
591    /// event is buffered.
592    #[tokio::test]
593    async fn a_send_that_completes_is_returned() {
594        let mut send = tokio::spawn(async { 42 });
595        let mut events = stream::empty::<RuntimeEvent>();
596        let mut pending = VecDeque::new();
597        let (mut channels, _rx) = operator_channel();
598
599        let joined =
600            await_send_watching_for_stop(&mut send, &mut events, &mut pending, &mut channels)
601                .await
602                .expect("joined");
603
604        assert_eq!(joined, 42);
605        assert!(pending.is_empty());
606    }
607
608    /// The #2742 behaviour: a `Stop` arriving mid-send reaches the operator
609    /// without waiting for the send, and a non-`Stop` event is kept for the
610    /// caller rather than dropped or reordered.
611    #[tokio::test]
612    async fn stop_reaches_the_operator_while_a_send_is_in_flight() {
613        let (unblock_tx, unblock_rx) = tokio::sync::oneshot::channel::<()>();
614        let mut send = tokio::spawn(async move {
615            let _ = unblock_rx.await;
616            7
617        });
618        let mut events = stream::iter(vec![
619            RuntimeEvent::Event(Event::Reload { operator_id: None }),
620            RuntimeEvent::Event(Event::Stop(StopCause::Manual)),
621        ]);
622        let mut pending = VecDeque::new();
623        let (mut channels, rx) = operator_channel();
624
625        // Release the send only after the operator has been told to stop, which
626        // is only possible if the forward happened while the send was pending.
627        tokio::spawn(async move {
628            while rx.is_empty() {
629                tokio::task::yield_now().await;
630            }
631            assert!(matches!(rx.recv_async().await, Ok(Event::Stop(_))));
632            let _ = unblock_tx.send(());
633        });
634
635        let joined = tokio::time::timeout(
636            Duration::from_secs(5),
637            await_send_watching_for_stop(&mut send, &mut events, &mut pending, &mut channels),
638        )
639        .await
640        .expect("must not hang")
641        .expect("joined");
642
643        assert_eq!(joined, 7);
644        assert_eq!(
645            pending.len(),
646            1,
647            "the non-Stop event must be kept, not lost"
648        );
649        assert!(
650            channels.is_empty(),
651            "forwarding Stop closes the operator channels"
652        );
653    }
654
655    /// Buffering is bounded: a flood of events during a send does not pull the
656    /// whole stream into memory.
657    #[tokio::test]
658    async fn buffering_during_a_send_is_bounded() {
659        let (unblock_tx, unblock_rx) = tokio::sync::oneshot::channel::<()>();
660        let mut send = tokio::spawn(async move {
661            let _ = unblock_rx.await;
662            0
663        });
664        let flood = (0..1000)
665            .map(|_| RuntimeEvent::Event(Event::Reload { operator_id: None }))
666            .collect::<Vec<_>>();
667        let mut events = stream::iter(flood);
668        let mut pending = VecDeque::new();
669        let (mut channels, _rx) = operator_channel();
670
671        tokio::spawn(async move {
672            tokio::time::sleep(Duration::from_millis(50)).await;
673            let _ = unblock_tx.send(());
674        });
675
676        await_send_watching_for_stop(&mut send, &mut events, &mut pending, &mut channels)
677            .await
678            .expect("joined");
679
680        assert!(
681            pending.len() <= 4,
682            "buffered {} events, expected the cap to hold",
683            pending.len()
684        );
685    }
686}