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}