Skip to main content

uni_store/runtime/
flush_coordinator.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Async-flush coordination.
5//!
6//! Bounds the number of in-flight L0→L1 flushes (via a semaphore),
7//! assigns rotate-order sequence numbers, and serializes finalize so
8//! the manifest parent-chain stays consistent.
9//!
10//! ## Architecture
11//!
12//! ```text
13//! Writer
14//!   ├── flush_lock              (brief: rotate + finalize)
15//!   └── flush_coordinator
16//!         ├── permits: Semaphore(max_pending_flushes)
17//!         ├── next_seq: AtomicU64
18//!         └── submit_tx → finalizer task
19//!                          └─ mpsc<FlushSubmit>
20//!                          └─ BinaryHeap reorder by seq
21//! ```
22
23use crate::storage::manager::{FlushInProgressGuard, StorageManager};
24use parking_lot::RwLock as PlRwLock;
25use std::cmp::Reverse;
26use std::collections::BinaryHeap;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicU64, Ordering};
29use tokio::sync::{Semaphore, mpsc, oneshot};
30use uni_common::core::snapshot::SnapshotManifest;
31
32/// Result of a rotate phase: the snapshot of state needed to stream and
33/// finalize. Send + 'static so it can travel into a spawned task.
34pub struct RotatedFlush {
35    pub seq: u64,
36    pub old_l0_arc: Arc<PlRwLock<crate::runtime::l0::L0Buffer>>,
37    pub wal_lsn: u64,
38    pub current_version: u64,
39    pub name: Option<String>,
40    /// Snapshot of `cached_manifest` taken at rotate time. Stream uses this
41    /// as a tentative parent; finalize may rewrite it if predecessors
42    /// finalized in between.
43    pub parent_manifest: Option<SnapshotManifest>,
44    /// Permit holding the back-pressure slot. Released on finalize drop.
45    pub permit: tokio::sync::OwnedSemaphorePermit,
46    /// Acquired during rotate; dropped when this `RotatedFlush` is consumed
47    /// by finalize (success or failure). Keeps `flush_in_progress` accurate
48    /// for the full async pipeline duration.
49    pub flush_in_progress_guard: FlushInProgressGuard,
50}
51
52/// Result of a stream phase: the manifest to publish.
53pub struct FlushOutcome {
54    pub new_manifest: SnapshotManifest,
55    pub snapshot_id: String,
56}
57
58/// Carried across the spawn boundary so a finalize step can run without
59/// touching `Writer` (which is `&self` and lives in the caller).
60#[derive(Clone)]
61pub struct SharedFlushCtx {
62    pub storage: Arc<StorageManager>,
63    pub l0_manager: Arc<crate::runtime::l0_manager::L0Manager>,
64    pub adjacency_manager: Arc<crate::storage::adjacency_manager::AdjacencyManager>,
65    pub property_manager: Option<Arc<crate::runtime::property_manager::PropertyManager>>,
66    pub schema_manager: Arc<uni_common::core::schema::SchemaManager>,
67    pub cached_manifest: Arc<parking_lot::Mutex<Option<SnapshotManifest>>>,
68    pub last_flush_time: Arc<parking_lot::Mutex<std::time::Instant>>,
69    pub fork_id: Option<uni_common::core::fork::ForkId>,
70    pub fork_flush_count: Arc<std::sync::atomic::AtomicU64>,
71    pub fork_fragment_warn_fired: Arc<std::sync::atomic::AtomicBool>,
72    pub fork_fragment_warn_threshold: usize,
73    /// Re-acquired by the static `flush_finalize_now` running on the
74    /// finalizer task. NOT held during stream — that's the whole point.
75    pub flush_lock: Arc<tokio::sync::Mutex<()>>,
76    pub index_rebuild_manager:
77        Arc<std::sync::OnceLock<Arc<crate::storage::index_rebuild::IndexRebuildManager>>>,
78    pub compaction_handle: Arc<parking_lot::RwLock<Option<tokio::task::JoinHandle<()>>>>,
79    pub compaction_config: uni_common::config::CompactionConfig,
80    pub index_rebuild_config: uni_common::config::IndexRebuildConfig,
81    pub auto_rebuild_enabled: bool,
82}
83
84/// A submission to the ordered finalizer.
85struct FlushSubmit {
86    seq: u64,
87    rotated: RotatedFlush,
88    result: anyhow::Result<FlushOutcome>,
89    /// Optional notification when finalize completes (for `FlushTicket`).
90    ack: Option<oneshot::Sender<anyhow::Result<String>>>,
91}
92
93/// User-facing handle to wait on an async-flush completion (proposal §5.6).
94pub struct FlushTicket {
95    /// `None` means the flush completed inline (sync path).
96    rx: Option<oneshot::Receiver<anyhow::Result<String>>>,
97}
98
99impl FlushTicket {
100    pub fn ready(snapshot_id: anyhow::Result<String>) -> Self {
101        // For sync paths: pre-resolved.
102        let (tx, rx) = oneshot::channel();
103        let _ = tx.send(snapshot_id);
104        Self { rx: Some(rx) }
105    }
106
107    pub fn pending(rx: oneshot::Receiver<anyhow::Result<String>>) -> Self {
108        Self { rx: Some(rx) }
109    }
110
111    /// Wait for the flush to finalize. Returns the snapshot id on success.
112    pub async fn await_finalize(self) -> anyhow::Result<String> {
113        match self.rx {
114            Some(rx) => rx
115                .await
116                .unwrap_or_else(|_| Err(anyhow::anyhow!("flush ticket dropped before completion"))),
117            None => Err(anyhow::anyhow!("flush ticket has no completion channel")),
118        }
119    }
120}
121
122pub struct FlushCoordinator {
123    permits: Arc<Semaphore>,
124    next_seq: AtomicU64,
125    /// Wrapped in Mutex<Option<...>> so `shutdown()` can take and drop
126    /// it explicitly, which closes the mpsc and lets the finalizer task
127    /// exit. `submit()` reads through the option; if absent, the
128    /// submission is silently dropped (coordinator is shutting down).
129    submit_tx: parking_lot::Mutex<Option<mpsc::UnboundedSender<FlushSubmit>>>,
130    /// Counter exposed for `drop_fork` to wait on. Incremented at rotate,
131    /// decremented after finalize.
132    pending_count: Arc<std::sync::atomic::AtomicUsize>,
133    drain_notify: Arc<tokio::sync::Notify>,
134    max_pending_flushes: usize,
135    /// Wall-clock bound on a single stream phase. A stream that exceeds this
136    /// is converted into a data-safe flush *failure* so its rotate-seq is
137    /// still submitted and the finalizer never wedges (issue #132).
138    stream_timeout: std::time::Duration,
139    /// Tracked for `ShutdownHandle::track_task` registration AND for
140    /// `shutdown()`'s await. Set to None after either takes it.
141    finalizer_handle: parking_lot::Mutex<Option<tokio::task::JoinHandle<()>>>,
142    /// Every spawned stream-phase task. `shutdown()` awaits each so
143    /// the closure-captured `Arc<Writer>` (and through it
144    /// `Arc<StorageManager>` + `Arc<ForkScope>` on a fork-scoped
145    /// writer) actually drops before `shutdown` returns. Without this,
146    /// `drop_fork` sees a transient `ForkInUse` because the stream
147    /// task's destructor is still on tokio's scheduler queue after
148    /// `drain()` returned. Opportunistically pruned in
149    /// `submit_for_stream` to keep the vec bounded.
150    stream_handles: parking_lot::Mutex<Vec<tokio::task::JoinHandle<()>>>,
151}
152
153/// RAII guard that guarantees a rotated flush's `seq` is ALWAYS submitted to
154/// the finalizer — even if the stream task's future is dropped/cancelled
155/// before it reaches the normal `submit` (issue #132). The finalizer advances
156/// `expected` strictly in consecutive seq order, so a seq that is never
157/// submitted wedges every later flush and holds its back-pressure permit
158/// forever. On the normal path the caller [`disarm`](Self::disarm)s the guard
159/// to hand the `RotatedFlush` + `ack` to `submit`; if the guard is dropped
160/// while still armed, its `Drop` submits a synthetic failure so
161/// `finalize_failure` runs (releasing the permit and advancing `expected`).
162struct FlushSeqGuard {
163    coord: Arc<FlushCoordinator>,
164    seq: u64,
165    rotated: Option<RotatedFlush>,
166    ack: Option<oneshot::Sender<anyhow::Result<String>>>,
167}
168
169impl FlushSeqGuard {
170    /// Normal completion path: take back ownership so the caller can submit the
171    /// real stream result. Leaves the guard disarmed so its `Drop` is a no-op.
172    fn disarm(
173        mut self,
174    ) -> (
175        RotatedFlush,
176        Option<oneshot::Sender<anyhow::Result<String>>>,
177    ) {
178        (
179            self.rotated
180                .take()
181                .expect("FlushSeqGuard::disarm called more than once"),
182            self.ack.take(),
183        )
184    }
185}
186
187impl Drop for FlushSeqGuard {
188    fn drop(&mut self) {
189        // Armed only if `disarm` never ran (the RotatedFlush is still here).
190        // Submit a synthetic failure so the finalizer advances past this seq
191        // and the back-pressure permit is released. No panics in Drop.
192        if let Some(rotated) = self.rotated.take() {
193            self.coord.submit(
194                self.seq,
195                rotated,
196                Err(anyhow::anyhow!(
197                    "flush stream task dropped before completion (seq {})",
198                    self.seq
199                )),
200                self.ack.take(),
201            );
202        }
203    }
204}
205
206impl FlushCoordinator {
207    pub fn new(
208        max_pending_flushes: usize,
209        stream_timeout: std::time::Duration,
210        shared: SharedFlushCtx,
211        finalize_fn: Arc<dyn FinalizeFn>,
212    ) -> Self {
213        let permits = Arc::new(Semaphore::new(max_pending_flushes.max(1)));
214        let next_seq = AtomicU64::new(0);
215        let (submit_tx, submit_rx) = mpsc::unbounded_channel::<FlushSubmit>();
216        let pending_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
217        let drain_notify = Arc::new(tokio::sync::Notify::new());
218
219        let pending_count_for_task = pending_count.clone();
220        let drain_notify_for_task = drain_notify.clone();
221        let handle = tokio::spawn(finalizer_loop(
222            submit_rx,
223            shared,
224            finalize_fn,
225            pending_count_for_task,
226            drain_notify_for_task,
227        ));
228
229        Self {
230            permits,
231            next_seq,
232            submit_tx: parking_lot::Mutex::new(Some(submit_tx)),
233            pending_count,
234            drain_notify,
235            max_pending_flushes,
236            stream_timeout,
237            finalizer_handle: parking_lot::Mutex::new(Some(handle)),
238            stream_handles: parking_lot::Mutex::new(Vec::new()),
239        }
240    }
241
242    /// Drop the submit channel and await the finalizer task to exit.
243    /// After this returns, the coordinator's spawned task is gone and
244    /// any Arcs it held (including the writer's `Arc<StorageManager>`
245    /// inside `SharedFlushCtx`, which on a fork-scoped writer pins
246    /// `Arc<ForkScope>`) are released. Used by `drop_fork` so the
247    /// ForkHolderGuard can finally drop. Idempotent: safe to call
248    /// repeatedly.
249    pub async fn shutdown(&self) {
250        // 1. Drain every spawned stream task. Each task's destructor
251        //    drops the closure-captured `Arc<Writer>` (and through it
252        //    `Arc<StorageManager>` / `Arc<ForkScope>`). Awaiting forces
253        //    those drops to happen before we return — closing the L8
254        //    fork-drop race documented in the plan.
255        let stream_handles: Vec<_> = self.stream_handles.lock().drain(..).collect();
256        for h in stream_handles {
257            let _ = h.await;
258        }
259        // 2. Drop submit_tx — closes the mpsc; the finalizer task will
260        //    receive None and exit its loop.
261        drop(self.submit_tx.lock().take());
262        // 3. Await the finalizer task. If already taken (e.g. by
263        //    ShutdownHandle::track_task), the JoinHandle is None and we
264        //    have no way to await — accept that and return; the task
265        //    is still on its way to exit because submit_tx is closed.
266        let handle = self.finalizer_handle.lock().take();
267        if let Some(h) = handle {
268            let _ = h.await;
269        }
270    }
271
272    /// Close the submit channel so the finalizer task exits, without awaiting.
273    ///
274    /// Step 2 of [`Self::shutdown`], usable from a synchronous context such as
275    /// `Drop`. The finalizer blocks on `submit_rx.recv()` and so never observes
276    /// the shutdown broadcast — closing the channel is the only thing that ends
277    /// it. A teardown path that signals and then waits for the task to exit
278    /// waits forever (or to its deadline) without this.
279    ///
280    /// Idempotent: the sender is taken out of the `Option`, so a later
281    /// [`Self::shutdown`] is unaffected.
282    pub fn close_submit_channel(&self) {
283        drop(self.submit_tx.lock().take());
284    }
285
286    /// Hand off the finalizer task's JoinHandle for tracking by
287    /// `ShutdownHandle`. Returns `None` if already taken.
288    pub fn take_finalizer_handle(&self) -> Option<tokio::task::JoinHandle<()>> {
289        self.finalizer_handle.lock().take()
290    }
291
292    pub fn max_pending_flushes(&self) -> usize {
293        self.max_pending_flushes
294    }
295
296    pub async fn acquire_permit(&self) -> anyhow::Result<tokio::sync::OwnedSemaphorePermit> {
297        self.permits
298            .clone()
299            .acquire_owned()
300            .await
301            .map_err(|_| anyhow::anyhow!("flush coordinator permit semaphore closed"))
302    }
303
304    /// Non-blocking variant of [`Self::acquire_permit`]. Returns `None`
305    /// if the permit pool is at capacity. Used on the commit hot path
306    /// to avoid awaiting under `flush_lock`.
307    pub fn try_acquire_permit(&self) -> Option<tokio::sync::OwnedSemaphorePermit> {
308        self.permits.clone().try_acquire_owned().ok()
309    }
310
311    pub fn next_rotate_seq(&self) -> u64 {
312        self.next_seq.fetch_add(1, Ordering::AcqRel)
313    }
314
315    pub fn note_pending(&self) {
316        self.pending_count.fetch_add(1, Ordering::AcqRel);
317    }
318
319    pub fn pending_flush_count(&self) -> usize {
320        self.pending_count.load(Ordering::Acquire)
321    }
322
323    /// Submit a completed-stream flush for ordered finalization.
324    /// Silently drops the submission if the coordinator has been shut
325    /// down (submit_tx taken).
326    pub fn submit(
327        &self,
328        seq: u64,
329        rotated: RotatedFlush,
330        result: anyhow::Result<FlushOutcome>,
331        ack: Option<oneshot::Sender<anyhow::Result<String>>>,
332    ) {
333        let submit_msg = FlushSubmit {
334            seq,
335            rotated,
336            result,
337            ack,
338        };
339        if let Some(tx) = self.submit_tx.lock().as_ref() {
340            let _ = tx.send(submit_msg);
341        }
342        // else: coordinator is shutting down; pending_count will be
343        // decremented by the matching drop of submit_msg (RotatedFlush
344        // contains the FlushInProgressGuard which already adjusts
345        // flush_in_progress on drop). We must also decrement
346        // pending_count manually because the finalizer won't see this.
347        else {
348            self.pending_count
349                .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
350            self.drain_notify.notify_waiters();
351        }
352    }
353
354    /// Spawn the stream phase on a tokio task and return a [`FlushTicket`].
355    ///
356    /// `run_stream` is the closure that actually performs the L1 stream
357    /// work — it takes the rotate snapshot (`old_l0_arc`, `wal_lsn`,
358    /// `current_version`, `name`) and returns the built (but not yet
359    /// published) manifest as a `FlushOutcome`. The closure typically
360    /// captures `Arc<Writer>` so it can call `writer.flush_stream_l1`.
361    ///
362    /// On stream completion, the result and the consumed `RotatedFlush`
363    /// are sent through the coordinator's mpsc to the single-task
364    /// finalizer, which preserves rotate-order via a BinaryHeap.
365    ///
366    /// The returned `FlushTicket` resolves when finalize completes
367    /// (or fails). Dropping the ticket does NOT cancel the flush — the
368    /// pipeline runs to completion either way.
369    pub fn submit_for_stream<F, Fut>(
370        self: &Arc<Self>,
371        rotated: RotatedFlush,
372        run_stream: F,
373    ) -> FlushTicket
374    where
375        F: FnOnce(Arc<PlRwLock<crate::runtime::l0::L0Buffer>>, u64, u64, Option<String>) -> Fut
376            + Send
377            + 'static,
378        Fut: std::future::Future<Output = anyhow::Result<FlushOutcome>> + Send + 'static,
379    {
380        let (ack_tx, ack_rx) = oneshot::channel();
381        let coord = self.clone();
382        let seq = rotated.seq;
383        let old_l0 = rotated.old_l0_arc.clone();
384        let wal_lsn = rotated.wal_lsn;
385        let current_version = rotated.current_version;
386        let name = rotated.name.clone();
387        let stream_timeout = self.stream_timeout;
388        let handle = tokio::spawn(async move {
389            // The seq guard guarantees this seq is submitted even if the task's
390            // future is dropped before the normal `submit` below (issue #132).
391            let guard = FlushSeqGuard {
392                coord: coord.clone(),
393                seq,
394                rotated: Some(rotated),
395                ack: Some(ack_tx),
396            };
397            // `run_stream_catching` converts a panic into a submitted *failure*
398            // (review H2). `timeout` additionally converts a STALLED stream — a
399            // lost-wakeup in the sparse/multivec Lance read-modify-write — into
400            // a data-safe failure, so it can neither wedge the finalizer's
401            // consecutive-seq pipeline nor hold a back-pressure permit forever
402            // (issue #132). Both cases still finalize via `finalize_failure`,
403            // which retains the old L0 in `pending_flush` and the WAL data.
404            let stream_fut =
405                run_stream_catching(run_stream(old_l0, wal_lsn, current_version, name));
406            let result = match tokio::time::timeout(stream_timeout, stream_fut).await {
407                Ok(r) => r,
408                Err(_elapsed) => {
409                    tracing::error!(
410                        seq,
411                        timeout_secs = stream_timeout.as_secs(),
412                        "flush stream exceeded timeout; converting to a data-safe flush \
413                         failure (old L0 retained in pending_flush, WAL retained, \
414                         recovery via replay/retry)"
415                    );
416                    metrics::counter!("uni_flush_stream_timeouts_total").increment(1);
417                    Err(anyhow::anyhow!(
418                        "flush stream timed out after {:?} (seq {})",
419                        stream_timeout,
420                        seq
421                    ))
422                }
423            };
424            let (rotated, ack) = guard.disarm();
425            coord.submit(seq, rotated, result, ack);
426        });
427        // Track the handle so `shutdown()` can await all stream tasks'
428        // destructors. Opportunistically prune finished handles to keep
429        // the vec bounded under high flush rates.
430        let mut handles = self.stream_handles.lock();
431        handles.retain(|h| !h.is_finished());
432        handles.push(handle);
433        FlushTicket::pending(ack_rx)
434    }
435
436    /// Wait until pending_count drops to zero. Used by `drop_fork`.
437    pub async fn drain(&self, timeout: std::time::Duration) -> Result<(), &'static str> {
438        let deadline = tokio::time::Instant::now() + timeout;
439        loop {
440            if self.pending_flush_count() == 0 {
441                return Ok(());
442            }
443            let notified = self.drain_notify.notified();
444            tokio::select! {
445                _ = notified => continue,
446                _ = tokio::time::sleep_until(deadline) => {
447                    return if self.pending_flush_count() == 0 {
448                        Ok(())
449                    } else {
450                        Err("pending flushes did not drain before deadline")
451                    };
452                }
453            }
454        }
455    }
456}
457
458/// Closure run by the finalizer task. Captures the parts of Writer that
459/// finalize touches; runs without holding any Writer reference.
460///
461/// `Writer::flush_finalize_now` implements this and is bound to the
462/// concrete WAL/storage state.
463pub trait FinalizeFn: Send + Sync {
464    fn finalize<'a>(
465        &'a self,
466        rotated: RotatedFlush,
467        outcome: FlushOutcome,
468        shared: SharedFlushCtx,
469    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<String>> + Send + 'a>>;
470
471    fn finalize_failure<'a>(
472        &'a self,
473        rotated: RotatedFlush,
474        err: anyhow::Error,
475        shared: SharedFlushCtx,
476    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Error> + Send + 'a>>;
477}
478
479/// Run a stream-phase future, converting a panic into an `Err` outcome instead
480/// of letting it unwind the spawned task.
481///
482/// The async-flush finalizer advances `expected` strictly in consecutive seq
483/// order and only when a `seq` is submitted. If a stream future panicked and the
484/// task died without submitting, that seq would be missing forever and every
485/// later flush — plus `drain()`/`shutdown()` — would block. Converting the panic
486/// to a failed `FlushOutcome` keeps the pipeline live (the flush still fails, but
487/// `finalize_failure` advances past it). (review H2)
488async fn run_stream_catching<Fut>(fut: Fut) -> anyhow::Result<FlushOutcome>
489where
490    Fut: std::future::Future<Output = anyhow::Result<FlushOutcome>>,
491{
492    use futures::FutureExt as _;
493    match std::panic::AssertUnwindSafe(fut).catch_unwind().await {
494        Ok(result) => result,
495        Err(panic) => {
496            let msg = panic
497                .downcast_ref::<&str>()
498                .map(|s| (*s).to_string())
499                .or_else(|| panic.downcast_ref::<String>().cloned())
500                .unwrap_or_else(|| "unknown panic".to_string());
501            tracing::error!(panic = %msg, "flush stream task panicked");
502            Err(anyhow::anyhow!("flush stream task panicked: {msg}"))
503        }
504    }
505}
506
507async fn finalizer_loop(
508    mut submit_rx: mpsc::UnboundedReceiver<FlushSubmit>,
509    shared: SharedFlushCtx,
510    finalize_fn: Arc<dyn FinalizeFn>,
511    pending_count: Arc<std::sync::atomic::AtomicUsize>,
512    drain_notify: Arc<tokio::sync::Notify>,
513) {
514    // Reorder-by-seq using a min-heap; finalize strictly in seq order.
515    let mut pending: BinaryHeap<Reverse<(u64, FlushSubmit)>> = BinaryHeap::new();
516    let mut expected: u64 = 0;
517    while let Some(submit) = submit_rx.recv().await {
518        pending.push(Reverse((submit.seq, submit)));
519        while let Some(Reverse((seq, _))) = pending.peek() {
520            if *seq != expected {
521                break;
522            }
523            let Reverse((_, s)) = pending.pop().unwrap();
524            let FlushSubmit {
525                rotated,
526                result,
527                ack,
528                ..
529            } = s;
530            let ack_result = match result {
531                Ok(outcome) => finalize_fn.finalize(rotated, outcome, shared.clone()).await,
532                Err(e) => {
533                    let _err = finalize_fn
534                        .finalize_failure(rotated, e, shared.clone())
535                        .await;
536                    Err(anyhow::anyhow!("flush stream failed: {}", _err))
537                }
538            };
539            if let Some(ack) = ack {
540                let _ = ack.send(ack_result);
541            }
542            pending_count.fetch_sub(1, Ordering::AcqRel);
543            drain_notify.notify_waiters();
544            expected += 1;
545        }
546    }
547}
548
549// We need a wrapper allowing FlushSubmit to be ordered by seq for the heap.
550// Default Ord on tuples uses the first element so (u64, FlushSubmit) needs
551// FlushSubmit to be Ord/PartialOrd. We don't actually compare FlushSubmits;
552// the seq is at position 0 of the tuple and the heap is keyed off it. To
553// avoid trait headaches we wrap manually:
554impl PartialEq for FlushSubmit {
555    fn eq(&self, other: &Self) -> bool {
556        self.seq == other.seq
557    }
558}
559impl Eq for FlushSubmit {}
560impl PartialOrd for FlushSubmit {
561    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
562        Some(self.cmp(other))
563    }
564}
565impl Ord for FlushSubmit {
566    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
567        self.seq.cmp(&other.seq)
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    /// H2: a panic inside the stream future must be converted into an `Err`
576    /// outcome (so the seq can still be submitted and the finalizer advances),
577    /// not propagated to abort the spawned task. A normal `Err` passes through
578    /// unchanged.
579    #[tokio::test]
580    async fn run_stream_catching_converts_panic_to_err() {
581        // (FlushOutcome isn't Debug, so we match instead of using expect_err.)
582        // A normal failure is forwarded verbatim.
583        let normal = run_stream_catching(async { Err(anyhow::anyhow!("normal failure")) }).await;
584        let normal_err = match normal {
585            Ok(_) => panic!("normal failure should stay an error"),
586            Err(e) => e,
587        };
588        assert!(normal_err.to_string().contains("normal failure"));
589
590        // A panic becomes an Err mentioning the panic, never unwinding.
591        let panicked = run_stream_catching(async {
592            panic!("boom in stream");
593            #[allow(unreachable_code)]
594            Ok(unreachable!())
595        })
596        .await;
597        let panic_err = match panicked {
598            Ok(_) => panic!("panic must be caught as an error"),
599            Err(e) => e,
600        };
601        assert!(
602            panic_err.to_string().contains("panicked"),
603            "error should identify the panic, got: {panic_err}"
604        );
605    }
606}