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//!
19//! ## The batch never overlaps shell code on the shell thread
20//!
21//! A hook is a shell FUNCTION, and running one moves state C keeps for a
22//! single thread of execution: `locallevel` (`Src/params.c:5837`
23//! `locallevel++` in `startparamscope`, `:5856` `locallevel--` in
24//! `endparamscope`) and the level-stamped entries of the one `paramtab`,
25//! which `scanendscope` (`Src/params.c:5904-5907`, `if (pm->level >
26//! locallevel)`) restores or deletes on every scope exit. The worker's
27//! pushes and pops land on the same counter and the same table as the
28//! shell thread's.
29//!
30//! The batch is fired from `preprompt()`, and the first thing the shell
31//! thread does after the prompt paints is usually run a widget: a
32//! shell-function widget brackets its body with `startparamscope();
33//! makezleparams(0); … endparamscope();` (`Src/Zle/zle_main.c:1533-1540`),
34//! and `makezleparams` stamps `$BUFFER` and its family with the scope level
35//! (`Src/Zle/zle_params.c:206`). A worker scope exit that lands inside that
36//! window deletes the widget's `$BUFFER`, and the write-back in
37//! `zle_param_sync::sync_from_paramtab` then copies the now-empty value
38//! into the editor: every character typed so far on the line disappears.
39//! Measured with a self-insert wrapper (`zle -N self-insert f; f() { zle
40//! .self-insert }`, the shape zpwr's `zpwrSelfInsert` has) and a hook that
41//! runs for half a second: most typed lines lost a prefix, and a trace
42//! showed the worker's `endparamscope` removing `BUFFER` at the widget's
43//! level immediately before the editor was overwritten with "".
44//! A top-level `typeset X=1` typed during the batch was stamped with the
45//! worker's level and vanished when the hook returned.
46//!
47//! So shell code on the shell thread waits for a batch that is already
48//! running ([`quiesce`]), and withdraws one the pool has not started yet.
49//! Plain typing is not held up: builtin widgets never open a scope. The
50//! first scope usually opens after the prompt is on screen; a
51//! `zle-line-init` widget is the exception, because this port's `zleread`
52//! runs it before its first `zrefresh` (C paints first, c:1353, then calls
53//! the hook, c:1357), so a running batch delays that paint by the hook's
54//! runtime.
55
56use std::sync::atomic::{AtomicU8, Ordering};
57use std::sync::{Arc, Condvar, Mutex, OnceLock};
58
59/// The session's shared worker pool, published by `ShellExecutor::new()` at
60/// startup. `preprompt()` runs BETWEEN commands where the thread_local
61/// `CURRENT_EXECUTOR` is not set, so `try_with_executor` returns `None` there —
62/// this global handle reaches the pool without an executor context.
63static SESSION_POOL: OnceLock<Arc<crate::worker::WorkerPool>> = OnceLock::new();
64
65/// Publish the session worker pool. Called once from `ShellExecutor::new()`.
66pub fn set_session_pool(pool: Arc<crate::worker::WorkerPool>) {
67 let _ = SESSION_POOL.set(pool);
68}
69
70/// Where the current batch is. Debounce: while it is not [`IDLE`], the next
71/// prompt skips its round rather than pile up overlapping runs of the same
72/// hooks.
73static BATCH: AtomicU8 = AtomicU8::new(IDLE);
74/// No batch in flight.
75const IDLE: u8 = 0;
76/// Submitted to the pool, not started. [`quiesce`] may withdraw it.
77const QUEUED: u8 = 1;
78/// A worker is running the hooks. [`quiesce`] waits for it.
79const RUNNING: u8 = 2;
80
81/// Paired with [`BATCH_DONE`]: the worker flips `BATCH` back to [`IDLE`]
82/// while holding it, so a waiter that checked `BATCH` under the same lock
83/// cannot miss the wake-up.
84static BATCH_LOCK: Mutex<()> = Mutex::new(());
85static BATCH_DONE: Condvar = Condvar::new();
86
87/// Returns the batch to [`IDLE`] and wakes [`quiesce`] however the worker
88/// leaves the hooks — including by unwinding out of a panicking one, which
89/// would otherwise leave the shell thread waiting forever.
90struct BatchFinished;
91
92impl Drop for BatchFinished {
93 fn drop(&mut self) {
94 let _guard = BATCH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
95 BATCH.store(IDLE, Ordering::Release);
96 BATCH_DONE.notify_all();
97 }
98}
99
100/// Make the shell thread safe to run shell code: wait for a batch a worker
101/// is running, or withdraw one that is only queued (its hooks run on the
102/// next prompt instead). Returns at once when no batch is in flight, and on
103/// any thread but the shell's — a worker must never wait on its own batch.
104///
105/// Called where the shell thread opens a parameter scope
106/// (`utils::inc_locallevel`, every function, widget, hook and trap call)
107/// and before it executes an accepted line at top level (`init::loop_`).
108/// After it returns no batch can start until the next `preprompt()`, since
109/// that is the only place one is fired.
110pub fn quiesce() {
111 if BATCH.load(Ordering::Acquire) == IDLE {
112 return;
113 }
114 // Same shell-thread test as `errflag_cell`: the shell runs on "main",
115 // pool workers are named "zshrs-worker-N".
116 if std::thread::current().name() != Some("main") {
117 return;
118 }
119 if BATCH
120 .compare_exchange(QUEUED, IDLE, Ordering::AcqRel, Ordering::Acquire)
121 .is_ok()
122 {
123 tracing::debug!("async_precmd: batch withdrawn before it started");
124 return;
125 }
126 let mut guard = BATCH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
127 while BATCH.load(Ordering::Acquire) == RUNNING {
128 guard = BATCH_DONE.wait(guard).unwrap_or_else(|e| e.into_inner());
129 }
130}
131
132/// Collect the registered `async_precmd` hook function names: the function
133/// literally named `async_precmd` (if defined) followed by every member of the
134/// `async_precmd_functions` array, in order.
135///
136/// The hook functions must live in the shared global `shfunctab` for a worker
137/// to run them — which is the case for functions defined by SOURCED config
138/// (.zshrc / plugins), the normal way hooks are registered. (A function TYPED
139/// at the interactive prompt currently takes a compile path that registers it
140/// only in the per-executor table, so a worker can't see it — not the intended
141/// registration route for a hook.)
142fn collect_hook_functions() -> Vec<String> {
143 let mut names: Vec<String> = Vec::new();
144 if crate::ported::hashtable::shfunctab_lock()
145 .read()
146 .map(|t| t.get("async_precmd").is_some())
147 .unwrap_or(false)
148 {
149 names.push("async_precmd".to_string());
150 }
151 if let Ok(t) = crate::ported::params::paramtab().read() {
152 if let Some(p) = t.get("async_precmd_functions") {
153 if let Some(arr) = p.u_arr.clone() {
154 names.extend(arr);
155 }
156 }
157 }
158 names
159}
160
161/// Fire the `async_precmd` hooks on a worker thread. Called from `preprompt()`
162/// AFTER precmd + prompt render, so the prompt is already on screen. Returns
163/// immediately (non-blocking): it submits ONE closure to the shared worker pool
164/// and lets it run in the background. Debounced via [`BATCH`].
165pub fn fire_async_precmd() {
166 let names = collect_hook_functions();
167 if names.is_empty() {
168 return;
169 }
170 // Debounce: only one batch in flight at a time.
171 if BATCH
172 .compare_exchange(IDLE, QUEUED, Ordering::AcqRel, Ordering::Acquire)
173 .is_err()
174 {
175 return;
176 }
177 tracing::debug!(?names, "async_precmd: dispatching hooks to worker pool");
178 // Reach the shared worker pool via the global session handle — the
179 // executor context is not entered during preprompt.
180 let Some(pool) = SESSION_POOL.get().map(Arc::clone) else {
181 tracing::warn!("async_precmd: session pool not published yet — skipping");
182 BATCH.store(IDLE, Ordering::Release);
183 return;
184 };
185 let pool_for_worker = std::sync::Arc::clone(&pool);
186 pool.submit(move || {
187 // The shell thread withdrew the batch (see `quiesce`) before a
188 // worker got to it: it is already running shell code.
189 if BATCH
190 .compare_exchange(QUEUED, RUNNING, Ordering::AcqRel, Ordering::Acquire)
191 .is_err()
192 {
193 return;
194 }
195 let _finished = BatchFinished;
196 // Lightweight worker executor — shares the global param/function tables.
197 let mut wex = crate::vm_helper::ShellExecutor::new_worker(pool_for_worker);
198 for name in &names {
199 // A hook whose function is gone is SKIPPED, never executed as a
200 // command word. c:Src/utils.c:1514-1518 -- `callhookfunc` looks
201 // each `${hook}_functions` member up in `shfunctab` and only
202 // calls it `if (shfunc)`; a stale name is silently ignored. The
203 // ported loop in `ported::utils::callhookfunc` does the same.
204 //
205 // Running the name through the script pipeline instead made this
206 // path diverge: with no function to find, the word fell through
207 // to `execute_external` and printed
208 // zshrs: command not found: :hist:precmd
209 // once per prompt, forever. Self-removing hooks reach that state
210 // by design -- zsh-hist registers `:hist:precmd`, and on its
211 // first run the body does `add-zsh-hook -d precmd $0` followed by
212 // `unfunction $0`. The delete names the `precmd` hook, so a copy
213 // registered on `async_precmd` keeps the now-dangling name.
214 //
215 // Looking the function up also skips a re-parse per hook per
216 // prompt, and stops the name being subjected to alias expansion
217 // and globbing on its way to being run.
218 if !wex.function_exists(name) {
219 tracing::debug!(hook = %name, "async_precmd: no such function — skipping");
220 continue;
221 }
222 // Invoking the function by name runs its body on this worker; any
223 // `typeset -g` lands in the shared global param table.
224 let _ = wex.execute_script_zsh_pipeline(name);
225 }
226 });
227}