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}