- workers/pool.rs: 5 new tests; catch_unwind keeps worker threads alive on handler panic (in-flight request loss unchanged, pinned by test + #843 docs); stubs.rs no longer falsely claims the pool is tested - save/load: execute_save_load pinned .after(Storyteller) so the scheduler cannot legally save pre-Input state; exclusive-system exception recorded in tick_phases.rs rules - surname corpus extracted to bin/shared/surname_corpus.rs (both economy generators import it; byte-identical output verified on 23.6MB+1.45MB TOMLs); all three stamp/watch registries updated - generator_spike gated behind non-default 'generator-spike' feature - economy.rs: 11 new D-181 signal-derivation tests on the new econ_sim Simulation::from_economy in-memory constructor - perception exemption comments now state the consumer sort contract; unused bytemuck removed; rayon comment corrected; the 22 allow(dead_code) documented as serde schema enforcement Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
361 lines
14 KiB
Rust
361 lines
14 KiB
Rust
//! Generic background worker pool.
|
|
//!
|
|
//! Wraps crossbeam channels + spawned threads. The handler closure captures
|
|
//! any context it needs (world seed, config, etc.) at construction time.
|
|
//!
|
|
//! ## Worker crash behavior
|
|
//!
|
|
//! Handler panics are contained per-request (T-1063): the worker thread
|
|
//! catches the unwind, logs an error, and keeps serving the queue — a
|
|
//! poisoned request cannot permanently shrink the pool. The in-flight
|
|
//! request is still LOST: no result is ever delivered for it.
|
|
//!
|
|
//! TODO (#843): re-enqueue lost requests (needs heartbeat/ack bookkeeping).
|
|
//! For Fallback-strategy workers (voice, LLM) the loss is acceptable — the
|
|
//! tick loop uses the default. For ModalLock-strategy workers (chunk gen on
|
|
//! gate transit), a lost request means the player waits forever. Before any
|
|
//! ModalLock consumer ships (Phase 5), implement re-enqueue or downgrade
|
|
//! ModalLock to an error path.
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::thread::{self, JoinHandle};
|
|
|
|
use crossbeam_channel::{Receiver, Sender, TryRecvError};
|
|
|
|
/// How the tick loop handles a not-yet-ready result.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DeliveryStrategy {
|
|
/// Use default/cached value. Player never waits. Seamless.
|
|
Fallback,
|
|
/// Area loads partially, elements pop in when ready. Visible but non-blocking.
|
|
GracefulDegrade,
|
|
/// Tick loop pauses until result arrives. Diegetic buffer UI shown to player.
|
|
/// Use sparingly — only for gate transit, initial world load, etc.
|
|
ModalLock,
|
|
}
|
|
|
|
/// A request with its delivery strategy attached.
|
|
#[derive(Debug)]
|
|
pub struct WorkRequest<Req> {
|
|
pub payload: Req,
|
|
pub strategy: DeliveryStrategy,
|
|
}
|
|
|
|
/// A generic background worker pool.
|
|
///
|
|
/// `Req` and `Resp` are the request/response types. The handler closure
|
|
/// runs on worker threads and transforms requests into responses.
|
|
///
|
|
/// ```ignore
|
|
/// let pool = BackgroundWorkerPool::spawn(2, |req: ChunkGenRequest| {
|
|
/// // runs on worker thread — has access to captured world_seed
|
|
/// generate_chunk(req, world_seed)
|
|
/// });
|
|
/// pool.submit(WorkRequest { payload: req, strategy: DeliveryStrategy::GracefulDegrade });
|
|
/// for result in pool.drain() { /* incorporate */ }
|
|
/// ```
|
|
pub struct BackgroundWorkerPool<Req, Resp> {
|
|
tx: Sender<WorkRequest<Req>>,
|
|
rx: Receiver<(Resp, DeliveryStrategy)>,
|
|
shutdown: Arc<AtomicBool>,
|
|
workers: Vec<JoinHandle<()>>,
|
|
}
|
|
|
|
impl<Req, Resp> BackgroundWorkerPool<Req, Resp>
|
|
where
|
|
Req: Send + 'static,
|
|
Resp: Send + 'static,
|
|
{
|
|
/// Spawn `count` worker threads with the given handler closure.
|
|
///
|
|
/// The handler is cloned to each thread. It should capture any context
|
|
/// it needs (world seed, config registries, etc.) via `Arc` or `Clone`.
|
|
pub fn spawn<H>(count: usize, handler: H) -> Self
|
|
where
|
|
H: Fn(Req) -> Resp + Send + Sync + Clone + 'static,
|
|
{
|
|
let (req_tx, req_rx) = crossbeam_channel::unbounded::<WorkRequest<Req>>();
|
|
let (resp_tx, resp_rx) = crossbeam_channel::unbounded::<(Resp, DeliveryStrategy)>();
|
|
let shutdown = Arc::new(AtomicBool::new(false));
|
|
|
|
let mut workers = Vec::with_capacity(count);
|
|
for id in 0..count {
|
|
let rx = req_rx.clone();
|
|
let tx = resp_tx.clone();
|
|
let shutdown = shutdown.clone();
|
|
let handler = handler.clone();
|
|
|
|
let handle = thread::Builder::new()
|
|
.name(format!("worker-{id}"))
|
|
.spawn(move || {
|
|
while !shutdown.load(Ordering::SeqCst) {
|
|
match rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
|
Ok(work) => {
|
|
let strategy = work.strategy;
|
|
// Contain handler panics (T-1063): the thread must
|
|
// survive a poisoned request or the pool permanently
|
|
// loses capacity. The in-flight request is still lost
|
|
// (no result delivered) — re-enqueue is future work,
|
|
// see the module-level TODO (#843).
|
|
let result = std::panic::catch_unwind(
|
|
std::panic::AssertUnwindSafe(|| handler(work.payload)),
|
|
);
|
|
match result {
|
|
Ok(resp) => {
|
|
if tx.send((resp, strategy)).is_err() {
|
|
break; // response channel closed
|
|
}
|
|
}
|
|
Err(_) => {
|
|
tracing::error!(
|
|
strategy = ?strategy,
|
|
"worker handler panicked — in-flight request lost, \
|
|
no result will be delivered (#843)"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue,
|
|
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
|
|
}
|
|
}
|
|
})
|
|
.expect("failed to spawn worker thread");
|
|
|
|
workers.push(handle);
|
|
}
|
|
|
|
Self {
|
|
tx: req_tx,
|
|
rx: resp_rx,
|
|
shutdown,
|
|
workers,
|
|
}
|
|
}
|
|
|
|
/// Submit a request. Non-blocking — returns immediately.
|
|
pub fn submit(&self, request: WorkRequest<Req>) {
|
|
let _ = self.tx.send(request);
|
|
}
|
|
|
|
/// Poll for a single completed result. Non-blocking.
|
|
pub fn poll(&self) -> Option<(Resp, DeliveryStrategy)> {
|
|
match self.rx.try_recv() {
|
|
Ok(result) => Some(result),
|
|
Err(TryRecvError::Empty) => None,
|
|
Err(TryRecvError::Disconnected) => None,
|
|
}
|
|
}
|
|
|
|
/// Drain all completed results. Non-blocking.
|
|
pub fn drain(&self) -> Vec<(Resp, DeliveryStrategy)> {
|
|
let mut results = Vec::new();
|
|
while let Ok(result) = self.rx.try_recv() {
|
|
results.push(result);
|
|
}
|
|
results
|
|
}
|
|
|
|
/// Check if any results are ready without consuming them.
|
|
pub fn has_results(&self) -> bool {
|
|
!self.rx.is_empty()
|
|
}
|
|
|
|
/// Number of pending requests in the work queue.
|
|
pub fn pending_count(&self) -> usize {
|
|
self.tx.len()
|
|
}
|
|
|
|
/// Signal all workers to shut down gracefully.
|
|
pub fn shutdown(&self) {
|
|
self.shutdown.store(true, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
impl<Req, Resp> Drop for BackgroundWorkerPool<Req, Resp> {
|
|
fn drop(&mut self) {
|
|
self.shutdown.store(true, Ordering::SeqCst);
|
|
// Join all worker threads — wait for clean exit (100ms max per thread)
|
|
for handle in self.workers.drain(..) {
|
|
let _ = handle.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests (T-1063): spawn/push/poll/shutdown lifecycle + worker-panic behavior.
|
|
//
|
|
// Every test also exercises Drop implicitly — a pool whose threads fail to
|
|
// exit would hang the test binary at scope end.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::{Duration, Instant};
|
|
|
|
const WAIT: Duration = Duration::from_secs(5);
|
|
|
|
/// Spin until `cond` holds, panicking after `WAIT`. Worker completion is
|
|
/// timing-dependent (thread scheduling), so tests wait on observable
|
|
/// state instead of sleeping fixed amounts.
|
|
fn wait_for(what: &str, mut cond: impl FnMut() -> bool) {
|
|
let start = Instant::now();
|
|
while !cond() {
|
|
if start.elapsed() > WAIT {
|
|
panic!("timed out after {WAIT:?} waiting for: {what}");
|
|
}
|
|
thread::sleep(Duration::from_millis(5));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_push_poll_round_trip() {
|
|
let pool = BackgroundWorkerPool::spawn(2, |x: u32| x * 2);
|
|
assert!(pool.poll().is_none(), "fresh pool has no results");
|
|
assert!(!pool.has_results());
|
|
assert_eq!(pool.pending_count(), 0);
|
|
|
|
pool.submit(WorkRequest {
|
|
payload: 21,
|
|
strategy: DeliveryStrategy::Fallback,
|
|
});
|
|
|
|
wait_for("result ready", || pool.has_results());
|
|
let (resp, strategy) = pool.poll().expect("has_results implies poll succeeds");
|
|
assert_eq!(resp, 42);
|
|
assert_eq!(strategy, DeliveryStrategy::Fallback);
|
|
assert!(pool.poll().is_none(), "single request yields single result");
|
|
assert_eq!(pool.pending_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn drain_collects_all_results_and_preserves_strategies() {
|
|
let pool = BackgroundWorkerPool::spawn(2, |x: u32| x + 1);
|
|
for i in 0..10u32 {
|
|
pool.submit(WorkRequest {
|
|
payload: i,
|
|
strategy: if i % 2 == 0 {
|
|
DeliveryStrategy::GracefulDegrade
|
|
} else {
|
|
DeliveryStrategy::ModalLock
|
|
},
|
|
});
|
|
}
|
|
|
|
let mut results: Vec<(u32, DeliveryStrategy)> = Vec::new();
|
|
wait_for("all 10 results drained", || {
|
|
results.extend(pool.drain());
|
|
results.len() == 10
|
|
});
|
|
|
|
// Two worker threads — completion order is not guaranteed. Each
|
|
// request must come back exactly once, paired with its own strategy.
|
|
results.sort_by_key(|(resp, _)| *resp);
|
|
for (i, (resp, strategy)) in results.iter().enumerate() {
|
|
assert_eq!(*resp, i as u32 + 1);
|
|
let expected = if i % 2 == 0 {
|
|
DeliveryStrategy::GracefulDegrade
|
|
} else {
|
|
DeliveryStrategy::ModalLock
|
|
};
|
|
assert_eq!(*strategy, expected, "strategy must travel with request {i}");
|
|
}
|
|
assert!(!pool.has_results(), "drain leaves the queue empty");
|
|
}
|
|
|
|
#[test]
|
|
fn shutdown_stops_workers_and_drops_later_submissions() {
|
|
let pool = BackgroundWorkerPool::spawn(2, |x: u32| x);
|
|
pool.shutdown();
|
|
wait_for("all workers exited", || {
|
|
pool.workers.iter().all(|h| h.is_finished())
|
|
});
|
|
|
|
// Once every worker has exited, all Receiver clones are gone (spawn
|
|
// drops the original), so the request channel is disconnected:
|
|
// submit() silently discards the request — by design (`let _ =` on
|
|
// send). Nothing is queued and no result ever appears.
|
|
pool.submit(WorkRequest {
|
|
payload: 7,
|
|
strategy: DeliveryStrategy::Fallback,
|
|
});
|
|
assert_eq!(
|
|
pool.pending_count(),
|
|
0,
|
|
"post-shutdown submissions are silently dropped, not queued"
|
|
);
|
|
assert!(pool.poll().is_none());
|
|
assert!(!pool.has_results());
|
|
}
|
|
|
|
// -- Worker-panic behavior (T-1063) ----------------------------------------
|
|
|
|
/// Handler that panics on payload 0 and echoes anything else.
|
|
fn poison_handler(x: u32) -> u32 {
|
|
if x == 0 {
|
|
panic!("poisoned request");
|
|
}
|
|
x
|
|
}
|
|
|
|
#[test]
|
|
fn handler_panic_does_not_kill_the_worker_thread() {
|
|
// Single thread: if the panic killed it, the second request would
|
|
// never be processed and this test would time out.
|
|
let pool = BackgroundWorkerPool::spawn(1, poison_handler);
|
|
pool.submit(WorkRequest {
|
|
payload: 0, // poison
|
|
strategy: DeliveryStrategy::ModalLock,
|
|
});
|
|
pool.submit(WorkRequest {
|
|
payload: 5,
|
|
strategy: DeliveryStrategy::Fallback,
|
|
});
|
|
|
|
wait_for("post-panic result", || pool.has_results());
|
|
let (resp, _) = pool.poll().expect("worker survived the panic");
|
|
assert_eq!(resp, 5);
|
|
assert!(
|
|
!pool.workers[0].is_finished(),
|
|
"worker thread must survive a handler panic"
|
|
);
|
|
}
|
|
|
|
/// KNOWN LIMITATION (#843): the panicked request is silently lost — no
|
|
/// result is ever delivered for it and it is not re-enqueued. For a
|
|
/// ModalLock consumer this means the player waits forever; heartbeat +
|
|
/// re-enqueue (or downgrading ModalLock to an error path) must land
|
|
/// before any ModalLock consumer ships (Phase 5). This test pins the
|
|
/// current loss behavior so the eventual fix has to update it visibly.
|
|
#[test]
|
|
fn handler_panic_loses_the_inflight_request() {
|
|
let pool = BackgroundWorkerPool::spawn(1, poison_handler);
|
|
pool.submit(WorkRequest {
|
|
payload: 0, // poison — would be the ModalLock request a player waits on
|
|
strategy: DeliveryStrategy::ModalLock,
|
|
});
|
|
// Sentinel: once its result arrives, the poison request has
|
|
// definitely been taken off the queue (single worker, FIFO channel).
|
|
pool.submit(WorkRequest {
|
|
payload: 99,
|
|
strategy: DeliveryStrategy::Fallback,
|
|
});
|
|
|
|
wait_for("sentinel result", || pool.has_results());
|
|
let results = pool.drain();
|
|
assert_eq!(
|
|
results.len(),
|
|
1,
|
|
"only the sentinel completes — the poisoned request produced no result"
|
|
);
|
|
assert_eq!(results[0].0, 99);
|
|
assert_eq!(
|
|
pool.pending_count(),
|
|
0,
|
|
"the poisoned request is gone from the queue, not re-enqueued"
|
|
);
|
|
}
|
|
}
|