Skip to main content

submilli_engine/runtime/
blocking.rs

1//! Blocking work whose cleanup belongs to an execution, not its request.
2
3use std::fmt;
4
5// Match Tokio’s default blocking admission bound. Repository-lock waiters
6// must not consume Git’s smaller active-operation limit before taking the lock.
7static WORKER_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(512);
8
9/// A failure of the worker boundary, rather than an operation's returned error.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum BlockingWorkError {
12    WorkerPanicked,
13    WorkerCancelled,
14    ResultChannelClosed,
15    RuntimeUnavailable,
16    AllocationFailed,
17    ThreadSpawnFailed,
18}
19
20impl fmt::Display for BlockingWorkError {
21    fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result {
22        out.write_str(match self {
23            Self::WorkerPanicked => "blocking worker panicked",
24            Self::WorkerCancelled => "blocking worker was cancelled before returning",
25            Self::ResultChannelClosed => "blocking worker result channel closed before returning",
26            Self::RuntimeUnavailable => "blocking worker requires a Tokio runtime",
27            Self::AllocationFailed => "could not reserve blocking worker ownership records",
28            Self::ThreadSpawnFailed => "could not create blocking worker thread",
29        })
30    }
31}
32
33impl std::error::Error for BlockingWorkError {}
34
35/// All failures observed while draining abandoned workers.
36#[derive(Debug)]
37pub struct BlockingWorkDrainError {
38    errors: Vec<BlockingWorkError>,
39}
40
41impl BlockingWorkDrainError {
42    pub fn errors(&self) -> &[BlockingWorkError] {
43        &self.errors
44    }
45}
46
47impl fmt::Display for BlockingWorkDrainError {
48    fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result {
49        for (index, error) in self.errors.iter().enumerate() {
50            if index > 0 {
51                out.write_str("; ")?;
52            }
53            error.fmt(out)?;
54        }
55        Ok(())
56    }
57}
58
59impl std::error::Error for BlockingWorkDrainError {}
60
61/// Embedders must finish draining before releasing execution resources.
62/// Dropping a waiter leaves its actual blocking worker registered here.
63#[derive(Default)]
64pub struct BlockingWork {
65    pending: Vec<Worker>,
66    drain_failures: Vec<BlockingWorkError>,
67}
68
69impl BlockingWork {
70    /// Run a synchronous closure on an owned native thread and await its result.
71    /// At most 512 such threads are admitted across executions, independently
72    /// of the Tokio runtime’s blocking-pool configuration.
73    /// Cancellation leaves the join registered until another finish call drains it.
74    pub async fn spawn<T: Send + 'static>(
75        &mut self,
76        work: impl FnOnce() -> T + Send + 'static,
77    ) -> Result<T, BlockingWorkError> {
78        self.spawn_with_slots(work, &WORKER_SLOTS).await
79    }
80
81    async fn spawn_with_slots<T: Send + 'static>(
82        &mut self,
83        work: impl FnOnce() -> T + Send + 'static,
84        slots: &'static tokio::sync::Semaphore,
85    ) -> Result<T, BlockingWorkError> {
86        tokio::runtime::Handle::try_current().map_err(|_| BlockingWorkError::RuntimeUnavailable)?;
87        let permit = slots
88            .acquire()
89            .await
90            .map_err(|_| BlockingWorkError::WorkerCancelled)?;
91        self.reserve_worker()?;
92        let (sender, receiver) = tokio::sync::oneshot::channel();
93        let worker = Worker::start(
94            move || {
95                // Undelivered results are destroyed before the worker completes.
96                let _ = sender.send(work());
97            },
98            Some(permit),
99        )?;
100        self.pending.push(worker);
101        self.join_last().await?;
102        receive_result(receiver).await
103    }
104
105    /// Drain all owned work, even when one worker fails. Cancelling this future
106    /// retains unfinished joins and failures already observed for a later call.
107    pub async fn finish(&mut self) -> Result<(), BlockingWorkDrainError> {
108        while !self.pending.is_empty() {
109            if let Err(error) = self.join_last().await {
110                // spawn reserved space for every outstanding worker's failure.
111                self.drain_failures.push(error);
112            }
113        }
114        if self.drain_failures.is_empty() {
115            return Ok(());
116        }
117        Err(BlockingWorkDrainError {
118            errors: std::mem::take(&mut self.drain_failures),
119        })
120    }
121
122    fn reserve_worker(&mut self) -> Result<(), BlockingWorkError> {
123        let outstanding = self
124            .pending
125            .len()
126            .checked_add(1)
127            .ok_or(BlockingWorkError::AllocationFailed)?;
128        self.pending
129            .try_reserve(1)
130            .map_err(|_| BlockingWorkError::AllocationFailed)?;
131        self.drain_failures
132            .try_reserve(outstanding)
133            .map_err(|_| BlockingWorkError::AllocationFailed)?;
134        Ok(())
135    }
136
137    async fn join_last(&mut self) -> Result<(), BlockingWorkError> {
138        let worker = self
139            .pending
140            .last_mut()
141            .ok_or(BlockingWorkError::ResultChannelClosed)?;
142        worker.wait().await;
143        let worker = self
144            .pending
145            .pop()
146            .ok_or(BlockingWorkError::ResultChannelClosed)?;
147        worker.join()
148    }
149}
150
151struct Worker {
152    thread: std::thread::JoinHandle<Result<(), BlockingWorkError>>,
153    completed: Option<tokio::sync::oneshot::Receiver<()>>,
154}
155
156impl Worker {
157    fn start(
158        work: impl FnOnce() + Send + 'static,
159        permit: Option<tokio::sync::SemaphorePermit<'static>>,
160    ) -> Result<Self, BlockingWorkError> {
161        Self::start_with(std::thread::Builder::new(), work, permit)
162    }
163
164    fn start_with(
165        builder: std::thread::Builder,
166        work: impl FnOnce() + Send + 'static,
167        permit: Option<tokio::sync::SemaphorePermit<'static>>,
168    ) -> Result<Self, BlockingWorkError> {
169        let runtime = tokio::runtime::Handle::try_current()
170            .map_err(|_| BlockingWorkError::RuntimeUnavailable)?;
171        let (sender, completed) = tokio::sync::oneshot::channel();
172        let thread = builder
173            .spawn(move || {
174                let _permit = permit;
175                let _completion = Completion(Some(sender));
176                let _runtime = runtime.enter();
177                // This is the worker panic boundary, equivalent to the pool’s
178                // boundary. Payload cleanup stays on this owned thread.
179                match std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)) {
180                    Ok(()) => Ok(()),
181                    Err(payload) => {
182                        dispose_panic_payload(payload);
183                        Err(BlockingWorkError::WorkerPanicked)
184                    }
185                }
186            })
187            .map_err(|_| BlockingWorkError::ThreadSpawnFailed)?;
188        Ok(Self {
189            thread,
190            completed: Some(completed),
191        })
192    }
193
194    async fn wait(&mut self) {
195        if let Some(completed) = self.completed.as_mut() {
196            let _ = completed.await;
197            self.completed = None;
198        }
199        // Completion wakes the owner during thread teardown. Never synchronously
200        // join a thread whose remaining teardown could still block the executor.
201        while !self.thread.is_finished() {
202            tokio::task::yield_now().await;
203        }
204    }
205
206    fn join(self) -> Result<(), BlockingWorkError> {
207        match self.thread.join() {
208            Ok(result) => result,
209            Err(payload) => {
210                dispose_panic_payload(payload);
211                Err(BlockingWorkError::WorkerPanicked)
212            }
213        }
214    }
215}
216
217struct Completion(Option<tokio::sync::oneshot::Sender<()>>);
218
219impl Drop for Completion {
220    fn drop(&mut self) {
221        if let Some(sender) = self.0.take() {
222            let _ = sender.send(());
223        }
224    }
225}
226
227fn dispose_panic_payload(mut payload: Box<dyn std::any::Any + Send>) {
228    // Payload destructors are arbitrary embedder code. Dispose each payload
229    // outside an active unwind; a destructor's replacement panic is disposed
230    // iteratively, preserving ownership without recursive drop or leaking it.
231    loop {
232        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(payload))) {
233            Ok(()) => return,
234            Err(replacement) => payload = replacement,
235        }
236    }
237}
238
239async fn receive_result<T>(
240    receiver: tokio::sync::oneshot::Receiver<T>,
241) -> Result<T, BlockingWorkError> {
242    receiver
243        .await
244        .map_err(|_| BlockingWorkError::ResultChannelClosed)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use std::time::Duration;
251
252    #[tokio::test]
253    async fn worker_panics_return_errors_and_allow_follow_up() {
254        let mut workers = BlockingWork::default();
255        assert_eq!(
256            workers.spawn(|| panic!("injected worker panic")).await,
257            Err(BlockingWorkError::WorkerPanicked)
258        );
259        assert_eq!(
260            workers.spawn(|| std::panic::panic_any(42_u32)).await,
261            Err(BlockingWorkError::WorkerPanicked)
262        );
263        assert!(workers.finish().await.is_ok());
264        assert_eq!(workers.spawn(|| 42).await, Ok(42));
265        assert_eq!(
266            workers.spawn(|| Err::<(), _>("operation failed")).await,
267            Ok(Err("operation failed"))
268        );
269    }
270
271    #[test]
272    fn spawning_without_runtime_returns_error() {
273        let mut workers = BlockingWork::default();
274        let mut future = Box::pin(workers.spawn(|| 42));
275        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
276        assert!(matches!(
277            std::future::Future::poll(future.as_mut(), &mut context),
278            std::task::Poll::Ready(Err(BlockingWorkError::RuntimeUnavailable))
279        ));
280        drop(future);
281        assert!(workers.pending.is_empty());
282    }
283
284    #[tokio::test]
285    async fn closed_result_channel_returns_error() {
286        let (sender, receiver) = tokio::sync::oneshot::channel::<()>();
287        drop(sender);
288        assert_eq!(
289            receive_result(receiver).await,
290            Err(BlockingWorkError::ResultChannelClosed)
291        );
292    }
293
294    #[tokio::test]
295    async fn thread_admission_failure_returns_error_and_allows_follow_up() {
296        let result = Worker::start_with(
297            std::thread::Builder::new().stack_size(usize::MAX),
298            || {},
299            None,
300        );
301        assert!(matches!(result, Err(BlockingWorkError::ThreadSpawnFailed)));
302        let mut workers = BlockingWork::default();
303        assert_eq!(workers.spawn(|| 42).await, Ok(42));
304        workers.finish().await.unwrap();
305    }
306
307    #[tokio::test]
308    async fn panicking_payload_destructors_are_disposed_without_interrupting_drain() {
309        struct Payload(std::sync::Arc<std::sync::atomic::AtomicUsize>, u8);
310        impl Drop for Payload {
311            fn drop(&mut self) {
312                self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
313                if self.1 > 0 {
314                    std::panic::panic_any(Payload(self.0.clone(), self.1 - 1));
315                }
316            }
317        }
318        let drops = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
319        let mark = drops.clone();
320        let mut workers = BlockingWork::default();
321        assert_eq!(
322            workers
323                .spawn(move || std::panic::panic_any(Payload(mark, 3)))
324                .await,
325            Err(BlockingWorkError::WorkerPanicked)
326        );
327        assert_eq!(drops.load(std::sync::atomic::Ordering::SeqCst), 4);
328        assert_eq!(workers.spawn(|| 42).await, Ok(42));
329        workers.finish().await.unwrap();
330    }
331
332    #[tokio::test]
333    async fn native_workers_preserve_runtime_context() {
334        let mut workers = BlockingWork::default();
335        assert_eq!(
336            workers
337                .spawn(|| { tokio::runtime::Handle::current().block_on(async { 42 }) })
338                .await,
339            Ok(42)
340        );
341    }
342
343    #[tokio::test]
344    async fn admission_waits_for_abandoned_worker_cleanup_and_can_be_cancelled() {
345        static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
346        let mut first = BlockingWork::default();
347        let mut second = BlockingWork::default();
348        let (started, ready) = tokio::sync::oneshot::channel();
349        let (release, gate) = std::sync::mpsc::channel();
350        let mut waiter = Box::pin(first.spawn_with_slots(
351            move || {
352                started.send(()).unwrap();
353                gate.recv_timeout(Duration::from_secs(5)).unwrap();
354            },
355            &SLOTS,
356        ));
357        tokio::select! {
358            result = &mut waiter => panic!("worker ended early: {result:?}"),
359            result = ready => result.unwrap(),
360        }
361        drop(waiter);
362        let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
363        let mark = called.clone();
364        assert!(
365            tokio::time::timeout(
366                Duration::from_millis(20),
367                second.spawn_with_slots(
368                    move || mark.store(true, std::sync::atomic::Ordering::SeqCst),
369                    &SLOTS
370                )
371            )
372            .await
373            .is_err()
374        );
375        assert!(second.pending.is_empty());
376        assert!(!called.load(std::sync::atomic::Ordering::SeqCst));
377        release.send(()).unwrap();
378        first.finish().await.unwrap();
379        assert_eq!(second.spawn_with_slots(|| 42, &SLOTS).await, Ok(42));
380    }
381
382    #[tokio::test]
383    async fn completed_abandoned_worker_releases_admission_before_join() {
384        static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
385        let mut workers = BlockingWork::default();
386        let (started, ready) = tokio::sync::oneshot::channel();
387        let (release, gate) = std::sync::mpsc::channel();
388        let mut waiter = Box::pin(workers.spawn_with_slots(
389            move || {
390                started.send(()).unwrap();
391                gate.recv_timeout(Duration::from_secs(5)).unwrap();
392                panic!("abandoned failure");
393            },
394            &SLOTS,
395        ));
396        tokio::select! {
397            result = &mut waiter => panic!("worker ended early: {result:?}"),
398            result = ready => result.unwrap(),
399        }
400        drop(waiter);
401        release.send(()).unwrap();
402        assert_eq!(
403            tokio::time::timeout(
404                Duration::from_secs(5),
405                workers.spawn_with_slots(|| 42, &SLOTS)
406            )
407            .await
408            .unwrap(),
409            Ok(42)
410        );
411        assert_eq!(workers.pending.len(), 1);
412        assert_eq!(
413            workers.finish().await.unwrap_err().errors(),
414            &[BlockingWorkError::WorkerPanicked]
415        );
416    }
417
418    #[tokio::test]
419    async fn payload_cleanup_stays_owned_and_does_not_block_executor() {
420        struct Payload {
421            started: Option<tokio::sync::oneshot::Sender<()>>,
422            gate: std::sync::mpsc::Receiver<()>,
423        }
424        impl Drop for Payload {
425            fn drop(&mut self) {
426                self.started.take().unwrap().send(()).unwrap();
427                self.gate.recv_timeout(Duration::from_secs(5)).unwrap();
428                panic!("payload destructor failure");
429            }
430        }
431        let mut workers = BlockingWork::default();
432        let (started, ready) = tokio::sync::oneshot::channel();
433        let (release, gate) = std::sync::mpsc::channel();
434        let mut waiter = Box::pin(workers.spawn(move || {
435            std::panic::panic_any(Payload {
436                started: Some(started),
437                gate,
438            });
439        }));
440        tokio::select! {
441            result = &mut waiter => panic!("worker ended early: {result:?}"),
442            result = ready => result.unwrap(),
443        }
444        drop(waiter);
445        assert!(
446            tokio::time::timeout(Duration::from_millis(20), workers.finish())
447                .await
448                .is_err()
449        );
450        assert_eq!(workers.pending.len(), 1);
451        release.send(()).unwrap();
452        let failures = workers.finish().await.unwrap_err();
453        assert_eq!(failures.errors(), &[BlockingWorkError::WorkerPanicked]);
454        assert_eq!(workers.spawn(|| 42).await, Ok(42));
455    }
456
457    #[tokio::test]
458    async fn cancelled_waiter_retains_actual_worker_and_result_cleanup() {
459        struct Output(std::sync::Arc<std::sync::atomic::AtomicBool>);
460        impl Drop for Output {
461            fn drop(&mut self) {
462                self.0.store(true, std::sync::atomic::Ordering::SeqCst);
463            }
464        }
465        let mut workers = BlockingWork::default();
466        let (started, ready) = tokio::sync::oneshot::channel();
467        let (release, gate) = std::sync::mpsc::channel();
468        let dropped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
469        let mark = dropped.clone();
470        let mut waiter = Box::pin(workers.spawn(move || {
471            started.send(()).unwrap();
472            gate.recv_timeout(Duration::from_secs(5)).unwrap();
473            Output(mark)
474        }));
475        tokio::select! {
476            result = &mut waiter => panic!("worker returned early: {}", result.is_ok()),
477            result = ready => result.unwrap(),
478        }
479        drop(waiter);
480        assert_eq!(workers.spawn(|| 42).await, Ok(42));
481        assert_eq!(workers.pending.len(), 1);
482        let mut finish = Box::pin(workers.finish());
483        assert!(
484            tokio::time::timeout(Duration::from_millis(20), &mut finish)
485                .await
486                .is_err()
487        );
488        assert!(!dropped.load(std::sync::atomic::Ordering::SeqCst));
489        release.send(()).unwrap();
490        finish.await.unwrap();
491        assert!(dropped.load(std::sync::atomic::Ordering::SeqCst));
492    }
493
494    #[tokio::test]
495    async fn cancelled_drain_retains_handles_and_every_observed_failure() {
496        let mut workers = BlockingWork::default();
497        let (release, gate) = std::sync::mpsc::channel();
498        workers.reserve_worker().unwrap();
499        workers.pending.push(
500            Worker::start(
501                move || {
502                    gate.recv_timeout(Duration::from_secs(5)).unwrap();
503                    panic!("late failure");
504                },
505                None,
506            )
507            .unwrap(),
508        );
509        for _ in 0..2 {
510            workers.reserve_worker().unwrap();
511            let task = Worker::start(|| panic!("early failure"), None).unwrap();
512            while !task.thread.is_finished() {
513                tokio::task::yield_now().await;
514            }
515            workers.pending.push(task);
516        }
517        assert!(
518            tokio::time::timeout(Duration::from_millis(20), workers.finish())
519                .await
520                .is_err()
521        );
522        assert_eq!(workers.pending.len(), 1);
523        assert_eq!(
524            workers.drain_failures,
525            vec![BlockingWorkError::WorkerPanicked; 2]
526        );
527        release.send(()).unwrap();
528        let errors = workers.finish().await.unwrap_err();
529        assert_eq!(errors.errors(), &[BlockingWorkError::WorkerPanicked; 3]);
530        assert!(workers.pending.is_empty());
531        assert!(workers.finish().await.is_ok());
532    }
533}