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}