Skip to main content

sema_core/
output_hook.rs

1use std::cell::{Cell, RefCell};
2use std::collections::{HashMap, HashSet};
3use std::rc::{Rc, Weak};
4
5use crate::runtime::{RootId, RuntimeId};
6
7type OutputHook = Option<Box<dyn Fn(&str) + Send>>;
8
9// HOST-ADAPTER-ONLY fallback output hooks (C05 / Commit C1).
10//
11// These thread-local hooks let a HOST adapter keep program stdout/stderr off the
12// process's real streams: the DAP server redirects program output into `Output`
13// events so prints don't corrupt the JSON-RPC stream, the MCP `eval_with_capture`
14// buffers it out of the protocol stream, the wasm host installs inert no-op
15// sinks (a real `print!` is an unsupported syscall on wasm32), and the
16// debug-session tests capture stderr. They are the fallback taken only when the
17// currently running root did NOT opt into root-tagged capture
18// (`OUTPUT_CAPTURE_ROUTES`, below) — the runtime-tagged path is unaffected.
19//
20// Contract (enforced): a hook is a non-suspending `Fn(&str)` — it MUST NOT block
21// or structurally suspend, because it runs an arbitrary host closure on the VM
22// thread inside a quantum where there is no runtime wait to yield to. A hook is
23// free to print, but a hook that itself calls `write_stdout`/`write_stderr`
24// re-enters as a PASS-THROUGH (a direct `print!`/`eprint!`), never an unbounded
25// recursion: `IN_HOST_OUTPUT_HOOK` latches the thread for the duration of one
26// hook invocation. Installation is HOST-ADAPTER-ONLY — the `set_host_*` naming
27// and the `HOST_OUTPUT_HOOK` source-policy allowlist pin every install site.
28thread_local! {
29    static HOST_STDOUT_HOOK: RefCell<OutputHook> = RefCell::new(None);
30    static HOST_STDERR_HOOK: RefCell<OutputHook> = RefCell::new(None);
31    // Latched for the duration of one host output-hook invocation on this
32    // thread so a hook that itself prints passes straight through instead of
33    // re-entering the hook (unbounded recursion). Shared across stdout/stderr so
34    // a cross-stream print inside a hook (a stdout hook that writes stderr) also
35    // passes through.
36    static IN_HOST_OUTPUT_HOOK: Cell<bool> = const { Cell::new(false) };
37}
38
39/// Install the thread-local HOST-ADAPTER-ONLY stdout capture hook; `None` clears.
40///
41/// HOST-ADAPTER-ONLY: only a host embedding (DAP/MCP/wasm/debug-session) that
42/// owns the process may install this. The hook must be a non-suspending
43/// `Fn(&str)` (see the module contract above); a hook that prints re-enters as a
44/// pass-through via the re-entrancy latch, never recursion. Runtime code routes
45/// output through root-tagged capture, never this fallback.
46pub fn set_host_stdout_hook(hook: OutputHook) {
47    HOST_STDOUT_HOOK.with(|cell| *cell.borrow_mut() = hook);
48}
49
50/// Install the thread-local HOST-ADAPTER-ONLY stderr capture hook; `None` clears.
51///
52/// See [`set_host_stdout_hook`] for the HOST-ADAPTER-ONLY / non-suspending
53/// contract and the re-entrancy latch.
54pub fn set_host_stderr_hook(hook: OutputHook) {
55    HOST_STDERR_HOOK.with(|cell| *cell.borrow_mut() = hook);
56}
57
58/// One line of program output captured for a root that opted into
59/// [`RootOptions::capture_output`](../../sema_eval/struct.RootOptions.html)
60/// instead of inheriting process stdout/stderr. Produced by
61/// [`write_stdout`]/[`write_stderr`] when the currently-running task's root
62/// is registered via [`mark_root_capturing`], drained by
63/// `Runtime::take_captured_output`.
64#[derive(Clone, Debug)]
65pub struct CapturedOutput {
66    pub root: RootId,
67    pub is_stderr: bool,
68    pub text: String,
69}
70
71thread_local! {
72    // Each runtime owns its output buffer. Routes are weak so this hook cannot
73    // keep a dropped runtime alive when its explicit teardown is bypassed.
74    static OUTPUT_CAPTURE_ROUTES: RefCell<HashMap<RuntimeId, Weak<RefCell<Vec<CapturedOutput>>>>> =
75        RefCell::new(HashMap::new());
76    // Roots currently opted into capture. A root is added here at submission
77    // (`capture_output: true`) and removed when it is reaped, so this never
78    // grows unbounded across a long-running host (REPL, notebook server).
79    static CAPTURING_ROOTS: RefCell<HashSet<RootId>> = RefCell::new(HashSet::new());
80    // Mirrors `CAPTURING_ROOTS.len()` as a plain counter so the print hot
81    // path can skip the hash-set lookup entirely with one `Cell` read when
82    // no root on this thread is capturing (the overwhelmingly common case —
83    // `capture_output` defaults to `false`).
84    static CAPTURING_COUNT: Cell<usize> = const { Cell::new(0) };
85    // The root of the task currently executing a quantum on this thread, set
86    // by the runtime around every VM step (mirrors `CURRENT_TASK_ID`).
87    static CURRENT_ROOT: Cell<Option<RootId>> = const { Cell::new(None) };
88}
89
90/// Register the buffer owned by `runtime_id`. Other live runtime routes and
91/// capturing-root markers on this thread remain intact.
92pub fn register_output_capture_sink(
93    runtime_id: RuntimeId,
94    sink: &Rc<RefCell<Vec<CapturedOutput>>>,
95) {
96    OUTPUT_CAPTURE_ROUTES.with(|routes| {
97        routes.borrow_mut().insert(runtime_id, Rc::downgrade(sink));
98    });
99}
100
101/// Remove one runtime's output route and any abandoned capturing-root markers
102/// it minted. Teardown is scoped by the full runtime identity so another live
103/// runtime on the same thread is unaffected.
104pub fn unregister_output_capture_sink(runtime_id: RuntimeId) {
105    OUTPUT_CAPTURE_ROUTES.with(|routes| {
106        routes.borrow_mut().remove(&runtime_id);
107    });
108    let remaining = CAPTURING_ROOTS.with(|roots| {
109        let mut roots = roots.borrow_mut();
110        roots.retain(|root| root.runtime() != runtime_id);
111        roots.len()
112    });
113    CAPTURING_COUNT.with(|count| count.set(remaining));
114}
115
116/// Test/introspection accessor for `CAPTURING_COUNT` — lets a white-box
117/// test (in another crate, so it can't reach the thread-local directly)
118/// assert the fast-path counter is clean after runtime teardown. Not
119/// `cfg(test)`: integration tests in downstream crates build this crate without
120/// the library's own `test` cfg, so a `cfg(test)`-gated item here would be
121/// invisible to them.
122#[doc(hidden)]
123pub fn capturing_root_count() -> usize {
124    CAPTURING_COUNT.with(Cell::get)
125}
126
127/// Mark `root` as capturing its output instead of inheriting process
128/// stdout/stderr. Idempotent.
129pub fn mark_root_capturing(root: RootId) {
130    CAPTURING_ROOTS.with(|set| {
131        if set.borrow_mut().insert(root) {
132            CAPTURING_COUNT.with(|c| c.set(c.get() + 1));
133        }
134    });
135}
136
137/// Stop capturing `root`'s output — called when a root is reaped, so the
138/// capturing set never accumulates dead entries. Idempotent.
139pub fn unmark_root_capturing(root: RootId) {
140    CAPTURING_ROOTS.with(|set| {
141        if set.borrow_mut().remove(&root) {
142            CAPTURING_COUNT.with(|c| c.set(c.get().saturating_sub(1)));
143        }
144    });
145}
146
147/// Publish `root` as the currently-executing quantum's root, returning the
148/// displaced value so the caller can restore it on quantum exit (mirrors
149/// [`crate::set_current_task_id`]).
150pub fn set_current_root(root: Option<RootId>) -> Option<RootId> {
151    CURRENT_ROOT.with(|cell| cell.replace(root))
152}
153
154/// Return the root published for the currently executing runtime quantum.
155/// Every runtime-driven VM quantum, including a root's main task, publishes
156/// this identity; host and ordinary compiled evaluation return `None`.
157pub fn current_root() -> Option<RootId> {
158    CURRENT_ROOT.with(Cell::get)
159}
160
161/// Append to the capture sink if the current quantum's root is capturing.
162/// Returns `true` if the text was captured (caller must not also print it).
163/// The `CAPTURING_COUNT == 0` check is a single cheap `Cell` read that keeps
164/// this a no-op branch for the default (non-capturing) path — no hash-set
165/// lookup, no allocation, unless at least one root on this thread actually
166/// opted in.
167fn try_capture(is_stderr: bool, s: &str) -> bool {
168    if CAPTURING_COUNT.with(Cell::get) == 0 {
169        return false;
170    }
171    let Some(root) = CURRENT_ROOT.with(Cell::get) else {
172        return false;
173    };
174    if !CAPTURING_ROOTS.with(|set| set.borrow().contains(&root)) {
175        return false;
176    }
177    let route = OUTPUT_CAPTURE_ROUTES.with(|routes| routes.borrow().get(&root.runtime()).cloned());
178    let Some(route) = route else {
179        return false;
180    };
181    let Some(sink) = route.upgrade() else {
182        unregister_output_capture_sink(root.runtime());
183        return false;
184    };
185    sink.borrow_mut().push(CapturedOutput {
186        root,
187        is_stderr,
188        text: s.to_string(),
189    });
190    true
191}
192
193#[cfg(test)]
194fn output_capture_route_count() -> usize {
195    OUTPUT_CAPTURE_ROUTES.with(|routes| routes.borrow().len())
196}
197
198/// RAII latch that closes the host output-hook re-entrancy hole. [`enter`] hands
199/// back `Some` only for the outermost hook invocation on this thread; while the
200/// guard is held, a hook that calls `write_stdout`/`write_stderr` again sees
201/// `None` and passes straight through. `Drop` clears the latch even if the hook
202/// panics, so a panicking hook can't wedge the thread into permanent pass-through.
203///
204/// [`enter`]: HostHookGuard::enter
205struct HostHookGuard;
206
207impl HostHookGuard {
208    fn enter() -> Option<Self> {
209        IN_HOST_OUTPUT_HOOK.with(|latched| {
210            if latched.get() {
211                None
212            } else {
213                latched.set(true);
214                Some(HostHookGuard)
215            }
216        })
217    }
218}
219
220impl Drop for HostHookGuard {
221    fn drop(&mut self) {
222        IN_HOST_OUTPUT_HOOK.with(|latched| latched.set(false));
223    }
224}
225
226/// Write a string to stdout: captured for the current quantum's root if it
227/// opted into `capture_output`, otherwise routed through the HOST-ADAPTER-ONLY
228/// fallback hook (if a host installed one) or via `print!`. A hook that itself
229/// prints re-enters through the latch as a direct `print!`, never recursion.
230pub fn write_stdout(s: &str) {
231    if try_capture(false, s) {
232        return;
233    }
234    match HostHookGuard::enter() {
235        Some(_guard) => HOST_STDOUT_HOOK.with(|cell| {
236            if let Some(hook) = cell.borrow().as_ref() {
237                hook(s);
238            } else {
239                print!("{}", s);
240            }
241        }),
242        // Re-entrant call from inside a running hook: pass through directly.
243        None => print!("{}", s),
244    }
245}
246
247/// Write a string to stderr: mirrors [`write_stdout`] — root capture first, then
248/// the HOST-ADAPTER-ONLY fallback hook or `eprint!`, guarded by the same
249/// re-entrancy latch so a hook that prints passes through instead of recursing.
250pub fn write_stderr(s: &str) {
251    if try_capture(true, s) {
252        return;
253    }
254    match HostHookGuard::enter() {
255        Some(_guard) => HOST_STDERR_HOOK.with(|cell| {
256            if let Some(hook) = cell.borrow().as_ref() {
257                hook(s);
258            } else {
259                eprint!("{}", s);
260            }
261        }),
262        // Re-entrant call from inside a running hook: pass through directly.
263        None => eprint!("{}", s),
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::runtime::{RootId, RuntimeId, RuntimeScopedIdCounter};
271
272    fn runtime_and_root() -> (RuntimeId, RootId) {
273        let runtime = RuntimeId::allocate().expect("runtime identity available");
274        let root = RuntimeScopedIdCounter::<RootId>::new(runtime)
275            .allocate()
276            .expect("root identity available");
277        (runtime, root)
278    }
279
280    #[test]
281    fn capture_routes_equal_local_roots_to_their_runtime_sinks() {
282        let (runtime_a, root_a) = runtime_and_root();
283        let (runtime_b, root_b) = runtime_and_root();
284        assert_eq!(root_a.local(), root_b.local());
285
286        let sink_a = Rc::new(RefCell::new(Vec::new()));
287        let sink_b = Rc::new(RefCell::new(Vec::new()));
288        register_output_capture_sink(runtime_a, &sink_a);
289        register_output_capture_sink(runtime_b, &sink_b);
290        mark_root_capturing(root_a);
291        mark_root_capturing(root_b);
292
293        set_current_root(Some(root_a));
294        write_stdout("A-only");
295        set_current_root(Some(root_b));
296        write_stdout("B-only");
297        set_current_root(None);
298
299        let events_a = sink_a.borrow();
300        assert!(matches!(
301            events_a.as_slice(),
302            [CapturedOutput { root, is_stderr: false, text }]
303                if *root == root_a && text == "A-only"
304        ));
305        let events_b = sink_b.borrow();
306        assert!(matches!(
307            events_b.as_slice(),
308            [CapturedOutput { root, is_stderr: false, text }]
309                if *root == root_b && text == "B-only"
310        ));
311
312        unregister_output_capture_sink(runtime_a);
313        unregister_output_capture_sink(runtime_b);
314    }
315
316    #[test]
317    fn unregister_and_dead_weak_pruning_are_scoped_to_one_runtime() {
318        let (runtime_a, root_a) = runtime_and_root();
319        let (runtime_b, root_b) = runtime_and_root();
320        let sink_a = Rc::new(RefCell::new(Vec::new()));
321        let sink_b = Rc::new(RefCell::new(Vec::new()));
322        register_output_capture_sink(runtime_a, &sink_a);
323        register_output_capture_sink(runtime_b, &sink_b);
324        mark_root_capturing(root_a);
325        mark_root_capturing(root_b);
326        assert_eq!(capturing_root_count(), 2);
327        assert_eq!(output_capture_route_count(), 2);
328
329        unregister_output_capture_sink(runtime_a);
330        assert_eq!(capturing_root_count(), 1);
331        assert_eq!(output_capture_route_count(), 1);
332
333        drop(sink_b);
334        set_host_stdout_hook(Some(Box::new(|_| {})));
335        set_current_root(Some(root_b));
336        write_stdout("dead route falls through");
337        set_current_root(None);
338
339        assert_eq!(capturing_root_count(), 0);
340        assert_eq!(output_capture_route_count(), 0);
341        set_host_stdout_hook(None);
342    }
343
344    #[test]
345    fn reentrant_host_hook_passes_through_without_recursion() {
346        use std::sync::atomic::{AtomicUsize, Ordering};
347        use std::sync::Arc;
348
349        // A host hook that itself prints. Without the re-entrancy latch its
350        // nested `write_stdout` would call the hook again — unbounded recursion
351        // (a stack overflow that aborts the process). With the latch, the nested
352        // write passes straight through, so the hook runs exactly once and the
353        // test returns.
354        let calls = Arc::new(AtomicUsize::new(0));
355        let calls_hook = calls.clone();
356        set_host_stdout_hook(Some(Box::new(move |_s: &str| {
357            calls_hook.fetch_add(1, Ordering::SeqCst);
358            write_stdout("nested-from-hook");
359        })));
360
361        write_stdout("outer");
362        set_host_stdout_hook(None);
363
364        assert_eq!(calls.load(Ordering::SeqCst), 1);
365    }
366
367    #[test]
368    fn host_hook_delivers_output_during_a_quantum() {
369        use std::sync::{Arc, Mutex};
370
371        // A DAP/MCP-style capture hook: a current root is published (a runtime
372        // quantum is active) but it did not opt into root-tagged capture, so
373        // output must still reach the host fallback hook.
374        let buf = Arc::new(Mutex::new(String::new()));
375        let sink = buf.clone();
376        set_host_stdout_hook(Some(Box::new(move |s: &str| {
377            sink.lock().unwrap().push_str(s);
378        })));
379
380        let (_runtime, root) = runtime_and_root();
381        set_current_root(Some(root));
382        write_stdout("hello ");
383        write_stdout("world");
384        set_current_root(None);
385        set_host_stdout_hook(None);
386
387        assert_eq!(buf.lock().unwrap().as_str(), "hello world");
388    }
389}