use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
use std::sync::{Condvar, LazyLock, Mutex, PoisonError};
use std::thread;
use std::time::{Duration, Instant};
use crate::error::Error;
use crate::resolve::ProxyStep;
#[cfg_attr(not(pac_quickjs), allow(dead_code))]
pub(super) fn run_with_timeout<F>(
timeout: Duration,
slots: &'static Slots,
job: F,
) -> Result<Vec<ProxyStep>, Error>
where
F: FnOnce(Option<Instant>) -> Result<Vec<ProxyStep>, Error> + Send + 'static,
{
if timeout.is_zero() {
return Err(Error::PacTimeout { timeout });
}
let deadline = Instant::now().checked_add(timeout);
let slot = slots.acquire(deadline).ok_or(Error::PacSaturated {
timeout,
limit: slots.limit,
})?;
let (sender, receiver) = mpsc::sync_channel(1);
slots.dispatch(Job {
run: Box::new(move || job(deadline)),
reply: sender,
slot,
})?;
let left = deadline.map_or(timeout, |deadline| {
deadline.saturating_duration_since(Instant::now())
});
match receiver.recv_timeout(left) {
Ok(result) => result,
Err(RecvTimeoutError::Timeout) => Err(Error::PacTimeout { timeout }),
Err(RecvTimeoutError::Disconnected) => Err(Error::pac_evaluation(
"the PAC evaluation thread ended without producing a result",
)),
}
}
pub(super) static SLOTS: LazyLock<Slots> = LazyLock::new(|| {
Slots::new(thread::available_parallelism().map_or(4, |cores| cores.get().max(4)))
});
pub(super) struct Slots {
limit: usize,
taken: Mutex<usize>,
freed: Condvar,
idle: Mutex<Vec<Idle>>,
linger: Duration,
}
const LINGER: Duration = Duration::from_secs(30);
static NEXT_THREAD: AtomicU64 = AtomicU64::new(0);
struct Idle {
id: u64,
process: u32,
sender: SyncSender<Job>,
}
pub(super) struct Job {
run: Box<dyn FnOnce() -> Result<Vec<ProxyStep>, Error> + Send>,
reply: SyncSender<Result<Vec<ProxyStep>, Error>>,
slot: Slot,
}
impl Slots {
pub(super) const fn new(limit: usize) -> Self {
Self::lingering(limit, LINGER)
}
const fn lingering(limit: usize, linger: Duration) -> Self {
Self {
limit,
taken: Mutex::new(0),
freed: Condvar::new(),
idle: Mutex::new(Vec::new()),
linger,
}
}
fn dispatch(&'static self, mut job: Job) -> Result<(), Error> {
loop {
let idle = self
.idle
.lock()
.unwrap_or_else(PoisonError::into_inner)
.pop();
let Some(idle) = idle else { break };
if idle.process != std::process::id() {
continue;
}
match idle.sender.send(job) {
Ok(()) => return Ok(()),
Err(mpsc::SendError(back)) => job = back,
}
}
let id = NEXT_THREAD.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = mpsc::sync_channel(1);
let _ = sender.send(job);
thread::Builder::new()
.name("proxy-watch-pac".to_owned())
.spawn(move || self.work(id, &sender, &receiver))
.map(drop)
.map_err(|source| Error::io("spawning the PAC evaluation thread", source))
}
fn work(&'static self, id: u64, sender: &SyncSender<Job>, receiver: &Receiver<Job>) {
let mut next = receiver.recv().ok();
while let Some(Job { run, reply, slot }) = next {
let result = run();
self.idle
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(Idle {
id,
process: std::process::id(),
sender: sender.clone(),
});
drop(slot);
let _ = reply.send(result);
next = match receiver.recv_timeout(self.linger) {
Ok(job) => Some(job),
Err(_) => {
let mut idle = self.idle.lock().unwrap_or_else(PoisonError::into_inner);
match idle.iter().position(|idle| idle.id == id) {
Some(at) => {
idle.swap_remove(at);
None
}
None => {
drop(idle);
receiver.recv().ok()
}
}
}
};
}
}
#[cfg(feature = "pac-subprocess")]
pub(super) fn limit(&self) -> usize {
self.limit
}
pub(super) fn acquire(&'static self, deadline: Option<Instant>) -> Option<Slot> {
let mut taken = self.taken.lock().unwrap_or_else(PoisonError::into_inner);
while *taken >= self.limit {
taken = match deadline {
None => self
.freed
.wait(taken)
.unwrap_or_else(PoisonError::into_inner),
Some(deadline) => {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return None;
}
self.freed
.wait_timeout(taken, left)
.unwrap_or_else(PoisonError::into_inner)
.0
}
};
}
*taken += 1;
Some(Slot(self))
}
}
pub(super) struct Slot(&'static Slots);
impl Drop for Slot {
fn drop(&mut self) {
*self.0.taken.lock().unwrap_or_else(PoisonError::into_inner) -= 1;
self.0.freed.notify_one();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_call_waits_for_a_slot_and_gives_up_when_none_comes_free() {
static ONE: Slots = Slots::new(1);
let slow = |_| {
thread::sleep(Duration::from_millis(300));
Ok(vec![ProxyStep::Direct])
};
let quick = |_| Ok(vec![ProxyStep::Direct]);
let first = run_with_timeout(Duration::from_millis(10), &ONE, slow).unwrap_err();
assert!(matches!(first, Error::PacTimeout { .. }), "{first:?}");
let second = run_with_timeout(Duration::from_millis(10), &ONE, quick).unwrap_err();
assert!(
matches!(second, Error::PacSaturated { limit: 1, .. }),
"{second:?}"
);
assert_eq!(
run_with_timeout(Duration::from_secs(120), &ONE, quick).unwrap(),
vec![ProxyStep::Direct]
);
}
fn thread_of(slots: &'static Slots) -> thread::ThreadId {
let (sender, receiver) = mpsc::channel();
run_with_timeout(Duration::from_secs(120), slots, move |_| {
sender.send(thread::current().id()).unwrap();
Ok(vec![ProxyStep::Direct])
})
.unwrap();
receiver.recv().unwrap()
}
#[test]
fn an_evaluation_runs_on_the_thread_the_one_before_it_left_idle() {
static FOUR: Slots = Slots::new(4);
let first = thread_of(&FOUR);
assert_ne!(first, thread::current().id());
for _ in 0..200 {
assert_eq!(thread_of(&FOUR), first);
}
}
#[test]
fn an_idle_thread_ends_after_lingering() {
static BRIEF: Slots = Slots::lingering(4, Duration::from_millis(50));
let first = thread_of(&BRIEF);
let deadline = Instant::now() + Duration::from_secs(60);
while !BRIEF.idle.lock().unwrap().is_empty() {
assert!(Instant::now() < deadline, "the idle thread is still listed");
thread::sleep(Duration::from_millis(10));
}
assert_ne!(thread_of(&BRIEF), first);
}
#[test]
fn an_idle_thread_of_another_process_is_not_handed_work() {
static ONE: Slots = Slots::new(1);
let (sender, _unread) = mpsc::sync_channel(1);
ONE.idle.lock().unwrap().push(Idle {
id: u64::MAX,
process: std::process::id().wrapping_add(1),
sender,
});
let quick = |_| Ok(vec![ProxyStep::Direct]);
assert_eq!(
run_with_timeout(Duration::from_secs(5), &ONE, quick).unwrap(),
vec![ProxyStep::Direct]
);
}
#[test]
fn concurrent_evaluations_never_use_more_threads_than_slots() {
static TWO: Slots = Slots::new(2);
let seen = std::sync::Arc::new(Mutex::new(std::collections::HashSet::new()));
let callers: Vec<_> = (0..8)
.map(|_| {
let seen = seen.clone();
thread::spawn(move || {
for _ in 0..200 {
let id = thread_of(&TWO);
seen.lock().unwrap().insert(id);
}
})
})
.collect();
for caller in callers {
caller.join().unwrap();
}
let threads = seen.lock().unwrap().len();
assert!(threads <= 2, "{threads} evaluation threads for 2 slots");
}
}