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}