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    /// Hand off the finalizer task's JoinHandle for tracking by
273    /// `ShutdownHandle`. Returns `None` if already taken.
274    pub fn take_finalizer_handle(&self) -> Option<tokio::task::JoinHandle<()>> {
275        self.finalizer_handle.lock().take()
276    }
277
278    pub fn max_pending_flushes(&self) -> usize {
279        self.max_pending_flushes
280    }
281
282    pub async fn acquire_permit(&self) -> anyhow::Result<tokio::sync::OwnedSemaphorePermit> {
283        self.permits
284            .clone()
285            .acquire_owned()
286            .await
287            .map_err(|_| anyhow::anyhow!("flush coordinator permit semaphore closed"))
288    }
289
290    /// Non-blocking variant of [`Self::acquire_permit`]. Returns `None`
291    /// if the permit pool is at capacity. Used on the commit hot path
292    /// to avoid awaiting under `flush_lock`.
293    pub fn try_acquire_permit(&self) -> Option<tokio::sync::OwnedSemaphorePermit> {
294        self.permits.clone().try_acquire_owned().ok()
295    }
296
297    pub fn next_rotate_seq(&self) -> u64 {
298        self.next_seq.fetch_add(1, Ordering::AcqRel)
299    }
300
301    pub fn note_pending(&self) {
302        self.pending_count.fetch_add(1, Ordering::AcqRel);
303    }
304
305    pub fn pending_flush_count(&self) -> usize {
306        self.pending_count.load(Ordering::Acquire)
307    }
308
309    /// Submit a completed-stream flush for ordered finalization.
310    /// Silently drops the submission if the coordinator has been shut
311    /// down (submit_tx taken).
312    pub fn submit(
313        &self,
314        seq: u64,
315        rotated: RotatedFlush,
316        result: anyhow::Result<FlushOutcome>,
317        ack: Option<oneshot::Sender<anyhow::Result<String>>>,
318    ) {
319        let submit_msg = FlushSubmit {
320            seq,
321            rotated,
322            result,
323            ack,
324        };
325        if let Some(tx) = self.submit_tx.lock().as_ref() {
326            let _ = tx.send(submit_msg);
327        }
328        // else: coordinator is shutting down; pending_count will be
329        // decremented by the matching drop of submit_msg (RotatedFlush
330        // contains the FlushInProgressGuard which already adjusts
331        // flush_in_progress on drop). We must also decrement
332        // pending_count manually because the finalizer won't see this.
333        else {
334            self.pending_count
335                .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
336            self.drain_notify.notify_waiters();
337        }
338    }
339
340    /// Spawn the stream phase on a tokio task and return a [`FlushTicket`].
341    ///
342    /// `run_stream` is the closure that actually performs the L1 stream
343    /// work — it takes the rotate snapshot (`old_l0_arc`, `wal_lsn`,
344    /// `current_version`, `name`) and returns the built (but not yet
345    /// published) manifest as a `FlushOutcome`. The closure typically
346    /// captures `Arc<Writer>` so it can call `writer.flush_stream_l1`.
347    ///
348    /// On stream completion, the result and the consumed `RotatedFlush`
349    /// are sent through the coordinator's mpsc to the single-task
350    /// finalizer, which preserves rotate-order via a BinaryHeap.
351    ///
352    /// The returned `FlushTicket` resolves when finalize completes
353    /// (or fails). Dropping the ticket does NOT cancel the flush — the
354    /// pipeline runs to completion either way.
355    pub fn submit_for_stream<F, Fut>(
356        self: &Arc<Self>,
357        rotated: RotatedFlush,
358        run_stream: F,
359    ) -> FlushTicket
360    where
361        F: FnOnce(Arc<PlRwLock<crate::runtime::l0::L0Buffer>>, u64, u64, Option<String>) -> Fut
362            + Send
363            + 'static,
364        Fut: std::future::Future<Output = anyhow::Result<FlushOutcome>> + Send + 'static,
365    {
366        let (ack_tx, ack_rx) = oneshot::channel();
367        let coord = self.clone();
368        let seq = rotated.seq;
369        let old_l0 = rotated.old_l0_arc.clone();
370        let wal_lsn = rotated.wal_lsn;
371        let current_version = rotated.current_version;
372        let name = rotated.name.clone();
373        let stream_timeout = self.stream_timeout;
374        let handle = tokio::spawn(async move {
375            // The seq guard guarantees this seq is submitted even if the task's
376            // future is dropped before the normal `submit` below (issue #132).
377            let guard = FlushSeqGuard {
378                coord: coord.clone(),
379                seq,
380                rotated: Some(rotated),
381                ack: Some(ack_tx),
382            };
383            // `run_stream_catching` converts a panic into a submitted *failure*
384            // (review H2). `timeout` additionally converts a STALLED stream — a
385            // lost-wakeup in the sparse/multivec Lance read-modify-write — into
386            // a data-safe failure, so it can neither wedge the finalizer's
387            // consecutive-seq pipeline nor hold a back-pressure permit forever
388            // (issue #132). Both cases still finalize via `finalize_failure`,
389            // which retains the old L0 in `pending_flush` and the WAL data.
390            let stream_fut =
391                run_stream_catching(run_stream(old_l0, wal_lsn, current_version, name));
392            let result = match tokio::time::timeout(stream_timeout, stream_fut).await {
393                Ok(r) => r,
394                Err(_elapsed) => {
395                    tracing::error!(
396                        seq,
397                        timeout_secs = stream_timeout.as_secs(),
398                        "flush stream exceeded timeout; converting to a data-safe flush \
399                         failure (old L0 retained in pending_flush, WAL retained, \
400                         recovery via replay/retry)"
401                    );
402                    metrics::counter!("uni_flush_stream_timeouts_total").increment(1);
403                    Err(anyhow::anyhow!(
404                        "flush stream timed out after {:?} (seq {})",
405                        stream_timeout,
406                        seq
407                    ))
408                }
409            };
410            let (rotated, ack) = guard.disarm();
411            coord.submit(seq, rotated, result, ack);
412        });
413        // Track the handle so `shutdown()` can await all stream tasks'
414        // destructors. Opportunistically prune finished handles to keep
415        // the vec bounded under high flush rates.
416        let mut handles = self.stream_handles.lock();
417        handles.retain(|h| !h.is_finished());
418        handles.push(handle);
419        FlushTicket::pending(ack_rx)
420    }
421
422    /// Wait until pending_count drops to zero. Used by `drop_fork`.
423    pub async fn drain(&self, timeout: std::time::Duration) -> Result<(), &'static str> {
424        let deadline = tokio::time::Instant::now() + timeout;
425        loop {
426            if self.pending_flush_count() == 0 {
427                return Ok(());
428            }
429            let notified = self.drain_notify.notified();
430            tokio::select! {
431                _ = notified => continue,
432                _ = tokio::time::sleep_until(deadline) => {
433                    return if self.pending_flush_count() == 0 {
434                        Ok(())
435                    } else {
436                        Err("pending flushes did not drain before deadline")
437                    };
438                }
439            }
440        }
441    }
442}
443
444/// Closure run by the finalizer task. Captures the parts of Writer that
445/// finalize touches; runs without holding any Writer reference.
446///
447/// `Writer::flush_finalize_now` implements this and is bound to the
448/// concrete WAL/storage state.
449pub trait FinalizeFn: Send + Sync {
450    fn finalize<'a>(
451        &'a self,
452        rotated: RotatedFlush,
453        outcome: FlushOutcome,
454        shared: SharedFlushCtx,
455    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<String>> + Send + 'a>>;
456
457    fn finalize_failure<'a>(
458        &'a self,
459        rotated: RotatedFlush,
460        err: anyhow::Error,
461        shared: SharedFlushCtx,
462    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Error> + Send + 'a>>;
463}
464
465/// Run a stream-phase future, converting a panic into an `Err` outcome instead
466/// of letting it unwind the spawned task.
467///
468/// The async-flush finalizer advances `expected` strictly in consecutive seq
469/// order and only when a `seq` is submitted. If a stream future panicked and the
470/// task died without submitting, that seq would be missing forever and every
471/// later flush — plus `drain()`/`shutdown()` — would block. Converting the panic
472/// to a failed `FlushOutcome` keeps the pipeline live (the flush still fails, but
473/// `finalize_failure` advances past it). (review H2)
474async fn run_stream_catching<Fut>(fut: Fut) -> anyhow::Result<FlushOutcome>
475where
476    Fut: std::future::Future<Output = anyhow::Result<FlushOutcome>>,
477{
478    use futures::FutureExt as _;
479    match std::panic::AssertUnwindSafe(fut).catch_unwind().await {
480        Ok(result) => result,
481        Err(panic) => {
482            let msg = panic
483                .downcast_ref::<&str>()
484                .map(|s| (*s).to_string())
485                .or_else(|| panic.downcast_ref::<String>().cloned())
486                .unwrap_or_else(|| "unknown panic".to_string());
487            tracing::error!(panic = %msg, "flush stream task panicked");
488            Err(anyhow::anyhow!("flush stream task panicked: {msg}"))
489        }
490    }
491}
492
493async fn finalizer_loop(
494    mut submit_rx: mpsc::UnboundedReceiver<FlushSubmit>,
495    shared: SharedFlushCtx,
496    finalize_fn: Arc<dyn FinalizeFn>,
497    pending_count: Arc<std::sync::atomic::AtomicUsize>,
498    drain_notify: Arc<tokio::sync::Notify>,
499) {
500    // Reorder-by-seq using a min-heap; finalize strictly in seq order.
501    let mut pending: BinaryHeap<Reverse<(u64, FlushSubmit)>> = BinaryHeap::new();
502    let mut expected: u64 = 0;
503    while let Some(submit) = submit_rx.recv().await {
504        pending.push(Reverse((submit.seq, submit)));
505        while let Some(Reverse((seq, _))) = pending.peek() {
506            if *seq != expected {
507                break;
508            }
509            let Reverse((_, s)) = pending.pop().unwrap();
510            let FlushSubmit {
511                rotated,
512                result,
513                ack,
514                ..
515            } = s;
516            let ack_result = match result {
517                Ok(outcome) => finalize_fn.finalize(rotated, outcome, shared.clone()).await,
518                Err(e) => {
519                    let _err = finalize_fn
520                        .finalize_failure(rotated, e, shared.clone())
521                        .await;
522                    Err(anyhow::anyhow!("flush stream failed: {}", _err))
523                }
524            };
525            if let Some(ack) = ack {
526                let _ = ack.send(ack_result);
527            }
528            pending_count.fetch_sub(1, Ordering::AcqRel);
529            drain_notify.notify_waiters();
530            expected += 1;
531        }
532    }
533}
534
535// We need a wrapper allowing FlushSubmit to be ordered by seq for the heap.
536// Default Ord on tuples uses the first element so (u64, FlushSubmit) needs
537// FlushSubmit to be Ord/PartialOrd. We don't actually compare FlushSubmits;
538// the seq is at position 0 of the tuple and the heap is keyed off it. To
539// avoid trait headaches we wrap manually:
540impl PartialEq for FlushSubmit {
541    fn eq(&self, other: &Self) -> bool {
542        self.seq == other.seq
543    }
544}
545impl Eq for FlushSubmit {}
546impl PartialOrd for FlushSubmit {
547    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
548        Some(self.cmp(other))
549    }
550}
551impl Ord for FlushSubmit {
552    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
553        self.seq.cmp(&other.seq)
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    /// H2: a panic inside the stream future must be converted into an `Err`
562    /// outcome (so the seq can still be submitted and the finalizer advances),
563    /// not propagated to abort the spawned task. A normal `Err` passes through
564    /// unchanged.
565    #[tokio::test]
566    async fn run_stream_catching_converts_panic_to_err() {
567        // (FlushOutcome isn't Debug, so we match instead of using expect_err.)
568        // A normal failure is forwarded verbatim.
569        let normal = run_stream_catching(async { Err(anyhow::anyhow!("normal failure")) }).await;
570        let normal_err = match normal {
571            Ok(_) => panic!("normal failure should stay an error"),
572            Err(e) => e,
573        };
574        assert!(normal_err.to_string().contains("normal failure"));
575
576        // A panic becomes an Err mentioning the panic, never unwinding.
577        let panicked = run_stream_catching(async {
578            panic!("boom in stream");
579            #[allow(unreachable_code)]
580            Ok(unreachable!())
581        })
582        .await;
583        let panic_err = match panicked {
584            Ok(_) => panic!("panic must be caught as an error"),
585            Err(e) => e,
586        };
587        assert!(
588            panic_err.to_string().contains("panicked"),
589            "error should identify the panic, got: {panic_err}"
590        );
591    }
592}