Skip to main content

ssg/core/
io_pool.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Bounded writer-thread pool that decouples disk writes from rayon
5//! CPU workers (issue #569, phase 1).
6//!
7//! Rayon worker threads that call `fs::write` directly stall a CPU
8//! slot for the duration of the syscall. [`IoPool`] moves those
9//! writes onto a small dedicated pool of writer threads (2–4) fed by
10//! a **bounded** `std::sync::mpsc` channel: producers enqueue
11//! `{path, bytes}` jobs with [`IoPool::write`] and the bounded
12//! channel provides natural backpressure (a full queue blocks the
13//! sender instead of buffering unbounded memory).
14//!
15//! # Design constraints
16//!
17//! - **std-only, tokio-free** — per
18//!   [ADR-0001](../../docs/adr/0001-tokio-free.md), `ssg` runs one
19//!   scheduler (rayon) plus plain OS threads; no async executor is
20//!   introduced here.
21//! - **`io_uring` is out of scope** — that is phase 2 of issue #569
22//!   (v0.0.48+, Linux-only feature flag). This module is the
23//!   thread-pool backend only.
24//! - **No silent data loss** — every write error is captured and
25//!   surfaced by [`IoPool::flush`]. Dropping the pool without a
26//!   final `flush()` still drains and joins the writers; any errors
27//!   that were never observed via `flush()` are logged at `error`
28//!   level from `Drop`.
29//!
30//! # Flush semantics
31//!
32//! [`IoPool::flush`] is a *barrier*, not a shutdown: it blocks until
33//! every job enqueued so far has been fully processed (written or
34//! failed), then reports the first captured error (logging any
35//! additional ones). The pool remains usable afterwards, so a build
36//! phase can `flush()` between batches and reuse the same threads.
37//!
38//! # Examples
39//!
40//! ```rust
41//! use ssg::io_pool::IoPool;
42//! use tempfile::tempdir;
43//!
44//! let dir = tempdir().unwrap();
45//! let pool = IoPool::new();
46//! pool.write(dir.path().join("a.html"), b"<p>a</p>".to_vec()).unwrap();
47//! pool.write(dir.path().join("b.html"), b"<p>b</p>".to_vec()).unwrap();
48//! pool.flush().unwrap(); // barrier: both files are durably on disk
49//! assert_eq!(std::fs::read(dir.path().join("a.html")).unwrap(), b"<p>a</p>");
50//! ```
51
52use std::fs;
53use std::io;
54use std::path::{Path, PathBuf};
55use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
56use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
57use std::thread::JoinHandle;
58
59use crate::error::{PathErrorExt, SsgError};
60
61/// Queue capacity per writer thread. Small enough that a slow disk
62/// exerts backpressure on producers quickly, large enough to keep
63/// the writers busy between producer bursts.
64const QUEUE_CAP_PER_WORKER: usize = 32;
65
66/// Hard ceiling on writer threads — disk write throughput saturates
67/// with very few writers; more threads only add seek contention.
68const MAX_WRITERS: usize = 4;
69
70/// A single queued write: destination path plus the full contents.
71#[derive(Debug)]
72struct WriteJob {
73    path: PathBuf,
74    bytes: Vec<u8>,
75}
76
77/// Mutable pool state shared between producers, workers, and
78/// `flush()` waiters.
79#[derive(Debug, Default)]
80struct StateInner {
81    /// Jobs enqueued but not yet fully processed (written or failed).
82    pending: usize,
83    /// Write failures captured since the last `flush()`.
84    errors: Vec<(PathBuf, io::Error)>,
85    /// Successfully completed writes since pool creation.
86    completed: usize,
87}
88
89/// Shared synchronization block: state guarded by a mutex plus the
90/// condvar `flush()` waits on.
91#[derive(Debug)]
92struct PoolState {
93    inner: Mutex<StateInner>,
94    all_done: Condvar,
95}
96
97impl PoolState {
98    /// Locks the inner state, recovering from a poisoned mutex.
99    ///
100    /// Workers never panic while holding the lock (the write happens
101    /// outside the critical section), so poison recovery is safe: the
102    /// counters are always internally consistent.
103    fn lock(&self) -> MutexGuard<'_, StateInner> {
104        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
105    }
106}
107
108/// A small pool of dedicated writer threads fed by a bounded channel.
109///
110/// See the [module docs](self) for the full design rationale
111/// (issue #569 phase 1, ADR-0001).
112///
113/// # Examples
114///
115/// ```rust
116/// use ssg::io_pool::IoPool;
117/// use tempfile::tempdir;
118///
119/// let dir = tempdir().unwrap();
120/// let pool = IoPool::with_threads(2);
121/// pool.write(dir.path().join("page.html"), b"<html/>".to_vec()).unwrap();
122/// pool.flush().unwrap();
123/// assert!(dir.path().join("page.html").exists());
124/// ```
125#[derive(Debug)]
126pub struct IoPool {
127    /// `Some` while the pool is live; taken (dropped) in `Drop` to
128    /// close the channel and let workers drain + exit.
129    tx: Option<SyncSender<WriteJob>>,
130    /// Writer thread handles, joined in `Drop`.
131    workers: Vec<JoinHandle<()>>,
132    /// Shared pending/error accounting.
133    state: Arc<PoolState>,
134}
135
136impl Default for IoPool {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142impl IoPool {
143    /// Creates a pool with the default writer count:
144    /// `min(4, max(1, available_parallelism / 2))`.
145    ///
146    /// # Examples
147    ///
148    /// ```rust
149    /// use ssg::io_pool::IoPool;
150    ///
151    /// let pool = IoPool::new();
152    /// pool.flush().unwrap(); // empty pool flushes trivially
153    /// ```
154    #[must_use]
155    pub fn new() -> Self {
156        Self::with_threads(default_writer_threads())
157    }
158
159    /// Creates a pool with an explicit writer-thread count.
160    ///
161    /// `threads` is clamped to the `1..=4` range: zero writers would
162    /// deadlock producers, and more than four writers only adds seek
163    /// contention on the output disk.
164    ///
165    /// # Examples
166    ///
167    /// ```rust
168    /// use ssg::io_pool::IoPool;
169    ///
170    /// let pool = IoPool::with_threads(0); // clamped to 1
171    /// pool.flush().unwrap();
172    /// ```
173    #[must_use]
174    pub fn with_threads(threads: usize) -> Self {
175        let threads = threads.clamp(1, MAX_WRITERS);
176        let (tx, rx) = sync_channel::<WriteJob>(threads * QUEUE_CAP_PER_WORKER);
177        let rx = Arc::new(Mutex::new(rx));
178        let state = Arc::new(PoolState {
179            inner: Mutex::new(StateInner::default()),
180            all_done: Condvar::new(),
181        });
182
183        let workers = (0..threads)
184            .map(|i| spawn_writer(i, Arc::clone(&rx), Arc::clone(&state)))
185            .filter_map(|handle| match handle {
186                Ok(h) => Some(h),
187                Err(e) => {
188                    log::error!("io_pool: failed to spawn writer thread: {e}");
189                    None
190                }
191            })
192            .collect::<Vec<_>>();
193
194        // If *no* thread could be spawned, fall back to a degenerate
195        // pool whose `write` performs the I/O inline — never deadlock.
196        Self {
197            tx: if workers.is_empty() { None } else { Some(tx) },
198            workers,
199            state,
200        }
201    }
202
203    /// Enqueues a write of `bytes` to `path`.
204    ///
205    /// Blocks when the bounded queue is full (backpressure). The
206    /// write itself happens asynchronously on a writer thread; any
207    /// failure is captured and reported by the next [`flush`].
208    ///
209    /// Returns an error only if the job could not be enqueued at all
210    /// (all writer threads gone); in the degenerate zero-worker
211    /// fallback the write is performed inline instead.
212    ///
213    /// [`flush`]: IoPool::flush
214    ///
215    /// # Examples
216    ///
217    /// ```rust
218    /// use ssg::io_pool::IoPool;
219    /// use tempfile::tempdir;
220    ///
221    /// let dir = tempdir().unwrap();
222    /// let pool = IoPool::new();
223    /// pool.write(dir.path().join("x.txt"), b"x".to_vec()).unwrap();
224    /// pool.flush().unwrap();
225    /// ```
226    pub fn write(
227        &self,
228        path: impl Into<PathBuf>,
229        bytes: Vec<u8>,
230    ) -> Result<(), SsgError> {
231        let path = path.into();
232        let Some(tx) = self.tx.as_ref() else {
233            // Degenerate fallback: no writer threads — write inline
234            // so no job is ever silently dropped.
235            perform_write(&path, &bytes).with_path(&path)?;
236            self.state.lock().completed += 1;
237            return Ok(());
238        };
239
240        // Count the job as pending *before* it enters the queue so a
241        // concurrent `flush()` cannot slip past it.
242        self.state.lock().pending += 1;
243
244        if let Err(e) = tx.send(WriteJob { path, bytes }) {
245            // Channel disconnected: workers are gone. Undo the
246            // accounting and surface a typed error.
247            let mut inner = self.state.lock();
248            inner.pending -= 1;
249            if inner.pending == 0 {
250                self.state.all_done.notify_all();
251            }
252            drop(inner);
253            let job = e.0;
254            return Err(SsgError::Io {
255                path: job.path,
256                source: io::Error::other(
257                    "io_pool: writer threads terminated; job not enqueued",
258                ),
259            });
260        }
261        Ok(())
262    }
263
264    /// Barrier: blocks until every job enqueued so far is fully
265    /// processed, then reports write failures.
266    ///
267    /// Returns the **first** captured error (with its path); any
268    /// additional failures are logged at `error` level so nothing is
269    /// silently dropped. The error buffer is cleared, and the pool
270    /// stays alive — `flush()` does **not** shut the pool down and
271    /// may be called repeatedly.
272    ///
273    /// # Examples
274    ///
275    /// ```rust
276    /// use ssg::io_pool::IoPool;
277    ///
278    /// let pool = IoPool::new();
279    /// // Writing into a directory that does not exist fails at flush.
280    /// pool.write("/nonexistent-ssg-dir/x.txt", b"x".to_vec()).unwrap();
281    /// assert!(pool.flush().is_err());
282    /// // The pool remains usable after a failed flush.
283    /// pool.flush().unwrap();
284    /// ```
285    pub fn flush(&self) -> Result<(), SsgError> {
286        let mut inner = self.state.lock();
287        while inner.pending > 0 {
288            inner = self
289                .state
290                .all_done
291                .wait(inner)
292                .unwrap_or_else(PoisonError::into_inner);
293        }
294        let errors = std::mem::take(&mut inner.errors);
295        drop(inner);
296
297        let mut iter = errors.into_iter();
298        let Some((first_path, first_err)) = iter.next() else {
299            return Ok(());
300        };
301        for (path, err) in iter {
302            log::error!(
303                "io_pool: additional write failure at '{}': {err}",
304                path.display()
305            );
306        }
307        Err(SsgError::Io {
308            path: first_path,
309            source: first_err,
310        })
311    }
312
313    /// Number of writes that have completed successfully since the
314    /// pool was created. Primarily useful for tests and diagnostics.
315    ///
316    /// Note: this is a live counter; call [`IoPool::flush`] first for
317    /// a stable reading.
318    ///
319    /// # Examples
320    ///
321    /// ```rust
322    /// use ssg::io_pool::IoPool;
323    /// use tempfile::tempdir;
324    ///
325    /// let dir = tempdir().unwrap();
326    /// let pool = IoPool::new();
327    /// assert_eq!(pool.completed_writes(), 0);
328    /// pool.write(dir.path().join("y.txt"), b"y".to_vec()).unwrap();
329    /// pool.flush().unwrap();
330    /// assert_eq!(pool.completed_writes(), 1);
331    /// ```
332    #[must_use]
333    pub fn completed_writes(&self) -> usize {
334        self.state.lock().completed
335    }
336
337    /// Number of writer threads backing this pool.
338    ///
339    /// # Examples
340    ///
341    /// ```rust
342    /// use ssg::io_pool::IoPool;
343    ///
344    /// assert_eq!(IoPool::with_threads(9).threads(), 4); // clamped
345    /// assert!(IoPool::new().threads() >= 1);
346    /// ```
347    #[must_use]
348    pub const fn threads(&self) -> usize {
349        self.workers.len()
350    }
351}
352
353impl Drop for IoPool {
354    /// Drains and joins: closes the channel so workers finish every
355    /// queued job, then joins them. Errors never observed through
356    /// [`IoPool::flush`] are logged — not silently discarded.
357    fn drop(&mut self) {
358        // Closing the sender makes `recv()` return `Err` once the
359        // queue is empty, so workers drain naturally then exit.
360        drop(self.tx.take());
361        for handle in self.workers.drain(..) {
362            if handle.join().is_err() {
363                log::error!("io_pool: writer thread panicked");
364            }
365        }
366        let inner = self.state.lock();
367        for (path, err) in &inner.errors {
368            log::error!(
369                "io_pool: write failure at '{}' dropped without flush(): {err}",
370                path.display()
371            );
372        }
373    }
374}
375
376/// Spawns one named writer thread, with a fault-injection point so
377/// tests can drive the "no writer threads could be spawned"
378/// degenerate-pool fallback (feature `test-fault-injection`).
379fn spawn_writer(
380    index: usize,
381    rx: Arc<Mutex<Receiver<WriteJob>>>,
382    state: Arc<PoolState>,
383) -> io::Result<JoinHandle<()>> {
384    fail_point!("io_pool::spawn", |_| {
385        Err(io::Error::other("injected: io_pool::spawn"))
386    });
387    std::thread::Builder::new()
388        .name(format!("ssg-io-writer-{index}"))
389        .spawn(move || worker_loop(&rx, &state))
390}
391
392/// Default writer-thread count: half the available cores, floored at
393/// 1 and capped at [`MAX_WRITERS`].
394fn default_writer_threads() -> usize {
395    let cores = std::thread::available_parallelism()
396        .map_or(2, std::num::NonZeroUsize::get);
397    (cores / 2).clamp(1, MAX_WRITERS)
398}
399
400/// Writer-thread body: pull jobs until the channel closes.
401fn worker_loop(rx: &Mutex<Receiver<WriteJob>>, state: &PoolState) {
402    loop {
403        // Hold the receiver lock only while dequeuing, never during
404        // the write itself.
405        let job = {
406            let guard = rx.lock().unwrap_or_else(PoisonError::into_inner);
407            guard.recv()
408        };
409        let Ok(job) = job else {
410            return; // channel closed and drained — pool is dropping
411        };
412
413        let result = perform_write(&job.path, &job.bytes);
414
415        let mut inner = state.lock();
416        match result {
417            Ok(()) => inner.completed += 1,
418            Err(e) => inner.errors.push((job.path, e)),
419        }
420        inner.pending -= 1;
421        if inner.pending == 0 {
422            state.all_done.notify_all();
423        }
424    }
425}
426
427/// The actual disk write, with a fault-injection point mirroring the
428/// naming convention used by the other fs paths
429/// (`tests/fault_injection.rs`, feature `test-fault-injection`).
430fn perform_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
431    fail_point!("io_pool::write", |_| {
432        Err(io::Error::other("injected: io_pool::write"))
433    });
434    fs::write(path, bytes)
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use rayon::prelude::*;
441    use tempfile::tempdir;
442
443    /// Extracts the path from an `SsgError::Io`, `None` otherwise.
444    ///
445    /// Used instead of inline `match … => panic!` so both arms are
446    /// exercised (see `io_error_path_returns_none_for_non_io`).
447    fn io_error_path(err: &SsgError) -> Option<PathBuf> {
448        match err {
449            SsgError::Io { path, .. } => Some(path.clone()),
450            _ => None,
451        }
452    }
453
454    #[test]
455    #[serial_test::parallel(io_pool_failpoints)]
456    fn io_error_path_returns_none_for_non_io() {
457        let err = SsgError::Validation {
458            field: "f".to_string(),
459            message: "m".to_string(),
460        };
461        assert!(io_error_path(&err).is_none());
462    }
463
464    #[test]
465    #[serial_test::parallel(io_pool_failpoints)]
466    fn default_pool_behaves_like_new() {
467        let pool = IoPool::default();
468        assert!(pool.threads() >= 1);
469        assert!(pool.flush().is_ok());
470    }
471
472    #[test]
473    #[serial_test::parallel(io_pool_failpoints)]
474    fn flush_logs_additional_errors_and_returns_first() {
475        crate::test_support::init_logger();
476        let dir = tempdir().unwrap();
477        let bad_a = dir.path().join("no-dir-a").join("a.html");
478        let bad_b = dir.path().join("no-dir-b").join("b.html");
479
480        let pool = IoPool::with_threads(1);
481        pool.write(&bad_a, b"a".to_vec()).unwrap();
482        pool.write(&bad_b, b"b".to_vec()).unwrap();
483
484        // Both writes fail; flush returns the first error and logs
485        // the second (the additional-failure loop body executes).
486        let err = pool.flush().expect_err("flush must fail");
487        let path = io_error_path(&err).expect("must be an Io error");
488        assert!(path == bad_a || path == bad_b);
489
490        // Error buffer is cleared afterwards.
491        assert!(pool.flush().is_ok());
492    }
493
494    #[test]
495    #[serial_test::parallel(io_pool_failpoints)]
496    fn drop_without_flush_logs_unobserved_errors() {
497        crate::test_support::init_logger();
498        let dir = tempdir().unwrap();
499        let bad = dir.path().join("missing-dir").join("x.html");
500        {
501            let pool = IoPool::with_threads(1);
502            pool.write(&bad, b"x".to_vec()).unwrap();
503            // Give the worker time to fail the write so the error is
504            // buffered before the drop (drop drains regardless, but
505            // this makes the captured-error path deterministic).
506            while pool.state.lock().pending > 0 {
507                std::thread::yield_now();
508            }
509            // No flush() — Drop must log the unobserved failure.
510        }
511    }
512
513    #[test]
514    #[serial_test::parallel(io_pool_failpoints)]
515    fn empty_pool_flush_is_ok() {
516        let pool = IoPool::new();
517        assert!(pool.flush().is_ok());
518        assert_eq!(pool.completed_writes(), 0);
519    }
520
521    #[test]
522    #[serial_test::parallel(io_pool_failpoints)]
523    fn thread_count_is_clamped() {
524        assert_eq!(IoPool::with_threads(0).threads(), 1);
525        assert_eq!(IoPool::with_threads(100).threads(), MAX_WRITERS);
526        let d = default_writer_threads();
527        assert!((1..=MAX_WRITERS).contains(&d));
528    }
529
530    #[test]
531    #[serial_test::parallel(io_pool_failpoints)]
532    fn concurrent_rayon_producers_all_bytes_correct() {
533        let dir = tempdir().unwrap();
534        let pool = IoPool::with_threads(3);
535        let n = 200usize;
536
537        (0..n)
538            .into_par_iter()
539            .try_for_each(|i| {
540                pool.write(
541                    dir.path().join(format!("f{i}.html")),
542                    format!("<p>page {i}</p>").into_bytes(),
543                )
544            })
545            .unwrap();
546
547        pool.flush().unwrap();
548        assert_eq!(pool.completed_writes(), n);
549
550        for i in 0..n {
551            let got = fs::read_to_string(dir.path().join(format!("f{i}.html")))
552                .unwrap();
553            assert_eq!(got, format!("<p>page {i}</p>"));
554        }
555    }
556
557    #[test]
558    #[serial_test::parallel(io_pool_failpoints)]
559    fn write_error_surfaces_at_flush_and_pool_survives() {
560        let dir = tempdir().unwrap();
561        let missing = dir.path().join("no-such-subdir").join("x.html");
562
563        let pool = IoPool::with_threads(2);
564        pool.write(&missing, b"x".to_vec()).unwrap(); // enqueue OK
565        let err = pool.flush().expect_err("flush must surface the failure");
566        let path = io_error_path(&err)
567            .expect("flush error must be SsgError::Io with a path");
568        assert_eq!(path, missing);
569
570        // Error buffer cleared; pool still functional.
571        assert!(pool.flush().is_ok());
572        pool.write(dir.path().join("ok.html"), b"ok".to_vec())
573            .unwrap();
574        pool.flush().unwrap();
575        assert_eq!(
576            fs::read_to_string(dir.path().join("ok.html")).unwrap(),
577            "ok"
578        );
579    }
580
581    #[cfg(unix)]
582    #[test]
583    #[serial_test::parallel(io_pool_failpoints)]
584    fn unwritable_dir_error_surfaces_at_flush() {
585        use std::os::unix::fs::PermissionsExt;
586
587        let dir = tempdir().unwrap();
588        let ro = dir.path().join("readonly");
589        fs::create_dir(&ro).unwrap();
590        fs::set_permissions(&ro, fs::Permissions::from_mode(0o555)).unwrap();
591
592        let pool = IoPool::with_threads(2);
593        pool.write(ro.join("blocked.html"), b"x".to_vec()).unwrap();
594        assert!(pool.flush().is_err());
595
596        // Restore permissions so tempdir cleanup succeeds.
597        fs::set_permissions(&ro, fs::Permissions::from_mode(0o755)).unwrap();
598    }
599
600    #[test]
601    #[serial_test::parallel(io_pool_failpoints)]
602    fn drop_without_flush_completes_queued_writes() {
603        let dir = tempdir().unwrap();
604        {
605            let pool = IoPool::with_threads(2);
606            for i in 0..50 {
607                pool.write(
608                    dir.path().join(format!("d{i}.txt")),
609                    format!("v{i}").into_bytes(),
610                )
611                .unwrap();
612            }
613            // No flush — Drop must drain the queue and join.
614        }
615        for i in 0..50 {
616            assert_eq!(
617                fs::read_to_string(dir.path().join(format!("d{i}.txt")))
618                    .unwrap(),
619                format!("v{i}")
620            );
621        }
622    }
623
624    #[test]
625    #[serial_test::parallel(io_pool_failpoints)]
626    fn flush_is_reusable_across_batches() {
627        let dir = tempdir().unwrap();
628        let pool = IoPool::with_threads(2);
629
630        pool.write(dir.path().join("a.txt"), b"a".to_vec()).unwrap();
631        pool.flush().unwrap();
632        assert_eq!(pool.completed_writes(), 1);
633
634        pool.write(dir.path().join("b.txt"), b"b".to_vec()).unwrap();
635        pool.flush().unwrap();
636        assert_eq!(pool.completed_writes(), 2);
637
638        assert_eq!(fs::read_to_string(dir.path().join("a.txt")).unwrap(), "a");
639        assert_eq!(fs::read_to_string(dir.path().join("b.txt")).unwrap(), "b");
640    }
641
642    #[test]
643    #[serial_test::parallel(io_pool_failpoints)]
644    fn poisoned_state_lock_recovers_via_into_inner() {
645        // `PoolState::lock` recovers from a poisoned mutex instead of
646        // panicking (see its doc comment). Workers never panic while
647        // holding the lock in normal operation, so the only way to
648        // reach this branch in a test is to poison it directly.
649        let state = Arc::new(PoolState {
650            inner: Mutex::new(StateInner::default()),
651            all_done: Condvar::new(),
652        });
653        let poison_state = Arc::clone(&state);
654        let handle = std::thread::spawn(move || {
655            let _guard = poison_state.inner.lock().unwrap();
656            panic!("deliberately poison the mutex for test coverage");
657        });
658        assert!(handle.join().is_err());
659
660        // Recovers instead of panicking; state is still readable.
661        let guard = state.lock();
662        assert_eq!(guard.pending, 0);
663    }
664
665    #[test]
666    #[serial_test::parallel(io_pool_failpoints)]
667    fn flush_condvar_wait_recovers_from_poison() {
668        // `flush()`'s `all_done.wait(...).unwrap_or_else(...)` also
669        // recovers from a poisoned mutex. Reproduced directly against
670        // a bare `PoolState` (mirroring `flush`'s wait loop) since
671        // driving it through a live `IoPool` cannot deterministically
672        // poison the lock while a waiter is parked in `wait()`.
673        let state = Arc::new(PoolState {
674            inner: Mutex::new(StateInner {
675                pending: 1,
676                ..StateInner::default()
677            }),
678            all_done: Condvar::new(),
679        });
680
681        let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
682        let waiter_state = Arc::clone(&state);
683        let waiter = std::thread::spawn(move || {
684            let mut inner = waiter_state.lock();
685            let _ = ready_tx.send(());
686            while inner.pending > 0 {
687                inner = waiter_state
688                    .all_done
689                    .wait(inner)
690                    .unwrap_or_else(PoisonError::into_inner);
691            }
692            inner.pending
693        });
694
695        // The waiter signals only after acquiring the lock while
696        // `pending == 1`, so it is guaranteed to call `Condvar::wait`
697        // next (which releases the mutex while parked); poll until
698        // that release is observable before poisoning it.
699        ready_rx.recv().unwrap();
700        while state.inner.try_lock().is_err() {
701            std::thread::yield_now();
702        }
703
704        let poison_state = Arc::clone(&state);
705        let poisoner = std::thread::spawn(move || {
706            let mut inner = poison_state.inner.lock().unwrap();
707            inner.pending = 0;
708            poison_state.all_done.notify_all();
709            panic!("poison the mutex while notifying the waiter");
710        });
711        assert!(poisoner.join().is_err());
712
713        let pending_after =
714            waiter.join().expect("waiter must recover, not panic");
715        assert_eq!(pending_after, 0);
716    }
717
718    #[test]
719    #[serial_test::parallel(io_pool_failpoints)]
720    fn worker_loop_receiver_lock_recovers_from_poison() {
721        // `worker_loop`'s `rx.lock().unwrap_or_else(...)` uses the same
722        // poison-recovery idiom for the receiver mutex.
723        let (tx, rx) = sync_channel::<WriteJob>(1);
724        let rx = Arc::new(Mutex::new(rx));
725        let poison_rx = Arc::clone(&rx);
726        let handle = std::thread::spawn(move || {
727            let _guard = poison_rx.lock().unwrap();
728            panic!("deliberately poison the receiver mutex");
729        });
730        assert!(handle.join().is_err());
731
732        drop(tx);
733        let guard = rx.lock().unwrap_or_else(PoisonError::into_inner);
734        assert!(guard.recv().is_err());
735    }
736
737    /// Fault-injection tests. Failpoints are process-global, so every
738    /// test here serialises via `serial_test` and restores the
739    /// failpoint with an RAII guard (mirrors `tests/fault_injection.rs`).
740    #[cfg(feature = "test-fault-injection")]
741    mod fault_injection {
742        use super::*;
743        use serial_test::serial;
744
745        /// RAII guard that disables a failpoint on drop.
746        struct FailGuard<'a>(&'a str);
747
748        impl Drop for FailGuard<'_> {
749            fn drop(&mut self) {
750                let _ = fail::cfg(self.0, "off");
751            }
752        }
753
754        #[test]
755        #[serial(io_pool_failpoints)]
756        fn injected_write_failure_surfaces_at_flush() {
757            let _guard = FailGuard("io_pool::write");
758            fail::cfg("io_pool::write", "return").expect("activate failpoint");
759
760            let dir = tempdir().unwrap();
761            let pool = IoPool::with_threads(1);
762            pool.write(dir.path().join("x.html"), b"x".to_vec())
763                .unwrap();
764            let err = pool.flush().expect_err("injected write must fail");
765            assert!(
766                format!("{err}").contains("injected: io_pool::write"),
767                "got: {err}"
768            );
769        }
770
771        #[test]
772        #[serial(io_pool_failpoints)]
773        fn spawn_failure_falls_back_to_inline_writes() {
774            crate::test_support::init_logger();
775            let dir = tempdir().unwrap();
776
777            let pool = {
778                let _guard = FailGuard("io_pool::spawn");
779                fail::cfg("io_pool::spawn", "return")
780                    .expect("activate failpoint");
781                IoPool::with_threads(2)
782            };
783
784            // No writer threads: the pool degrades to inline writes.
785            assert_eq!(pool.threads(), 0);
786            pool.write(dir.path().join("inline.html"), b"i".to_vec())
787                .unwrap();
788            assert_eq!(pool.completed_writes(), 1);
789            assert_eq!(
790                fs::read_to_string(dir.path().join("inline.html")).unwrap(),
791                "i"
792            );
793
794            // An inline write that fails surfaces immediately.
795            let err = pool
796                .write(dir.path().join("no-dir").join("y.html"), b"y".to_vec())
797                .expect_err("inline write into missing dir must fail");
798            assert!(io_error_path(&err).is_some());
799
800            assert!(pool.flush().is_ok());
801        }
802
803        #[test]
804        #[serial(io_pool_failpoints)]
805        fn write_after_workers_died_returns_typed_error() {
806            crate::test_support::init_logger();
807            let dir = tempdir().unwrap();
808            let pool = IoPool::with_threads(1);
809
810            {
811                let _guard = FailGuard("io_pool::write");
812                fail::cfg("io_pool::write", "panic")
813                    .expect("activate failpoint");
814                // The single worker dequeues this job and panics,
815                // dropping the channel receiver.
816                pool.write(dir.path().join("doomed.html"), b"d".to_vec())
817                    .unwrap();
818
819                // Once the receiver is gone, send() fails and write()
820                // surfaces the disconnected-channel error.
821                let err = loop {
822                    match pool
823                        .write(dir.path().join("after.html"), b"a".to_vec())
824                    {
825                        Err(e) => break e,
826                        Ok(()) => std::thread::sleep(
827                            std::time::Duration::from_millis(1),
828                        ),
829                    }
830                };
831                assert!(
832                    format!("{err}").contains("writer threads terminated"),
833                    "got: {err}"
834                );
835            }
836            // Drop joins the panicked worker (join().is_err() branch).
837            // Never call flush() here: the panicked job left
838            // `pending > 0` permanently.
839            drop(pool);
840        }
841    }
842}