Skip to main content

asupersync/runtime/
spawn_blocking.rs

1//! Async wrapper for blocking pool operations.
2//!
3//! This module provides `spawn_blocking` helpers that run blocking closures on a
4//! runtime blocking pool when available, or a dedicated thread as a fallback.
5//!
6//! # Cancellation Safety
7//!
8//! When the returned future is dropped (cancelled), the blocking operation
9//! continues to run to completion on the background thread, but its result is
10//! discarded. This is the standard "soft cancellation" model for blocking
11//! operations.
12//!
13//! # Example
14//!
15//! ```ignore
16//! use asupersync::runtime::spawn_blocking;
17//! use std::io;
18//!
19//! async fn read_file(path: &str) -> io::Result<String> {
20//!     let path = path.to_string();
21//!     spawn_blocking(move || std::fs::read_to_string(&path)).await
22//! }
23//! ```
24
25use crate::cx::Cx;
26use crate::runtime::blocking_pool::{BlockingPoolHandle, BlockingTaskHandle};
27use parking_lot::Mutex;
28use std::sync::Arc;
29use std::sync::atomic::{AtomicUsize, Ordering};
30use std::task::Waker;
31use std::thread;
32
33/// Maximum number of concurrent fallback blocking threads (when no pool exists).
34/// Prevents unbounded thread creation under load.
35const MAX_FALLBACK_THREADS: usize = 256;
36
37/// Current number of active fallback blocking threads.
38static FALLBACK_THREAD_COUNT: AtomicUsize = AtomicUsize::new(0);
39
40struct CancelOnDrop {
41    handle: BlockingTaskHandle,
42    done: bool,
43}
44
45impl CancelOnDrop {
46    fn new(handle: BlockingTaskHandle) -> Self {
47        Self {
48            handle,
49            done: false,
50        }
51    }
52
53    fn mark_done(&mut self) {
54        self.done = true;
55    }
56}
57
58impl Drop for CancelOnDrop {
59    fn drop(&mut self) {
60        if !self.done {
61            self.handle.cancel();
62        }
63    }
64}
65
66struct BlockingOneshotState<T> {
67    result: Option<std::thread::Result<T>>,
68    waker: Option<Waker>,
69    done: bool,
70    closed_without_result: bool,
71}
72
73struct BlockingOneshot<T> {
74    state: Arc<Mutex<BlockingOneshotState<T>>>,
75    sent: bool,
76}
77
78impl<T> BlockingOneshot<T> {
79    fn new() -> (Self, BlockingOneshotReceiver<T>) {
80        let state = Arc::new(Mutex::new(BlockingOneshotState {
81            result: None,
82            waker: None,
83            done: false,
84            closed_without_result: false,
85        }));
86        (
87            Self {
88                state: state.clone(),
89                sent: false,
90            },
91            BlockingOneshotReceiver {
92                state,
93                completed: false,
94            },
95        )
96    }
97
98    fn send(mut self, val: std::thread::Result<T>) {
99        let waker = {
100            let mut guard = self.state.lock();
101            guard.result = Some(val);
102            guard.done = true;
103            guard.closed_without_result = false;
104            guard.waker.take()
105        };
106        self.sent = true;
107        if let Some(waker) = waker {
108            waker.wake();
109        }
110    }
111}
112
113impl<T> Drop for BlockingOneshot<T> {
114    fn drop(&mut self) {
115        if self.sent {
116            return;
117        }
118
119        let waker = {
120            let mut guard = self.state.lock();
121            if guard.done {
122                return;
123            }
124            guard.done = true;
125            guard.closed_without_result = true;
126            guard.waker.take()
127        };
128
129        if let Some(waker) = waker {
130            waker.wake();
131        }
132    }
133}
134
135struct BlockingOneshotReceiver<T> {
136    state: Arc<Mutex<BlockingOneshotState<T>>>,
137    completed: bool,
138}
139
140impl<T> Drop for BlockingOneshotReceiver<T> {
141    fn drop(&mut self) {
142        self.state.lock().waker = None;
143    }
144}
145
146impl<T> std::future::Future for BlockingOneshotReceiver<T> {
147    type Output = T;
148
149    fn poll(
150        self: std::pin::Pin<&mut Self>,
151        cx: &mut std::task::Context<'_>,
152    ) -> std::task::Poll<Self::Output> {
153        let this = self.get_mut();
154        assert!(
155            !this.completed,
156            "blocking operation polled after completion"
157        );
158
159        let mut guard = this.state.lock();
160        if guard.done {
161            this.completed = true;
162            let result = guard.result.take();
163            let closed_without_result = guard.closed_without_result;
164            drop(guard);
165
166            result.map_or_else(
167                || {
168                    if closed_without_result {
169                        panic!("blocking operation ended without producing a result"); // ubs:ignore - invariant violation
170                    } else {
171                        panic!("blocking operation polled after completion"); // ubs:ignore - invariant violation
172                    }
173                },
174                |result| match result {
175                    Ok(val) => std::task::Poll::Ready(val),
176                    Err(payload) => std::panic::resume_unwind(payload),
177                },
178            )
179        } else {
180            if !guard
181                .waker
182                .as_ref()
183                .is_some_and(|w| w.will_wake(cx.waker()))
184            {
185                guard.waker = Some(cx.waker().clone());
186            }
187            std::task::Poll::Pending
188        }
189    }
190}
191
192/// Spawns a blocking operation and returns a Future that yields until completion.
193///
194/// This function runs the provided closure on the runtime blocking pool when
195/// a current `Cx` is available, and falls back to a dedicated thread when
196/// no runtime context is set.
197///
198/// # Type Bounds
199///
200/// - `F: FnOnce() -> T + Send + 'static` - The closure must be sendable to another thread
201/// - `T: Send + 'static` - The return value must be sendable back
202///
203/// # Cancel Safety
204///
205/// If this future is dropped before completion, the blocking operation continues
206/// to run but its result is discarded.
207///
208/// # Panics
209///
210/// If the blocking operation panics, the panic is captured and re-raised when
211/// the future is awaited.
212pub async fn spawn_blocking<F, T>(f: F) -> T
213where
214    F: FnOnce() -> T + Send + 'static,
215    T: Send + 'static,
216{
217    if let Some(cx) = Cx::current() {
218        if let Some(pool) = cx.blocking_pool_handle() {
219            return spawn_blocking_on_pool(pool, f).await;
220        }
221        // Deterministic fallback when running inside a runtime without a pool.
222        return f();
223    }
224
225    spawn_blocking_on_thread(f).await
226}
227
228/// Spawns a blocking I/O operation and returns a Future.
229///
230/// Convenience wrapper around [`spawn_blocking`] for I/O operations.
231pub async fn spawn_blocking_io<F, T>(f: F) -> std::io::Result<T>
232where
233    F: FnOnce() -> std::io::Result<T> + Send + 'static,
234    T: Send + 'static,
235{
236    spawn_blocking(f).await
237}
238
239pub(crate) async fn spawn_blocking_on_pool<F, T>(pool: BlockingPoolHandle, f: F) -> T
240where
241    F: FnOnce() -> T + Send + 'static,
242    T: Send + 'static,
243{
244    let (tx, rx) = BlockingOneshot::new();
245    let handle = pool.spawn(move || {
246        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
247        tx.send(result);
248    });
249
250    let mut guard = CancelOnDrop::new(handle);
251    let result = rx.await;
252    guard.mark_done();
253    result
254}
255
256struct FallbackGuard;
257
258impl Drop for FallbackGuard {
259    fn drop(&mut self) {
260        FALLBACK_THREAD_COUNT.fetch_sub(1, Ordering::Release);
261    }
262}
263
264pub(crate) async fn spawn_blocking_on_thread<F, T>(f: F) -> T
265where
266    F: FnOnce() -> T + Send + 'static,
267    T: Send + 'static,
268{
269    // Wait until we are under the fallback thread limit to prevent unbounded
270    // thread creation when no blocking pool is available.
271    loop {
272        let current = FALLBACK_THREAD_COUNT.load(Ordering::Relaxed);
273        if current < MAX_FALLBACK_THREADS {
274            if FALLBACK_THREAD_COUNT
275                .compare_exchange_weak(current, current + 1, Ordering::Release, Ordering::Relaxed)
276                .is_ok()
277            {
278                break;
279            }
280        } else {
281            // Yield back to the executor instead of blocking the worker thread
282            // with a busy spin.
283            crate::runtime::yield_now::yield_now().await;
284        }
285    }
286
287    let (tx, rx) = BlockingOneshot::new();
288
289    // If thread spawn fails, run the closure inline instead of panicking.
290    // This keeps `spawn_blocking` usable under resource pressure.
291    let f_cell = Arc::new(Mutex::new(Some(f)));
292    let f_for_thread = Arc::clone(&f_cell);
293    let thread_result = thread::Builder::new()
294        .name("asupersync-blocking".to_string())
295        .spawn(move || {
296            let _guard = FallbackGuard;
297            let f = f_for_thread
298                .lock()
299                .take()
300                .expect("spawn_blocking_on_thread fn missing");
301            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
302            tx.send(result);
303        });
304
305    match thread_result {
306        Ok(_) => rx.await,
307        Err(_err) => {
308            FALLBACK_THREAD_COUNT.fetch_sub(1, Ordering::Release);
309            let f = f_cell
310                .lock()
311                .take()
312                .expect("spawn_blocking_on_thread fn missing");
313            f()
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    #![allow(
321        clippy::pedantic,
322        clippy::nursery,
323        clippy::expect_fun_call,
324        clippy::map_unwrap_or,
325        clippy::cast_possible_wrap,
326        clippy::future_not_send
327    )]
328    use super::*;
329    use crate::conformance::{ConformanceTarget, LabRuntimeTarget, TestConfig};
330    use crate::runtime::yield_now::yield_now;
331    use crate::types::{Budget, RegionId, TaskId};
332    use futures_lite::future;
333    use serde_json::Value;
334    use std::sync::Arc;
335    use std::sync::atomic::{AtomicU32, Ordering};
336    use std::sync::{Condvar, Mutex as StdMutex};
337    use std::time::Duration;
338
339    fn init_test(name: &str) {
340        crate::test_utils::init_test_logging();
341        crate::test_phase!(name);
342    }
343
344    #[test]
345    fn spawn_blocking_returns_result() {
346        init_test("spawn_blocking_returns_result");
347        future::block_on(async {
348            let result = spawn_blocking(|| 42).await;
349            crate::assert_with_log!(result == 42, "result", 42, result);
350        });
351        crate::test_complete!("spawn_blocking_returns_result");
352    }
353
354    #[test]
355    fn spawn_blocking_io_returns_result() {
356        init_test("spawn_blocking_io_returns_result");
357        future::block_on(async {
358            let result = spawn_blocking_io(|| Ok::<_, std::io::Error>(42))
359                .await
360                .unwrap();
361            crate::assert_with_log!(result == 42, "result", 42, result);
362        });
363        crate::test_complete!("spawn_blocking_io_returns_result");
364    }
365
366    #[test]
367    fn spawn_blocking_io_propagates_error() {
368        init_test("spawn_blocking_io_propagates_error");
369        future::block_on(async {
370            let result: std::io::Result<()> = spawn_blocking_io(|| {
371                Err(std::io::Error::new(
372                    std::io::ErrorKind::NotFound,
373                    "test error",
374                ))
375            })
376            .await;
377            crate::assert_with_log!(result.is_err(), "is error", true, result.is_err());
378        });
379        crate::test_complete!("spawn_blocking_io_propagates_error");
380    }
381
382    #[test]
383    fn spawn_blocking_captures_closure() {
384        init_test("spawn_blocking_captures_closure");
385        future::block_on(async {
386            let counter = Arc::new(AtomicU32::new(0));
387            let counter_clone = Arc::clone(&counter);
388
389            spawn_blocking(move || {
390                counter_clone.fetch_add(1, Ordering::Relaxed);
391            })
392            .await;
393
394            let count = counter.load(Ordering::Relaxed);
395            crate::assert_with_log!(count == 1, "counter incremented", 1u32, count);
396        });
397        crate::test_complete!("spawn_blocking_captures_closure");
398    }
399
400    #[test]
401    fn spawn_blocking_uses_pool_when_current() {
402        init_test("spawn_blocking_uses_pool_when_current");
403        let pool = crate::runtime::BlockingPool::new(1, 1);
404        let cx = Cx::new_with_drivers(
405            RegionId::new_for_test(0, 1),
406            TaskId::new_for_test(0, 0),
407            Budget::INFINITE,
408            None,
409            None,
410            None,
411            None,
412            None,
413        )
414        .with_blocking_pool_handle(Some(pool.handle()));
415
416        let _guard = Cx::set_current(Some(cx));
417
418        let thread_name = future::block_on(async {
419            spawn_blocking(|| {
420                std::thread::current()
421                    .name()
422                    .unwrap_or("unnamed")
423                    .to_string()
424            })
425            .await
426        });
427
428        crate::assert_with_log!(
429            thread_name.contains("-blocking-"),
430            "thread name uses pool",
431            true,
432            thread_name.contains("-blocking-")
433        );
434        crate::test_complete!("spawn_blocking_uses_pool_when_current");
435    }
436
437    #[test]
438    fn spawn_blocking_inline_when_no_pool() {
439        init_test("spawn_blocking_inline_when_no_pool");
440        let cx: Cx = Cx::for_testing();
441        let _guard = Cx::set_current(Some(cx));
442        let current_id = std::thread::current().id();
443
444        let thread_id =
445            future::block_on(async { spawn_blocking(|| std::thread::current().id()).await });
446
447        crate::assert_with_log!(
448            thread_id == current_id,
449            "same thread",
450            current_id,
451            thread_id
452        );
453        crate::test_complete!("spawn_blocking_inline_when_no_pool");
454    }
455
456    #[test]
457    fn spawn_blocking_runs_in_parallel() {
458        init_test("spawn_blocking_runs_in_parallel");
459        future::block_on(async {
460            let counter = Arc::new(AtomicU32::new(0));
461
462            let c1 = Arc::clone(&counter);
463            let h1 = spawn_blocking(move || {
464                thread::sleep(Duration::from_millis(10));
465                c1.fetch_add(1, Ordering::Relaxed);
466                1
467            });
468
469            let c2 = Arc::clone(&counter);
470            let h2 = spawn_blocking(move || {
471                thread::sleep(Duration::from_millis(10));
472                c2.fetch_add(1, Ordering::Relaxed);
473                2
474            });
475
476            // Since `spawn_blocking` is lazy, we must poll them concurrently
477            // to actually run the background threads in parallel.
478            let (r1, r2) = future::zip(h1, h2).await;
479
480            let count = counter.load(Ordering::Relaxed);
481            crate::assert_with_log!(count == 2, "both completed", 2u32, count);
482            crate::assert_with_log!(r1 == 1, "first result", 1, r1);
483            crate::assert_with_log!(r2 == 2, "second result", 2, r2);
484        });
485        crate::test_complete!("spawn_blocking_runs_in_parallel");
486    }
487
488    #[test]
489    fn spawn_blocking_pool_overflow_queues_under_lab_runtime() {
490        init_test("spawn_blocking_pool_overflow_queues_under_lab_runtime");
491
492        let config = TestConfig::new()
493            .with_seed(0x5A0B_B10C)
494            .with_tracing(true)
495            .with_max_steps(20_000);
496        let mut runtime = LabRuntimeTarget::create_runtime(config);
497        let pool = crate::runtime::BlockingPool::new(1, 1);
498        let pool_handle = pool.handle();
499        let checkpoints = Arc::new(StdMutex::new(Vec::<Value>::new()));
500        let gate = Arc::new((StdMutex::new(false), Condvar::new()));
501        let first_started = Arc::new(std::sync::atomic::AtomicBool::new(false));
502        let second_started = Arc::new(std::sync::atomic::AtomicBool::new(false));
503
504        let (first_value, second_value, queued_before_release, second_started_after, checkpoints) =
505            LabRuntimeTarget::block_on(&mut runtime, async move {
506                let cx = Cx::current().expect("lab runtime should install a current Cx");
507                let first_spawn_cx = cx.clone();
508                let second_spawn_cx = cx.clone();
509
510                let first_task = LabRuntimeTarget::spawn(&first_spawn_cx, Budget::INFINITE, {
511                    let pool_handle = pool_handle.clone();
512                    let checkpoints = Arc::clone(&checkpoints);
513                    let gate = Arc::clone(&gate);
514                    let first_started = Arc::clone(&first_started);
515                    async move {
516                        spawn_blocking_on_pool(pool_handle, move || {
517                            first_started.store(true, Ordering::SeqCst);
518                            let started = serde_json::json!({
519                                "phase": "first_started",
520                            });
521                            tracing::info!(event = %started, "spawn_blocking_lab_checkpoint");
522                            checkpoints
523                                .lock()
524                                .unwrap_or_else(std::sync::PoisonError::into_inner)
525                                .push(started);
526
527                            let (lock, cvar) = &*gate;
528                            let mut released = lock
529                                .lock()
530                                .unwrap_or_else(std::sync::PoisonError::into_inner);
531                            while !*released {
532                                released = cvar
533                                    .wait(released)
534                                    .unwrap_or_else(std::sync::PoisonError::into_inner);
535                            }
536
537                            let completed = serde_json::json!({
538                                "phase": "first_completed",
539                                "value": 11,
540                            });
541                            tracing::info!(event = %completed, "spawn_blocking_lab_checkpoint");
542                            checkpoints
543                                .lock()
544                                .unwrap_or_else(std::sync::PoisonError::into_inner)
545                                .push(completed);
546                            11
547                        })
548                        .await
549                    }
550                });
551
552                while !first_started.load(Ordering::SeqCst) {
553                    yield_now().await;
554                }
555
556                let second_task = LabRuntimeTarget::spawn(&second_spawn_cx, Budget::INFINITE, {
557                    let pool_handle = pool_handle.clone();
558                    let checkpoints = Arc::clone(&checkpoints);
559                    let second_started = Arc::clone(&second_started);
560                    async move {
561                        spawn_blocking_on_pool(pool_handle, move || {
562                            second_started.store(true, Ordering::SeqCst);
563                            let started = serde_json::json!({
564                                "phase": "second_started",
565                            });
566                            tracing::info!(event = %started, "spawn_blocking_lab_checkpoint");
567                            checkpoints
568                                .lock()
569                                .unwrap_or_else(std::sync::PoisonError::into_inner)
570                                .push(started);
571                            22
572                        })
573                        .await
574                    }
575                });
576
577                yield_now().await;
578                yield_now().await;
579
580                let queued_before_release = !second_started.load(Ordering::SeqCst);
581                let queued = serde_json::json!({
582                    "phase": "queue_observed",
583                    "second_started": second_started.load(Ordering::SeqCst),
584                });
585                tracing::info!(event = %queued, "spawn_blocking_lab_checkpoint");
586                checkpoints
587                    .lock()
588                    .unwrap_or_else(std::sync::PoisonError::into_inner)
589                    .push(queued);
590
591                {
592                    let (lock, cvar) = &*gate;
593                    let mut released = lock
594                        .lock()
595                        .unwrap_or_else(std::sync::PoisonError::into_inner);
596                    *released = true;
597                    cvar.notify_all();
598                }
599
600                let first_outcome = first_task.await;
601                crate::assert_with_log!(
602                    matches!(first_outcome, crate::types::Outcome::Ok(11)),
603                    "first blocking task completes successfully",
604                    true,
605                    matches!(first_outcome, crate::types::Outcome::Ok(11))
606                );
607                let crate::types::Outcome::Ok(first_value) = first_outcome else {
608                    panic!("first blocking task should finish successfully");
609                };
610
611                let second_outcome = second_task.await;
612                crate::assert_with_log!(
613                    matches!(second_outcome, crate::types::Outcome::Ok(22)),
614                    "second blocking task completes successfully",
615                    true,
616                    matches!(second_outcome, crate::types::Outcome::Ok(22))
617                );
618                let crate::types::Outcome::Ok(second_value) = second_outcome else {
619                    panic!("second blocking task should finish successfully");
620                };
621
622                (
623                    first_value,
624                    second_value,
625                    queued_before_release,
626                    second_started.load(Ordering::SeqCst),
627                    checkpoints
628                        .lock()
629                        .unwrap_or_else(std::sync::PoisonError::into_inner)
630                        .clone(),
631                )
632            });
633
634        assert_eq!(first_value, 11);
635        assert_eq!(second_value, 22);
636        assert!(
637            queued_before_release,
638            "second blocking task should remain queued while the single worker is occupied"
639        );
640        assert!(
641            second_started_after,
642            "second blocking task should eventually start after the first releases the worker"
643        );
644        assert!(
645            checkpoints
646                .iter()
647                .any(|event| event["phase"] == "first_started"),
648            "first task start checkpoint should be recorded"
649        );
650        assert!(
651            checkpoints.iter().any(|event| {
652                event["phase"] == "queue_observed" && event["second_started"] == false
653            }),
654            "queue observation checkpoint should record that the second task was still queued"
655        );
656        assert!(
657            checkpoints
658                .iter()
659                .any(|event| event["phase"] == "second_started"),
660            "second task start checkpoint should be recorded"
661        );
662
663        let violations = runtime.oracles.check_all(runtime.now());
664        assert!(
665            violations.is_empty(),
666            "spawn_blocking lab-runtime overflow test should leave runtime invariants clean: {violations:?}"
667        );
668        assert!(
669            pool.shutdown_and_wait(Duration::from_secs(1)),
670            "blocking pool should shut down cleanly after the test"
671        );
672    }
673
674    #[test]
675    fn blocking_oneshot_sender_drop_fails_closed() {
676        init_test("blocking_oneshot_sender_drop_fails_closed");
677        let (tx, rx) = BlockingOneshot::<u32>::new();
678        drop(tx);
679
680        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
681            future::block_on(rx);
682        }));
683
684        let payload = panic.expect_err("receiver should fail closed when sender drops");
685        let message = payload
686            .downcast_ref::<&str>()
687            .map(ToString::to_string)
688            .or_else(|| payload.downcast_ref::<String>().cloned())
689            .unwrap_or_default();
690
691        crate::assert_with_log!(
692            message.contains("without producing a result"),
693            "receiver panic message",
694            true,
695            message.contains("without producing a result")
696        );
697        crate::test_complete!("blocking_oneshot_sender_drop_fails_closed");
698    }
699
700    #[test]
701    fn blocking_oneshot_success_repoll_fails_closed() {
702        init_test("blocking_oneshot_success_repoll_fails_closed");
703        let (tx, rx) = BlockingOneshot::<u32>::new();
704        tx.send(Ok(42));
705
706        let mut rx = Box::pin(rx);
707        let first = future::block_on(std::future::poll_fn(|cx| rx.as_mut().poll(cx)));
708        crate::assert_with_log!(first == 42, "first result", 42u32, first);
709
710        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
711            future::block_on(std::future::poll_fn(|cx| rx.as_mut().poll(cx)));
712        }));
713
714        let payload = panic.expect_err("second poll should fail closed");
715        let message = payload
716            .downcast_ref::<&str>()
717            .map(ToString::to_string)
718            .or_else(|| payload.downcast_ref::<String>().cloned())
719            .unwrap_or_default();
720
721        crate::assert_with_log!(
722            message.contains("polled after completion"),
723            "repoll panic message",
724            true,
725            message.contains("polled after completion")
726        );
727        crate::test_complete!("blocking_oneshot_success_repoll_fails_closed");
728    }
729
730    #[test]
731    fn blocking_oneshot_sender_drop_repoll_fails_closed() {
732        init_test("blocking_oneshot_sender_drop_repoll_fails_closed");
733        let (tx, rx) = BlockingOneshot::<u32>::new();
734        drop(tx);
735
736        let mut rx = Box::pin(rx);
737        let first = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
738            future::block_on(std::future::poll_fn(|cx| rx.as_mut().poll(cx)));
739        }));
740        let first_payload = first.expect_err("first poll should fail closed on sender drop");
741        let first_message = first_payload
742            .downcast_ref::<&str>()
743            .map(ToString::to_string)
744            .or_else(|| first_payload.downcast_ref::<String>().cloned())
745            .unwrap_or_default();
746        crate::assert_with_log!(
747            first_message.contains("without producing a result"),
748            "first sender-drop panic message",
749            true,
750            first_message.contains("without producing a result")
751        );
752
753        let second = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
754            future::block_on(std::future::poll_fn(|cx| rx.as_mut().poll(cx)));
755        }));
756        let second_payload = second.expect_err("second poll should fail closed");
757        let second_message = second_payload
758            .downcast_ref::<&str>()
759            .map(ToString::to_string)
760            .or_else(|| second_payload.downcast_ref::<String>().cloned())
761            .unwrap_or_default();
762        crate::assert_with_log!(
763            second_message.contains("polled after completion"),
764            "second sender-drop panic message",
765            true,
766            second_message.contains("polled after completion")
767        );
768        crate::test_complete!("blocking_oneshot_sender_drop_repoll_fails_closed");
769    }
770
771    #[test]
772    #[should_panic(expected = "test panic")]
773    fn spawn_blocking_propagates_panic() {
774        future::block_on(async {
775            spawn_blocking(|| std::panic::panic_any("test panic")).await;
776        });
777    }
778}