zsh/extensions/async_precmd.rs
1//! `async_precmd` hook — run precmd-style functions on a POOL WORKER THREAD so
2//! they never block prompt rendering.
3//!
4//! zsh's `precmd` hooks run synchronously before the prompt paints, so a slow
5//! hook stalls the prompt. `async_precmd` is a new lifecycle hook (no zsh
6//! equivalent): its functions run on the shared worker pool AFTER the prompt is
7//! built/rendered, writing their results into the shared, `RwLock`-synchronized
8//! global param table. The prompt reads whatever is currently there and never
9//! waits — a slow segment simply updates a prompt or two later.
10//!
11//! Registration mirrors zsh's hook arrays:
12//! * a function literally named `async_precmd`, and/or
13//! * members of the `async_precmd_functions` array.
14//!
15//! Built on the Phase-1 [`crate::vm_helper::ShellExecutor::new_worker`]
16//! lightweight worker executor. No isolation is needed here: `async_precmd`
17//! WANTS its `typeset -g` writes to land in the shared table.
18
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::{Arc, OnceLock};
21
22/// The session's shared worker pool, published by `ShellExecutor::new()` at
23/// startup. `preprompt()` runs BETWEEN commands where the thread_local
24/// `CURRENT_EXECUTOR` is not set, so `try_with_executor` returns `None` there —
25/// this global handle reaches the pool without an executor context.
26static SESSION_POOL: OnceLock<Arc<crate::worker::WorkerPool>> = OnceLock::new();
27
28/// Publish the session worker pool. Called once from `ShellExecutor::new()`.
29pub fn set_session_pool(pool: Arc<crate::worker::WorkerPool>) {
30 let _ = SESSION_POOL.set(pool);
31}
32
33/// True while an async_precmd batch is in flight. Debounce: if the previous
34/// batch hasn't finished by the next prompt, skip this round rather than pile
35/// up overlapping runs of the same hooks.
36static RUNNING: AtomicBool = AtomicBool::new(false);
37
38/// Collect the registered `async_precmd` hook function names: the function
39/// literally named `async_precmd` (if defined) followed by every member of the
40/// `async_precmd_functions` array, in order.
41///
42/// The hook functions must live in the shared global `shfunctab` for a worker
43/// to run them — which is the case for functions defined by SOURCED config
44/// (.zshrc / plugins), the normal way hooks are registered. (A function TYPED
45/// at the interactive prompt currently takes a compile path that registers it
46/// only in the per-executor table, so a worker can't see it — not the intended
47/// registration route for a hook.)
48fn collect_hook_functions() -> Vec<String> {
49 let mut names: Vec<String> = Vec::new();
50 if crate::ported::hashtable::shfunctab_lock()
51 .read()
52 .map(|t| t.get("async_precmd").is_some())
53 .unwrap_or(false)
54 {
55 names.push("async_precmd".to_string());
56 }
57 if let Ok(t) = crate::ported::params::paramtab().read() {
58 if let Some(p) = t.get("async_precmd_functions") {
59 if let Some(arr) = p.u_arr.clone() {
60 names.extend(arr);
61 }
62 }
63 }
64 names
65}
66
67/// Fire the `async_precmd` hooks on a worker thread. Called from `preprompt()`
68/// AFTER precmd + prompt render, so the prompt is already on screen. Returns
69/// immediately (non-blocking): it submits ONE closure to the shared worker pool
70/// and lets it run in the background. Debounced via [`RUNNING`].
71pub fn fire_async_precmd() {
72 let names = collect_hook_functions();
73 if names.is_empty() {
74 return;
75 }
76 // Debounce: only one batch in flight at a time.
77 if RUNNING.swap(true, Ordering::AcqRel) {
78 return;
79 }
80 tracing::debug!(?names, "async_precmd: dispatching hooks to worker pool");
81 // Reach the shared worker pool via the global session handle — the
82 // executor context is not entered during preprompt.
83 let Some(pool) = SESSION_POOL.get().map(Arc::clone) else {
84 tracing::warn!("async_precmd: session pool not published yet — skipping");
85 RUNNING.store(false, Ordering::Release);
86 return;
87 };
88 let pool_for_worker = std::sync::Arc::clone(&pool);
89 pool.submit(move || {
90 // Lightweight worker executor — shares the global param/function tables.
91 let mut wex = crate::vm_helper::ShellExecutor::new_worker(pool_for_worker);
92 for name in &names {
93 // A hook whose function is gone is SKIPPED, never executed as a
94 // command word. c:Src/utils.c:1514-1518 -- `callhookfunc` looks
95 // each `${hook}_functions` member up in `shfunctab` and only
96 // calls it `if (shfunc)`; a stale name is silently ignored. The
97 // ported loop in `ported::utils::callhookfunc` does the same.
98 //
99 // Running the name through the script pipeline instead made this
100 // path diverge: with no function to find, the word fell through
101 // to `execute_external` and printed
102 // zshrs: command not found: :hist:precmd
103 // once per prompt, forever. Self-removing hooks reach that state
104 // by design -- zsh-hist registers `:hist:precmd`, and on its
105 // first run the body does `add-zsh-hook -d precmd $0` followed by
106 // `unfunction $0`. The delete names the `precmd` hook, so a copy
107 // registered on `async_precmd` keeps the now-dangling name.
108 //
109 // Looking the function up also skips a re-parse per hook per
110 // prompt, and stops the name being subjected to alias expansion
111 // and globbing on its way to being run.
112 if !wex.function_exists(name) {
113 tracing::debug!(hook = %name, "async_precmd: no such function — skipping");
114 continue;
115 }
116 // Invoking the function by name runs its body on this worker; any
117 // `typeset -g` lands in the shared global param table.
118 let _ = wex.execute_script_zsh_pipeline(name);
119 }
120 RUNNING.store(false, Ordering::Release);
121 });
122}