zsh/extensions/worker.rs
1//! Worker pool for zshrs — persistent threads for background work.
2//!
3//! **zshrs-original infrastructure — no C source counterpart.** This
4//! module does NOT port a corresponding `Src/*.c` file. C zsh's
5//! background-work strategy is `fork(2)`: every completion run,
6//! process substitution, or command substitution is a child process
7//! (see `zfork()` in Src/exec.c and the `forklevel` machinery
8//! Src/init.c uses to track depth). zshrs replaces that pattern with
9//! a fixed-size thread pool + crossbeam channel dispatch.
10//!
11//! Replacement rationale (vs the fork() path the C source takes):
12//! - No fork overhead (50-500μs per fork on macOS)
13//! - No address space duplication
14//! - Warm thread stacks ready to go
15//! - Backpressure via bounded channel
16//!
17//! Pool size = available_parallelism() clamped to [2, 18].
18//! Channel capacity = 4 × pool size (bounded backpressure).
19//!
20//! Audit fixes applied:
21//! 1. crossbeam-channel replaces Arc<Mutex<mpsc::Receiver>> — no mutex contention
22//! 2. Bounded channel (4×N) provides backpressure
23//! 3. catch_unwind wraps every task — panics logged, worker stays alive
24//! 4. tracing spans on submit + worker loop
25//! 5. Queue depth metric on submit
26//! 6. Task cancellation via AtomicBool flag
27
28use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
29use std::sync::Arc;
30use std::thread;
31
32/// A unit of work the pool can execute.
33type Task = Box<dyn FnOnce() + Send + 'static>;
34
35thread_local! {
36 /// True only on threads owned by a `WorkerPool`.
37 ///
38 /// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
39 /// C zsh is single-threaded: the one and only thread owns SHIN and
40 /// the line editor, so `inputline()` (Src/input.c:366) can read the
41 /// terminal unconditionally. zshrs runs background work (compinit
42 /// bytecode backfill, fpath scan, …) on pool threads that share the
43 /// process's `interact` / `SHINSTDIN` / SHTTY globals. When such a
44 /// task parses a shell body whose lexer buffer drains mid-construct,
45 /// the C-faithful "as a last resort, get some more input" arm
46 /// (input.c:354-356) fired ON THE WORKER and read the user's tty —
47 /// stealing keystrokes from ZLE. `in_worker_thread()` lets the input
48 /// layer treat that case as EOF instead.
49 static IN_WORKER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
50}
51
52/// True when the calling thread is a worker-pool thread.
53///
54/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!! See `IN_WORKER`.
55pub fn in_worker_thread() -> bool {
56 IN_WORKER.with(|f| f.get())
57}
58
59/// Fixed-size thread pool with bounded FIFO task queue.
60///
61/// zshrs-original — replaces C zsh's per-task `fork()` + `wait()`
62/// pattern (Src/exec.c `zfork()` / Src/jobs.c child management) with
63/// a persistent thread pool. Uses crossbeam-channel for lock-free
64/// multi-consumer dispatch — each worker calls `recv()` directly,
65/// no mutex.
66pub struct WorkerPool {
67 /// `workers` field. Behind a Mutex because the threads are spawned on
68 /// FIRST USE (see `ensure_spawned`), not in `new`.
69 workers: std::sync::Mutex<Vec<Worker>>,
70 /// Kept so the workers can be spawned later; cloned per thread.
71 receiver: crossbeam_channel::Receiver<Task>,
72 /// Set once the threads exist.
73 spawned: AtomicBool,
74 /// `sender` field.
75 sender: Option<crossbeam_channel::Sender<Task>>,
76 /// `size` field.
77 size: usize,
78 /// Shared cancellation flag — when set, workers drop pending tasks
79 cancelled: Arc<AtomicBool>,
80 /// Queue depth — incremented on submit, decremented on task start
81 queued: Arc<AtomicUsize>,
82 /// Total tasks completed across all workers
83 completed: Arc<AtomicUsize>,
84}
85
86struct Worker {
87 #[allow(dead_code)]
88 id: usize,
89 handle: Option<thread::JoinHandle<()>>,
90}
91
92impl WorkerPool {
93 /// Create a pool with `size` worker threads and bounded channel.
94 /// Channel capacity = 4 × size (provides backpressure without
95 /// starving).
96 /// zshrs-original — no C counterpart. Replaces the
97 /// "spawn-on-demand" semantics of `zfork()` (Src/exec.c) with
98 /// pre-spawned threads ready to receive work over a bounded
99 /// channel.
100 pub fn new(size: usize) -> Self {
101 let capacity = size * 4;
102 let (sender, receiver) = crossbeam_channel::bounded::<Task>(capacity);
103 let cancelled = Arc::new(AtomicBool::new(false));
104 let queued = Arc::new(AtomicUsize::new(0));
105 let completed = Arc::new(AtomicUsize::new(0));
106
107 WorkerPool {
108 workers: std::sync::Mutex::new(Vec::new()),
109 receiver,
110 spawned: AtomicBool::new(false),
111 sender: Some(sender),
112 size,
113 cancelled,
114 queued,
115 completed,
116 }
117 }
118
119 /// Spawn the worker threads if they do not exist yet.
120 ///
121 /// zshrs-original. The pool used to spawn every thread in `new`, which
122 /// runs while the shell is still starting: `zshrs -f -c exit` paid 18
123 /// `pthread_create`s plus their stacks to run one builtin and exit, and a
124 /// profile of any short command showed all of them parked in
125 /// `semaphore_wait_trap` for the whole run. Nothing is deferred that a
126 /// caller can observe — the first `submit` spawns the pool before the task
127 /// is queued, so a task never waits on a thread that is not there.
128 fn ensure_spawned(&self) {
129 if self.spawned.load(Ordering::Relaxed) {
130 return;
131 }
132 let mut workers = self.workers.lock().unwrap_or_else(|e| e.into_inner());
133 if self.spawned.load(Ordering::Relaxed) {
134 return; // lost the race; the winner already spawned
135 }
136 let size = self.size;
137 let receiver = &self.receiver;
138 let cancelled = &self.cancelled;
139 let queued = &self.queued;
140 let completed = &self.completed;
141 for id in 0..size {
142 let rx = receiver.clone();
143 let cancelled = Arc::clone(&cancelled);
144 let queued = Arc::clone(&queued);
145 let completed = Arc::clone(&completed);
146
147 let handle = thread::Builder::new()
148 .name(format!("zshrs-worker-{}", id))
149 .spawn(move || {
150 // Rust-only: mark this thread as pool-owned so the
151 // input layer never reads the user's tty from it.
152 IN_WORKER.with(|f| f.set(true));
153 loop {
154 let task = match rx.recv() {
155 Ok(task) => task,
156 Err(_) => break, // channel closed → shutdown
157 };
158
159 queued.fetch_sub(1, Ordering::Relaxed);
160
161 // Check cancellation before running
162 if cancelled.load(Ordering::Relaxed) {
163 continue; // drain without executing
164 }
165
166 // Every task starts on a clear error flag. The
167 // thread's `errflag` is private (see
168 // crate::errflag_cell), so an abort or parse error
169 // left behind by the PREVIOUS task on this same
170 // thread would otherwise be inherited — C never
171 // has that problem because its equivalent of a
172 // task is a fresh forked child.
173 crate::ported::utils::errflag.store(0, Ordering::Relaxed);
174 // catch_unwind keeps the worker alive if a task panics
175 if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(task))
176 {
177 let msg = if let Some(s) = e.downcast_ref::<&str>() {
178 (*s).to_string()
179 } else if let Some(s) = e.downcast_ref::<String>() {
180 s.clone()
181 } else {
182 "unknown panic".to_string()
183 };
184 tracing::error!(
185 worker = id,
186 panic = %msg,
187 "worker task panicked"
188 );
189 }
190
191 completed.fetch_add(1, Ordering::Relaxed);
192 }
193 tracing::debug!(worker = id, "worker thread exiting");
194 })
195 .expect("failed to spawn worker thread");
196
197 workers.push(Worker {
198 id,
199 handle: Some(handle),
200 });
201 }
202
203 self.spawned.store(true, Ordering::Relaxed);
204 drop(workers);
205 tracing::info!(pool_size = size, "worker pool started");
206 }
207
208 /// Create a pool sized to the machine's parallelism, clamped to
209 /// `[2, 18]`.
210 /// zshrs-original — no C counterpart. C zsh has no concept of a
211 /// "pool size" because it forks on demand (one child per
212 /// background task, see Src/jobs.c).
213 pub fn default_size() -> Self {
214 let cpus = thread::available_parallelism()
215 .map(|n| n.get())
216 .unwrap_or(4);
217 Self::new(cpus.clamp(2, 18))
218 }
219
220 /// Submit a task to the pool. Blocks if the queue is full
221 /// (backpressure). Panics if the pool has been shut down.
222 /// zshrs-original — replaces the `fork() + execve()` /
223 /// `fork() + run-shell-fn` dispatch pairs in Src/exec.c.
224 pub fn submit<F>(&self, f: F)
225 where
226 F: FnOnce() + Send + 'static,
227 {
228 self.ensure_spawned();
229 let depth = self.queued.fetch_add(1, Ordering::Relaxed) + 1;
230 if depth > self.size * 2 {
231 tracing::debug!(queue_depth = depth, "worker pool queue building up");
232 }
233 self.sender
234 .as_ref()
235 .expect("pool shut down")
236 .send(Box::new(f))
237 .expect("all workers dead");
238 }
239
240 /// Submit a task and get a receiver for its result.
241 /// zshrs-original — closest C analog is the pipe-based
242 /// command-substitution result capture in Src/exec.c
243 /// (`getoutput()` reading the child's stdout pipe), but using a
244 /// typed Rust channel sidesteps the marshalling.
245 pub fn submit_with_result<F, R>(&self, f: F) -> crossbeam_channel::Receiver<R>
246 where
247 F: FnOnce() -> R + Send + 'static,
248 R: Send + 'static,
249 {
250 let (tx, rx) = crossbeam_channel::bounded(1);
251 self.submit(move || {
252 let result = f();
253 let _ = tx.send(result);
254 });
255 rx
256 }
257
258 /// Signal all workers to drop pending tasks.
259 /// Already-running tasks will finish, but queued tasks are
260 /// skipped. Reset with `reset_cancel()`.
261 /// zshrs-original — closest C analog is the SIGINT/SIGQUIT
262 /// signal-storm dispatch C zsh fires at its background children
263 /// in Src/signals.c (`killjb()` / `killpg()`), but here we set a
264 /// flag instead of sending a signal across a fork boundary.
265 pub fn cancel(&self) {
266 self.cancelled.store(true, Ordering::Relaxed);
267 tracing::info!("worker pool: cancel requested");
268 }
269
270 /// Clear the cancellation flag — pool resumes normal execution.
271 /// zshrs-original — no C counterpart.
272 pub fn reset_cancel(&self) {
273 self.cancelled.store(false, Ordering::Relaxed);
274 }
275
276 /// Number of worker threads.
277 /// zshrs-original — no C counterpart.
278 pub fn size(&self) -> usize {
279 self.size
280 }
281
282 /// Approximate number of tasks waiting in the queue.
283 /// zshrs-original — no C counterpart; closest equivalent is the
284 /// `jobtab` length walk Src/jobs.c uses for `jobs -l` output.
285 pub fn queue_depth(&self) -> usize {
286 self.queued.load(Ordering::Relaxed)
287 }
288
289 /// Total tasks completed since pool creation.
290 /// zshrs-original — no C counterpart.
291 pub fn completed(&self) -> usize {
292 self.completed.load(Ordering::Relaxed)
293 }
294}
295
296impl Drop for WorkerPool {
297 fn drop(&mut self) {
298 // Signal workers to skip remaining queued tasks
299 self.cancelled.store(true, Ordering::Relaxed);
300 // Drop the sender → channel closes → recv() returns Err → threads exit
301 drop(self.sender.take());
302 // Give workers a brief window to finish their current task.
303 // Don't block indefinitely — the process is exiting.
304 let mut workers = self.workers.lock().unwrap_or_else(|e| e.into_inner());
305 for w in workers.iter_mut() {
306 if let Some(handle) = w.handle.take() {
307 // Detach the thread — OS cleans up on process exit.
308 // join() would block if a worker is mid-parse on a 500-line
309 // completion function. Not worth the wait on Ctrl-D/exit.
310 drop(handle);
311 }
312 }
313 // Demoted from `info!` to `debug!` so the default tracing
314 // filter (INFO) suppresses it. The bare shutdown announcement
315 // has no operational value — interesting telemetry would be
316 // a non-zero error count or a stuck worker, which warrants its
317 // own surface. Empirically (bug #23 in docs/BUGS.md) the
318 // existing info! also leaked to stdout when a script left a
319 // duped fd open (`exec 3>&1`): by the time worker Drop runs,
320 // the file-backed log writer is closed, and tracing's fallback
321 // writes to fd 1 — which is the original stdout the dup
322 // pointed at. Default INFO filter no longer triggers this code
323 // path at all in normal use.
324 tracing::debug!(
325 tasks_completed = self.completed.load(Ordering::Relaxed),
326 "worker pool shut down"
327 );
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 /// Spin-wait helper for tests: poll `counter` until it reaches
336 /// `target` or the deadline elapses. Replaces the old "drop(pool)
337 /// implicitly waits" pattern, which broke when production Drop
338 /// switched to setting cancelled=true (so queued tasks would be
339 /// skipped on drop instead of drained).
340 fn wait_for_count(counter: &AtomicUsize, target: usize, max_wait_ms: u64) {
341 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(max_wait_ms);
342 while counter.load(Ordering::Relaxed) < target {
343 if std::time::Instant::now() >= deadline {
344 panic!(
345 "wait_for_count timed out: counter={} target={} after {}ms",
346 counter.load(Ordering::Relaxed),
347 target,
348 max_wait_ms
349 );
350 }
351 std::thread::sleep(std::time::Duration::from_millis(2));
352 }
353 }
354
355 #[test]
356 fn test_pool_executes_tasks() {
357 let _g = crate::test_util::global_state_lock();
358 let pool = WorkerPool::new(2);
359 let counter = Arc::new(AtomicUsize::new(0));
360
361 for _ in 0..100 {
362 let c = Arc::clone(&counter);
363 pool.submit(move || {
364 c.fetch_add(1, Ordering::Relaxed);
365 });
366 }
367
368 // Drain explicitly — production Drop sets cancelled=true and
369 // skips queued tasks (intentional for shell exit), so the test
370 // can't rely on `drop(pool)` to wait.
371 wait_for_count(&counter, 100, 5_000);
372 drop(pool);
373 assert_eq!(counter.load(Ordering::Relaxed), 100);
374 }
375
376 #[test]
377 fn test_submit_with_result() {
378 let _g = crate::test_util::global_state_lock();
379 let pool = WorkerPool::new(2);
380 let rx = pool.submit_with_result(|| 42);
381 assert_eq!(rx.recv().unwrap(), 42);
382 }
383
384 #[test]
385 fn test_default_size() {
386 let _g = crate::test_util::global_state_lock();
387 let pool = WorkerPool::default_size();
388 assert!(pool.size() >= 2);
389 assert!(pool.size() <= 18);
390 }
391
392 #[test]
393 fn test_panic_does_not_kill_worker() {
394 let _g = crate::test_util::global_state_lock();
395 let pool = WorkerPool::new(2);
396 let counter = Arc::new(AtomicUsize::new(0));
397
398 // Submit a task that panics
399 pool.submit(|| panic!("intentional test panic"));
400
401 // Submit tasks after the panic — they should still run
402 for _ in 0..10 {
403 let c = Arc::clone(&counter);
404 pool.submit(move || {
405 c.fetch_add(1, Ordering::Relaxed);
406 });
407 }
408
409 wait_for_count(&counter, 10, 5_000);
410 drop(pool);
411 assert_eq!(counter.load(Ordering::Relaxed), 10);
412 }
413
414 #[test]
415 fn test_cancel_skips_queued_tasks() {
416 let _g = crate::test_util::global_state_lock();
417 let pool = WorkerPool::new(1); // single worker to control ordering
418 let barrier = Arc::new(std::sync::Barrier::new(2));
419 // Signal the worker fires when it ENTERS the barrier task. Lets
420 // the main thread wait until the worker is provably blocked
421 // inside the barrier BEFORE calling cancel(). Without this, a
422 // pre-empted worker that hasn't yet pulled task #1 would see the
423 // cancel flag, skip task #1, and the main thread's barrier.wait()
424 // below would deadlock waiting for a second party that never
425 // arrives.
426 let started = Arc::new(std::sync::Mutex::new(false));
427 let started_cv = Arc::new(std::sync::Condvar::new());
428 let counter = Arc::new(AtomicUsize::new(0));
429
430 let b = Arc::clone(&barrier);
431 let started_clone = Arc::clone(&started);
432 let cv_clone = Arc::clone(&started_cv);
433 pool.submit(move || {
434 // Mark "task entered" + notify before blocking.
435 *started_clone.lock().unwrap() = true;
436 cv_clone.notify_one();
437 b.wait();
438 });
439
440 // Wait until the worker is provably inside the task (and thus
441 // committed to calling b.wait() — no race with cancel below).
442 // 5s timeout is a safety net; in practice this fires within μs.
443 let mut g = started.lock().unwrap();
444 let timeout = std::time::Duration::from_secs(5);
445 while !*g {
446 let (gg, wait_result) = started_cv.wait_timeout(g, timeout).unwrap();
447 g = gg;
448 if wait_result.timed_out() && !*g {
449 panic!("worker never started task #1 within 5s — test scaffolding broken");
450 }
451 }
452 drop(g);
453
454 // Queue tasks that should be skipped (worker is parked at b.wait()).
455 // Cap at channel capacity (size * 4 = 4 for a 1-worker pool) MINUS 1
456 // for safety. Submitting more than the channel holds while the
457 // worker is blocked deadlocks `submit` itself, since the bounded
458 // crossbeam channel back-pressures `send()`. 3 skipped tasks is
459 // enough to prove "queued tasks get cancelled" — the count isn't
460 // load-bearing.
461 for _ in 0..3 {
462 let c = Arc::clone(&counter);
463 pool.submit(move || {
464 c.fetch_add(1, Ordering::Relaxed);
465 });
466 }
467
468 // Cancel, then unblock the worker — it'll return from b.wait(),
469 // loop, see cancelled=true, drain the 5 queued tasks without
470 // executing them.
471 pool.cancel();
472 barrier.wait();
473
474 // Give workers time to drain
475 std::thread::sleep(std::time::Duration::from_millis(50));
476
477 // Queued tasks should have been skipped
478 assert_eq!(counter.load(Ordering::Relaxed), 0);
479
480 // Reset and verify pool still works
481 pool.reset_cancel();
482 let c = Arc::clone(&counter);
483 pool.submit(move || {
484 c.fetch_add(1, Ordering::Relaxed);
485 });
486 // Wait for the post-reset task to complete BEFORE drop, since
487 // production Drop sets cancelled=true again and would skip
488 // any not-yet-pulled task.
489 wait_for_count(&counter, 1, 5_000);
490 drop(pool);
491 assert_eq!(counter.load(Ordering::Relaxed), 1);
492 }
493
494 #[test]
495 fn test_metrics() {
496 let _g = crate::test_util::global_state_lock();
497 let pool = WorkerPool::new(2);
498 assert_eq!(pool.completed(), 0);
499
500 for _ in 0..10 {
501 pool.submit(|| {});
502 }
503
504 drop(pool);
505 // Can't assert exact completed count due to timing,
506 // but it should be > 0 after drop waits for all
507 }
508
509 #[test]
510 fn test_backpressure_bounded() {
511 let _g = crate::test_util::global_state_lock();
512 // Pool of 1 with capacity 4 — 5th submit blocks (back-pressure)
513 // until the worker drains one. With 20 submits + 1 worker the
514 // pool's submit() call blocks naturally; by the time the loop
515 // exits, ~16 are completed and ~4 are still queued / in-flight.
516 let pool = WorkerPool::new(1);
517 let counter = Arc::new(AtomicUsize::new(0));
518
519 for _ in 0..20 {
520 let c = Arc::clone(&counter);
521 pool.submit(move || {
522 c.fetch_add(1, Ordering::Relaxed);
523 });
524 }
525
526 wait_for_count(&counter, 20, 5_000);
527 drop(pool);
528 assert_eq!(counter.load(Ordering::Relaxed), 20);
529 }
530
531 /// A pool thread must be identifiable as one, and `inputline()` must
532 /// report EOF there instead of prompting / reading SHIN.
533 ///
534 /// Regression: compinit's `-C` bytecode backfill parses ~47k autoload
535 /// bodies on the pool. A body whose lexer buffer drained mid-construct
536 /// fell through C's "as a last resort, get some more input" arm
537 /// (Src/input.c:354-356), so the WORKER read the user's terminal —
538 /// stealing keystrokes from ZLE, rendering PS2 (`> `) after every
539 /// `compinit -C`, and swallowing the following command lines.
540 #[test]
541 fn worker_threads_never_read_shin() {
542 let _g = crate::test_util::global_state_lock();
543 assert!(
544 !in_worker_thread(),
545 "the shell thread must not be flagged as a pool thread"
546 );
547
548 let pool = WorkerPool::new(1);
549 let flagged = Arc::new(AtomicUsize::new(0));
550 let eof = Arc::new(AtomicUsize::new(0));
551 let f = Arc::clone(&flagged);
552 let e = Arc::clone(&eof);
553 pool.submit(move || {
554 if in_worker_thread() {
555 f.store(1, Ordering::SeqCst);
556 }
557 // Returns 1 (EOF) immediately; never touches the terminal.
558 if crate::ported::input::inputline() == 1 {
559 e.store(1, Ordering::SeqCst);
560 }
561 });
562 wait_for_count(&eof, 1, 5_000);
563 drop(pool);
564
565 assert_eq!(flagged.load(Ordering::SeqCst), 1, "pool thread not flagged");
566 assert_eq!(
567 eof.load(Ordering::SeqCst),
568 1,
569 "inputline() must return EOF on a pool thread"
570 );
571 }
572}