#![cfg(feature = "cli")]
use crossbeam_channel::{Receiver, Sender, bounded};
use std::io;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::{OnceLock, mpsc};
use std::thread;
use std::time::Duration;
const DEFAULT_TIMEOUT_MS: u64 = 5000;
type Job = Box<dyn FnOnce() + Send + 'static>;
static POOL: OnceLock<ThreadPool> = OnceLock::new();
struct ThreadPool {
sender: Sender<Job>,
}
impl ThreadPool {
fn get() -> &'static Self {
POOL.get_or_init(Self::new)
}
fn new() -> Self {
let (sender, receiver): (Sender<Job>, Receiver<Job>) = bounded(4096);
let cpu_count = thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let size = (cpu_count * 4).clamp(4, 64);
let mut spawned = 0usize;
for i in 0..size {
let rx = receiver.clone();
let builder = thread::Builder::new().name(format!("luff-io-worker-{i}"));
match builder.spawn(move || Self::worker_loop(&rx)) {
Ok(_) => spawned += 1,
Err(e) => {
log::warn!("Failed to spawn IO worker thread {i}: {e}");
}
}
}
assert!(
spawned > 0,
"Failed to spawn any IO worker threads ({size} attempted)"
);
Self { sender }
}
fn worker_loop(receiver: &Receiver<Job>) {
while let Ok(job) = receiver.recv() {
if let Err(cause) = catch_unwind(AssertUnwindSafe(job)) {
log::error!("IO worker thread caught panic: {cause:?}");
}
}
}
fn try_execute<F>(&self, f: F) -> io::Result<()>
where
F: FnOnce() + Send + 'static,
{
use crossbeam_channel::TrySendError;
self.sender.try_send(Box::new(f)).map_err(|e| match e {
TrySendError::Full(_) => io::Error::other(
"I/O thread pool saturated — all workers are blocked. \
This may indicate a hung filesystem (e.g., NFS). \
Consider increasing LUFF_READ_TIMEOUT_MS or investigating the mount.",
),
TrySendError::Disconnected(_) => {
io::Error::other("I/O thread pool workers disconnected unexpectedly")
}
})
}
}
pub trait TimeoutEnv {
fn timeout_ms(&self) -> Option<u64>;
}
struct RealTimeoutEnv;
impl TimeoutEnv for RealTimeoutEnv {
fn timeout_ms(&self) -> Option<u64> {
std::env::var("LUFF_READ_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse().ok())
}
}
#[must_use]
fn get_timeout_duration_with_env(env: &dyn TimeoutEnv) -> Duration {
env.timeout_ms().map_or_else(
|| Duration::from_millis(DEFAULT_TIMEOUT_MS),
Duration::from_millis,
)
}
pub fn with_timeout<F, T>(f: F) -> io::Result<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
TimeoutExecutor::new().execute(f)
}
struct TimeoutExecutor {
env: Box<dyn TimeoutEnv>,
}
impl TimeoutExecutor {
fn new() -> Self {
Self {
env: Box::new(RealTimeoutEnv),
}
}
#[cfg(test)]
fn with_env(env: impl TimeoutEnv + 'static) -> Self {
Self { env: Box::new(env) }
}
fn execute<F, T>(&self, f: F) -> io::Result<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
let timeout = get_timeout_duration_with_env(&*self.env);
let (tx, rx) = mpsc::channel();
ThreadPool::get().try_execute(move || {
let _ = tx.send(f());
})?;
match rx.recv_timeout(timeout) {
Ok(result) => Ok(result),
Err(mpsc::RecvTimeoutError::Timeout) => Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"Operation timed out after {}ms (LUFF_READ_TIMEOUT_MS)",
timeout.as_millis()
),
)),
Err(mpsc::RecvTimeoutError::Disconnected) => Err(io::Error::other(
"Operation thread disconnected unexpectedly",
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread::sleep;
struct TestEnv {
value: Option<u64>,
}
impl TimeoutEnv for TestEnv {
fn timeout_ms(&self) -> Option<u64> {
self.value
}
}
#[test]
fn test_timeout_with_fast_operation() {
let result = with_timeout(|| "success".to_string());
assert!(result.is_ok());
assert_eq!(result.unwrap(), "success");
}
#[test]
fn test_timeout_with_send_bound() {
fn assert_send<T: Send>(_: &T) {}
let result = with_timeout(|| 42i32);
assert_send(&result);
}
#[test]
fn test_timeout_with_slow_operation() {
let executor = TimeoutExecutor::with_env(TestEnv { value: Some(5000) });
let result: io::Result<()> = executor.execute(|| {
sleep(Duration::from_millis(100));
});
assert!(result.is_ok());
}
#[test]
fn test_timeout_triggers() {
let executor = TimeoutExecutor::with_env(TestEnv { value: Some(10) });
let result: io::Result<()> = executor.execute(|| {
sleep(Duration::from_millis(100));
});
let err = result.expect_err("should time out with 10ms timeout");
assert_eq!(
err.kind(),
io::ErrorKind::TimedOut,
"timeout should produce TimedOut error kind, got: {err}"
);
assert!(
err.to_string().contains("LUFF_READ_TIMEOUT_MS"),
"timeout error should mention the env var for discoverability, got: {err}"
);
}
#[test]
fn test_get_timeout_duration_default() {
let env = TestEnv { value: None };
let duration = get_timeout_duration_with_env(&env);
assert_eq!(duration, Duration::from_secs(5));
}
#[test]
fn test_get_timeout_duration_custom() {
let env = TestEnv { value: Some(1000) };
let duration = get_timeout_duration_with_env(&env);
assert_eq!(duration, Duration::from_secs(1));
}
#[test]
fn test_thread_pool_reuse() {
let executor = TimeoutExecutor::with_env(TestEnv { value: Some(5000) });
let mut handles = vec![];
for i in 0..10 {
let result = executor.execute(move || i);
handles.push(result);
}
for (i, res) in handles.into_iter().enumerate() {
assert_eq!(
res.expect("pool reuse should not fail"),
i,
"closure should return its captured value"
);
}
}
#[test]
fn test_closure_error_passes_through() {
let outer = with_timeout(|| -> Result<(), String> { Err("application error".to_string()) });
let inner = outer.expect("timeout layer should succeed");
assert_eq!(
inner.unwrap_err(),
"application error",
"inner application error should pass through unchanged"
);
}
#[test]
fn test_timeout_returns_value_types() {
let int = with_timeout(|| 42u64).expect("u64 return should work");
assert_eq!(int, 42);
let vec = with_timeout(|| vec![1, 2, 3]).expect("Vec return should work");
assert_eq!(vec, [1, 2, 3]);
let opt = with_timeout(|| Option::<String>::None).expect("Option return should work");
assert_eq!(opt, None);
}
#[test]
fn test_timeout_zero_ms_triggers_immediately() {
let executor = TimeoutExecutor::with_env(TestEnv { value: Some(0) });
let result: io::Result<()> = executor.execute(|| {
sleep(Duration::from_millis(50));
});
if let Err(e) = result {
assert_eq!(e.kind(), io::ErrorKind::TimedOut);
}
}
}