Skip to main content

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 builder = thread::Builder::new().name(format!("zshrs-worker-{}", id));
148            let handle = crate::signal_thread::spawn_with(builder, move || {
149                    // Rust-only: mark this thread as pool-owned so the
150                    // input layer never reads the user's tty from it.
151                    IN_WORKER.with(|f| f.set(true));
152                    loop {
153                        let task = match rx.recv() {
154                            Ok(task) => task,
155                            Err(_) => break, // channel closed → shutdown
156                        };
157
158                        queued.fetch_sub(1, Ordering::Relaxed);
159
160                        // Check cancellation before running
161                        if cancelled.load(Ordering::Relaxed) {
162                            continue; // drain without executing
163                        }
164
165                        // Every task starts on a clear error flag. The
166                        // thread's `errflag` is private (see
167                        // crate::errflag_cell), so an abort or parse error
168                        // left behind by the PREVIOUS task on this same
169                        // thread would otherwise be inherited — C never
170                        // has that problem because its equivalent of a
171                        // task is a fresh forked child.
172                        crate::ported::utils::errflag.store(0, Ordering::Relaxed);
173                        // catch_unwind keeps the worker alive if a task panics
174                        if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(task))
175                        {
176                            let msg = if let Some(s) = e.downcast_ref::<&str>() {
177                                (*s).to_string()
178                            } else if let Some(s) = e.downcast_ref::<String>() {
179                                s.clone()
180                            } else {
181                                "unknown panic".to_string()
182                            };
183                            tracing::error!(
184                                worker = id,
185                                panic = %msg,
186                                "worker task panicked"
187                            );
188                        }
189
190                        completed.fetch_add(1, Ordering::Relaxed);
191                    }
192                    tracing::debug!(worker = id, "worker thread exiting");
193                })
194                .expect("failed to spawn worker thread");
195
196            workers.push(Worker {
197                id,
198                handle: Some(handle),
199            });
200        }
201
202        self.spawned.store(true, Ordering::Relaxed);
203        drop(workers);
204        tracing::info!(pool_size = size, "worker pool started");
205    }
206
207    /// Create a pool sized to the machine's parallelism, clamped to
208    /// `[2, 18]`.
209    /// zshrs-original — no C counterpart. C zsh has no concept of a
210    /// "pool size" because it forks on demand (one child per
211    /// background task, see Src/jobs.c).
212    pub fn default_size() -> Self {
213        let cpus = thread::available_parallelism()
214            .map(|n| n.get())
215            .unwrap_or(4);
216        Self::new(cpus.clamp(2, 18))
217    }
218
219    /// Submit a task to the pool. Blocks if the queue is full
220    /// (backpressure). Panics if the pool has been shut down.
221    /// zshrs-original — replaces the `fork() + execve()` /
222    /// `fork() + run-shell-fn` dispatch pairs in Src/exec.c.
223    pub fn submit<F>(&self, f: F)
224    where
225        F: FnOnce() + Send + 'static,
226    {
227        self.ensure_spawned();
228        let depth = self.queued.fetch_add(1, Ordering::Relaxed) + 1;
229        if depth > self.size * 2 {
230            tracing::debug!(queue_depth = depth, "worker pool queue building up");
231        }
232        self.sender
233            .as_ref()
234            .expect("pool shut down")
235            .send(Box::new(f))
236            .expect("all workers dead");
237    }
238
239    /// Submit a task and get a receiver for its result.
240    /// zshrs-original — closest C analog is the pipe-based
241    /// command-substitution result capture in Src/exec.c
242    /// (`getoutput()` reading the child's stdout pipe), but using a
243    /// typed Rust channel sidesteps the marshalling.
244    pub fn submit_with_result<F, R>(&self, f: F) -> crossbeam_channel::Receiver<R>
245    where
246        F: FnOnce() -> R + Send + 'static,
247        R: Send + 'static,
248    {
249        let (tx, rx) = crossbeam_channel::bounded(1);
250        self.submit(move || {
251            let result = f();
252            let _ = tx.send(result);
253        });
254        rx
255    }
256
257    /// Signal all workers to drop pending tasks.
258    /// Already-running tasks will finish, but queued tasks are
259    /// skipped. Reset with `reset_cancel()`.
260    /// zshrs-original — closest C analog is the SIGINT/SIGQUIT
261    /// signal-storm dispatch C zsh fires at its background children
262    /// in Src/signals.c (`killjb()` / `killpg()`), but here we set a
263    /// flag instead of sending a signal across a fork boundary.
264    pub fn cancel(&self) {
265        self.cancelled.store(true, Ordering::Relaxed);
266        tracing::info!("worker pool: cancel requested");
267    }
268
269    /// Clear the cancellation flag — pool resumes normal execution.
270    /// zshrs-original — no C counterpart.
271    pub fn reset_cancel(&self) {
272        self.cancelled.store(false, Ordering::Relaxed);
273    }
274
275    /// Number of worker threads.
276    /// zshrs-original — no C counterpart.
277    pub fn size(&self) -> usize {
278        self.size
279    }
280
281    /// Approximate number of tasks waiting in the queue.
282    /// zshrs-original — no C counterpart; closest equivalent is the
283    /// `jobtab` length walk Src/jobs.c uses for `jobs -l` output.
284    pub fn queue_depth(&self) -> usize {
285        self.queued.load(Ordering::Relaxed)
286    }
287
288    /// Total tasks completed since pool creation.
289    /// zshrs-original — no C counterpart.
290    pub fn completed(&self) -> usize {
291        self.completed.load(Ordering::Relaxed)
292    }
293}
294
295impl Drop for WorkerPool {
296    fn drop(&mut self) {
297        // Signal workers to skip remaining queued tasks
298        self.cancelled.store(true, Ordering::Relaxed);
299        // Drop the sender → channel closes → recv() returns Err → threads exit
300        drop(self.sender.take());
301        // Give workers a brief window to finish their current task.
302        // Don't block indefinitely — the process is exiting.
303        let mut workers = self.workers.lock().unwrap_or_else(|e| e.into_inner());
304        for w in workers.iter_mut() {
305            if let Some(handle) = w.handle.take() {
306                // Detach the thread — OS cleans up on process exit.
307                // join() would block if a worker is mid-parse on a 500-line
308                // completion function. Not worth the wait on Ctrl-D/exit.
309                drop(handle);
310            }
311        }
312        // Demoted from `info!` to `debug!` so the default tracing
313        // filter (INFO) suppresses it. The bare shutdown announcement
314        // has no operational value — interesting telemetry would be
315        // a non-zero error count or a stuck worker, which warrants its
316        // own surface. Empirically (bug #23 in docs/BUGS.md) the
317        // existing info! also leaked to stdout when a script left a
318        // duped fd open (`exec 3>&1`): by the time worker Drop runs,
319        // the file-backed log writer is closed, and tracing's fallback
320        // writes to fd 1 — which is the original stdout the dup
321        // pointed at. Default INFO filter no longer triggers this code
322        // path at all in normal use.
323        tracing::debug!(
324            tasks_completed = self.completed.load(Ordering::Relaxed),
325            "worker pool shut down"
326        );
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    /// Spin-wait helper for tests: poll `counter` until it reaches
335    /// `target` or the deadline elapses. Replaces the old "drop(pool)
336    /// implicitly waits" pattern, which broke when production Drop
337    /// switched to setting cancelled=true (so queued tasks would be
338    /// skipped on drop instead of drained).
339    fn wait_for_count(counter: &AtomicUsize, target: usize, max_wait_ms: u64) {
340        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(max_wait_ms);
341        while counter.load(Ordering::Relaxed) < target {
342            if std::time::Instant::now() >= deadline {
343                panic!(
344                    "wait_for_count timed out: counter={} target={} after {}ms",
345                    counter.load(Ordering::Relaxed),
346                    target,
347                    max_wait_ms
348                );
349            }
350            std::thread::sleep(std::time::Duration::from_millis(2));
351        }
352    }
353
354    #[test]
355    fn test_pool_executes_tasks() {
356        let _g = crate::test_util::global_state_lock();
357        let pool = WorkerPool::new(2);
358        let counter = Arc::new(AtomicUsize::new(0));
359
360        for _ in 0..100 {
361            let c = Arc::clone(&counter);
362            pool.submit(move || {
363                c.fetch_add(1, Ordering::Relaxed);
364            });
365        }
366
367        // Drain explicitly — production Drop sets cancelled=true and
368        // skips queued tasks (intentional for shell exit), so the test
369        // can't rely on `drop(pool)` to wait.
370        wait_for_count(&counter, 100, 5_000);
371        drop(pool);
372        assert_eq!(counter.load(Ordering::Relaxed), 100);
373    }
374
375    #[test]
376    fn test_submit_with_result() {
377        let _g = crate::test_util::global_state_lock();
378        let pool = WorkerPool::new(2);
379        let rx = pool.submit_with_result(|| 42);
380        assert_eq!(rx.recv().unwrap(), 42);
381    }
382
383    #[test]
384    fn test_default_size() {
385        let _g = crate::test_util::global_state_lock();
386        let pool = WorkerPool::default_size();
387        assert!(pool.size() >= 2);
388        assert!(pool.size() <= 18);
389    }
390
391    #[test]
392    fn test_panic_does_not_kill_worker() {
393        let _g = crate::test_util::global_state_lock();
394        let pool = WorkerPool::new(2);
395        let counter = Arc::new(AtomicUsize::new(0));
396
397        // Submit a task that panics
398        pool.submit(|| panic!("intentional test panic"));
399
400        // Submit tasks after the panic — they should still run
401        for _ in 0..10 {
402            let c = Arc::clone(&counter);
403            pool.submit(move || {
404                c.fetch_add(1, Ordering::Relaxed);
405            });
406        }
407
408        wait_for_count(&counter, 10, 5_000);
409        drop(pool);
410        assert_eq!(counter.load(Ordering::Relaxed), 10);
411    }
412
413    #[test]
414    fn test_cancel_skips_queued_tasks() {
415        let _g = crate::test_util::global_state_lock();
416        let pool = WorkerPool::new(1); // single worker to control ordering
417        let barrier = Arc::new(std::sync::Barrier::new(2));
418        // Signal the worker fires when it ENTERS the barrier task. Lets
419        // the main thread wait until the worker is provably blocked
420        // inside the barrier BEFORE calling cancel(). Without this, a
421        // pre-empted worker that hasn't yet pulled task #1 would see the
422        // cancel flag, skip task #1, and the main thread's barrier.wait()
423        // below would deadlock waiting for a second party that never
424        // arrives.
425        let started = Arc::new(std::sync::Mutex::new(false));
426        let started_cv = Arc::new(std::sync::Condvar::new());
427        let counter = Arc::new(AtomicUsize::new(0));
428
429        let b = Arc::clone(&barrier);
430        let started_clone = Arc::clone(&started);
431        let cv_clone = Arc::clone(&started_cv);
432        pool.submit(move || {
433            // Mark "task entered" + notify before blocking.
434            *started_clone.lock().unwrap() = true;
435            cv_clone.notify_one();
436            b.wait();
437        });
438
439        // Wait until the worker is provably inside the task (and thus
440        // committed to calling b.wait() — no race with cancel below).
441        // 5s timeout is a safety net; in practice this fires within μs.
442        let mut g = started.lock().unwrap();
443        let timeout = std::time::Duration::from_secs(5);
444        while !*g {
445            let (gg, wait_result) = started_cv.wait_timeout(g, timeout).unwrap();
446            g = gg;
447            if wait_result.timed_out() && !*g {
448                panic!("worker never started task #1 within 5s — test scaffolding broken");
449            }
450        }
451        drop(g);
452
453        // Queue tasks that should be skipped (worker is parked at b.wait()).
454        // Cap at channel capacity (size * 4 = 4 for a 1-worker pool) MINUS 1
455        // for safety. Submitting more than the channel holds while the
456        // worker is blocked deadlocks `submit` itself, since the bounded
457        // crossbeam channel back-pressures `send()`. 3 skipped tasks is
458        // enough to prove "queued tasks get cancelled" — the count isn't
459        // load-bearing.
460        for _ in 0..3 {
461            let c = Arc::clone(&counter);
462            pool.submit(move || {
463                c.fetch_add(1, Ordering::Relaxed);
464            });
465        }
466
467        // Cancel, then unblock the worker — it'll return from b.wait(),
468        // loop, see cancelled=true, drain the 5 queued tasks without
469        // executing them.
470        pool.cancel();
471        barrier.wait();
472
473        // Give workers time to drain
474        std::thread::sleep(std::time::Duration::from_millis(50));
475
476        // Queued tasks should have been skipped
477        assert_eq!(counter.load(Ordering::Relaxed), 0);
478
479        // Reset and verify pool still works
480        pool.reset_cancel();
481        let c = Arc::clone(&counter);
482        pool.submit(move || {
483            c.fetch_add(1, Ordering::Relaxed);
484        });
485        // Wait for the post-reset task to complete BEFORE drop, since
486        // production Drop sets cancelled=true again and would skip
487        // any not-yet-pulled task.
488        wait_for_count(&counter, 1, 5_000);
489        drop(pool);
490        assert_eq!(counter.load(Ordering::Relaxed), 1);
491    }
492
493    #[test]
494    fn test_metrics() {
495        let _g = crate::test_util::global_state_lock();
496        let pool = WorkerPool::new(2);
497        assert_eq!(pool.completed(), 0);
498
499        for _ in 0..10 {
500            pool.submit(|| {});
501        }
502
503        drop(pool);
504        // Can't assert exact completed count due to timing,
505        // but it should be > 0 after drop waits for all
506    }
507
508    #[test]
509    fn test_backpressure_bounded() {
510        let _g = crate::test_util::global_state_lock();
511        // Pool of 1 with capacity 4 — 5th submit blocks (back-pressure)
512        // until the worker drains one. With 20 submits + 1 worker the
513        // pool's submit() call blocks naturally; by the time the loop
514        // exits, ~16 are completed and ~4 are still queued / in-flight.
515        let pool = WorkerPool::new(1);
516        let counter = Arc::new(AtomicUsize::new(0));
517
518        for _ in 0..20 {
519            let c = Arc::clone(&counter);
520            pool.submit(move || {
521                c.fetch_add(1, Ordering::Relaxed);
522            });
523        }
524
525        wait_for_count(&counter, 20, 5_000);
526        drop(pool);
527        assert_eq!(counter.load(Ordering::Relaxed), 20);
528    }
529
530    /// A pool thread must be identifiable as one, and `inputline()` must
531    /// report EOF there instead of prompting / reading SHIN.
532    ///
533    /// Regression: compinit's `-C` bytecode backfill parses ~47k autoload
534    /// bodies on the pool. A body whose lexer buffer drained mid-construct
535    /// fell through C's "as a last resort, get some more input" arm
536    /// (Src/input.c:354-356), so the WORKER read the user's terminal —
537    /// stealing keystrokes from ZLE, rendering PS2 (`> `) after every
538    /// `compinit -C`, and swallowing the following command lines.
539    #[test]
540    fn worker_threads_never_read_shin() {
541        let _g = crate::test_util::global_state_lock();
542        assert!(
543            !in_worker_thread(),
544            "the shell thread must not be flagged as a pool thread"
545        );
546
547        let pool = WorkerPool::new(1);
548        let flagged = Arc::new(AtomicUsize::new(0));
549        let eof = Arc::new(AtomicUsize::new(0));
550        let f = Arc::clone(&flagged);
551        let e = Arc::clone(&eof);
552        pool.submit(move || {
553            if in_worker_thread() {
554                f.store(1, Ordering::SeqCst);
555            }
556            // Returns 1 (EOF) immediately; never touches the terminal.
557            if crate::ported::input::inputline() == 1 {
558                e.store(1, Ordering::SeqCst);
559            }
560        });
561        wait_for_count(&eof, 1, 5_000);
562        drop(pool);
563
564        assert_eq!(flagged.load(Ordering::SeqCst), 1, "pool thread not flagged");
565        assert_eq!(
566            eof.load(Ordering::SeqCst),
567            1,
568            "inputline() must return EOF on a pool thread"
569        );
570    }
571
572    /// A pool-thread parse must leave the main thread's open history line
573    /// alone. Async precmd hooks parse on the pool while the shell reads a
574    /// command; their strinbeg/strinend cleared histactive, so the main
575    /// line's hend ran unlinkcurline, curhist dropped by one and the next
576    /// command reused an event number (`fc -l` then looped on it).
577    #[test]
578    fn worker_parse_leaves_the_main_history_line_alone() {
579        use crate::ported::hist::{curhist, histactive, HA_ACTIVE};
580        let _g = crate::test_util::global_state_lock();
581
582        let saved = (
583            histactive.load(Ordering::SeqCst),
584            curhist.load(Ordering::SeqCst),
585        );
586        histactive.store(HA_ACTIVE, Ordering::SeqCst);
587        curhist.store(41, Ordering::SeqCst);
588
589        // The damage is transient: the pool parse's own save/restore puts the
590        // values back by the time it returns, so watch them WHILE it runs.
591        let pool = WorkerPool::new(1);
592        let done = Arc::new(AtomicUsize::new(0));
593        let d = Arc::clone(&done);
594        pool.submit(move || {
595            for _ in 0..200 {
596                let _ = crate::vm_helper::parse_isolated_at("print hook; x=${y[1]}", None);
597            }
598            d.store(1, Ordering::SeqCst);
599        });
600        let mut seen = std::collections::BTreeSet::new();
601        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
602        while done.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline {
603            seen.insert((
604                histactive.load(Ordering::SeqCst),
605                curhist.load(Ordering::SeqCst),
606            ));
607        }
608        drop(pool);
609
610        histactive.store(saved.0, Ordering::SeqCst);
611        curhist.store(saved.1, Ordering::SeqCst);
612        assert_eq!(done.load(Ordering::SeqCst), 1, "pool parses did not finish");
613        assert_eq!(
614            seen.into_iter().collect::<Vec<_>>(),
615            vec![(HA_ACTIVE, 41)],
616            "(histactive, curhist) observed while a pool thread parsed"
617        );
618    }
619}