oxdock_core/exec/state.rs
1use std::collections::HashMap;
2use std::marker::PhantomData;
3use std::sync::atomic::{AtomicBool, AtomicU64};
4use std::sync::{Arc, Condvar, Mutex};
5
6use anyhow::Result;
7use oxdock_fs::{CargoScratch, GuardedPath, WorkspaceFs};
8use oxdock_parser::{Step, TypeDescriptor, Value};
9use oxdock_process::{BackgroundHandle, CommandContext, ProcessManager};
10
11use super::capture::SpillBuffer;
12use super::io::{ExactCapture, ExecIo, SlidingWindow};
13use super::native::FunctionRegistry;
14use super::pipe::KeeperGuard;
15
16/// Maximum nested function-call depth. Guards the host thread stack against
17/// runaway recursion; the error names the function that overflowed.
18pub(super) const MAX_CALL_DEPTH: usize = 64;
19
20/// Execution state for one script run. Public so hosts can register
21/// functions and introspect listings; all fields stay crate-private so the
22/// scope, recursion-budget, and task invariants cannot be broken from outside.
23pub struct ExecState<P: ProcessManager> {
24 pub(super) fs: Box<dyn WorkspaceFs>,
25 /// Pre-reserved guarded scratch name for `CARGO_TARGET_DIR` (issue #131).
26 /// Opaque [`CargoScratch`]: renderable for the child environment but not
27 /// nameable as a `&GuardedPath`, so host code cannot `ensure()` or
28 /// `create_dir_all` it. The child `cargo` creates it on demand.
29 pub(super) cargo_scratch: CargoScratch,
30 pub(super) cwd: GuardedPath,
31 pub(super) envs: Arc<HashMap<String, String>>,
32 pub(super) bg_children: Vec<Box<dyn BackgroundHandle>>,
33 pub(super) scope_stack: Vec<ScopeSnapshot>,
34 pub(super) io: ExecIo,
35 /// Pre-registered SlidingWindow observers for stream assertion steps.
36 /// Keyed by (generation, step index). TeeWriter pushes every chunk to all windows.
37 pub(super) assert_windows: Arc<Mutex<HashMap<(usize, usize), SlidingWindow>>>,
38 /// Same observer map fed by the stderr tee for `ASSERT_CONTAINS stderr`.
39 pub(super) assert_windows_stderr: Arc<Mutex<HashMap<(usize, usize), SlidingWindow>>>,
40 /// Exact-match stdout accumulators for `ASSERT_EQ stdout`, keyed by
41 /// generation and fed by the same tee. Entries allocated during
42 /// pre-registration observe bytes from scope entry; the common
43 /// top-level case therefore sees trial-cumulative output.
44 pub(super) exact_stdout: Arc<Mutex<HashMap<usize, ExactCapture>>>,
45 /// Variable scopes for $variable bindings (FOR loops, LET assignments).
46 /// Innermost scope is last. Variables are looked up from innermost to outermost.
47 /// Each entry carries its declared type name alongside the value.
48 pub(super) var_scopes: Vec<HashMap<String, (String, Value)>>,
49 /// Name directory for type resolution: startup descriptors plus the
50 /// run's host descriptors. Words carry their own vtables, so this map
51 /// serves only name queries (declarations, `TYPES()`, `TYPE_DESCRIBE`).
52 pub(super) types: HashMap<String, &'static TypeDescriptor>,
53 /// Cancellation token for background thread teardown.
54 #[allow(dead_code)]
55 pub(super) cancel_token: Arc<AtomicBool>,
56 /// Handle to the currently executing foreground OS process, so
57 /// ThreadJoinHandle::kill() can interrupt a blocking wait().
58 #[allow(dead_code)]
59 pub(super) active_process: Arc<Mutex<Option<Box<dyn BackgroundHandle>>>>,
60 /// Named task registry for AWAIT/CANCEL support. Shared across subscopes
61 /// via Arc. Each entry is a synchronized state machine (`TaskEntry`):
62 /// the handle lives inside the entry so `CANCEL` can synchronously tear
63 /// down a task even while a concurrent `AWAIT` is waiting on it.
64 /// Entries are retained as `Cancelled`/`Completed` tombstones so later
65 /// `AWAIT`/`CANCEL` report precise errors instead of `TaskNotFound`.
66 #[allow(dead_code)]
67 pub(super) named_tasks: Arc<Mutex<HashMap<u64, Arc<TaskEntry>>>>,
68 /// Counter for generating unique task IDs. Shared across subscopes via Arc.
69 #[allow(dead_code)]
70 pub(super) next_task_id: Arc<AtomicU64>,
71 /// Whether we're inside an ASYNC block thread. When true, `handlers::run()`
72 /// spawns in background mode so the handle can be registered for cancellation.
73 pub(super) inside_async: bool,
74 /// Step-indexed keeper expiry for one `ASYNC` worker. Each guard pins a
75 /// pipe the worker produces to, bridging the spawn-to-first-attach
76 /// window and every transient gap between producer steps. Guards keyed
77 /// to step `k` drop when the worker completes its top-level step `k`
78 /// (matched by slice identity, so nested bodies never discharge them);
79 /// leftovers drop with the worker thread. Always `None` outside
80 /// workers; `fork` never inherits it.
81 pub(super) keeper_expiry: Option<KeeperExpiry>,
82 /// Whether `handlers::run()` must spawn in background mode so the handle
83 /// registers in `active_process` for cancellation. Set while a `TIMEOUT`
84 /// body executes on the current thread so the deadline watcher can kill
85 /// a blocking foreground process. Unlike `inside_async`, this does not
86 /// affect end-of-pipeline named-task reaping.
87 pub(super) cancellable: bool,
88 /// The single function registry: DSL `FUNC` definitions, builtins, and
89 /// host extensions. Script entries scope lexically through the
90 /// registry's own frames (managed by `push_scope`/`pop_scope`);
91 /// native entries persist. Shared across `fork()` via clone.
92 pub(super) functions: FunctionRegistry<P>,
93 /// Current nested function-call depth on this thread. Enforced against
94 /// `MAX_CALL_DEPTH`; cloned (not reset) by `fork()` so async children
95 /// inherit the caller's depth budget.
96 pub(super) call_depth: usize,
97 pub(super) _marker: PhantomData<P>,
98}
99
100pub(super) struct ScopeSnapshot {
101 pub(super) cwd: GuardedPath,
102 pub(super) root: GuardedPath,
103 pub(super) envs: Arc<HashMap<String, String>>,
104}
105
106/// Lifecycle phase of a named background task (`LET $var: HANDLE = ASYNC ...`).
107/// `Running` and `Awaiting` both hold the live handle inside the entry;
108/// `Cancelled` and `Completed` are terminal tombstones with no handle.
109pub(super) enum TaskPhase {
110 Running,
111 Awaiting,
112 Cancelled,
113 Completed,
114}
115
116pub(super) struct TaskEntryState {
117 pub(super) phase: TaskPhase,
118 pub(super) handle: Option<Box<dyn BackgroundHandle>>,
119 /// True once the handle has been consumed and its thread joined
120 /// (`kill()` for cancellations, `try_wait`-reap for natural completion).
121 /// Threads observing `Cancelled` must wait on `done` until `reaped`
122 /// before resuming, so no caller outruns OS process teardown.
123 pub(super) reaped: bool,
124 /// Per-task stdout sink (`LET $t: HANDLE = ASYNC ...`). The child thread writes
125 /// here instead of the parent writer. Exactly one consumer takes it:
126 /// `LET $o: STRING = AWAIT $t` binds it, bare `AWAIT $t` forwards it to the
127 /// parent stdout, and end-poll reaping forwards un-awaited output.
128 pub(super) sink: Option<Arc<SpillBuffer>>,
129 /// Return value of a background `CALL` task (`LET $t: HANDLE = ASYNC CALL
130 /// FOO(...)`). Set under the entry lock before `done.notify_all()`; read
131 /// by `LET $o: TYPE = AWAIT $t` when the task body was a single `Call`.
132 /// `None` for block tasks and for tasks that have not finished.
133 pub(super) return_value: Option<Value>,
134}
135
136/// Synchronized named-task entry shared by every scope that can observe the
137/// task (`AWAIT`, `CANCEL`, end-poll reaping). Exactly one thread ever takes
138/// the handle and performs teardown; all other observers rendezvous on
139/// `done`/`reaped`.
140pub(super) struct TaskEntry {
141 pub(super) state: Mutex<TaskEntryState>,
142 pub(super) done: Condvar,
143}
144
145impl TaskEntry {
146 pub(super) fn new_with_sink(handle: Box<dyn BackgroundHandle>, sink: Arc<SpillBuffer>) -> Self {
147 Self {
148 state: Mutex::new(TaskEntryState {
149 phase: TaskPhase::Running,
150 handle: Some(handle),
151 reaped: false,
152 sink: Some(sink),
153 return_value: None,
154 }),
155 done: Condvar::new(),
156 }
157 }
158
159 /// Take the task's stdout sink exactly once. The first consumer
160 /// (awaiter or end-poll reaper) wins; later calls get `None`.
161 pub(super) fn take_sink(&self) -> Option<Arc<SpillBuffer>> {
162 self.state
163 .lock()
164 .unwrap_or_else(|e| e.into_inner())
165 .sink
166 .take()
167 }
168
169 /// Block until the teardown owner has consumed the handle and joined
170 /// the task thread. Lock-free for callers except the wait itself.
171 pub(super) fn wait_reaped(&self) {
172 let mut guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
173 while !guard.reaped {
174 guard = self.done.wait(guard).unwrap_or_else(|e| e.into_inner());
175 }
176 }
177
178 /// Mark teardown complete and wake every rendezvous waiter.
179 pub(super) fn finish_teardown(&self) {
180 {
181 let mut guard = self.state.lock().unwrap_or_else(|e| e.into_inner());
182 guard.handle = None;
183 guard.reaped = true;
184 }
185 self.done.notify_all();
186 }
187}
188
189/// Step-indexed keeper expiry for one `ASYNC` worker thread. Guards are
190/// keyed by the top-level body index of the final producer step for each
191/// pipe: dropping the guards for step `k` once it completes keeps the
192/// pipe open across every transient gap between producers, then releases
193/// it so later consumer steps in the same task observe EOF.
194///
195/// Slice identity (`body_addr`/`body_len`) gates discharge: nested bodies
196/// (`FOR`/`IF`/`TIMEOUT`/inner `ASYNC`) execute through the same stepping
197/// code with different slices and must never consume the worker's
198/// top-level map.
199pub(super) struct KeeperExpiry {
200 body_addr: usize,
201 body_len: usize,
202 map: HashMap<usize, Vec<KeeperGuard>>,
203}
204
205impl KeeperExpiry {
206 pub(super) fn new(steps: &[Step], map: HashMap<usize, Vec<KeeperGuard>>) -> Self {
207 Self {
208 body_addr: steps.as_ptr() as usize,
209 body_len: steps.len(),
210 map,
211 }
212 }
213
214 pub(super) fn matches(&self, steps: &[Step]) -> bool {
215 self.body_addr == steps.as_ptr() as usize && self.body_len == steps.len()
216 }
217
218 /// Drop the guards expiring at top-level step `idx`. Returns true when
219 /// the map is drained and the expiry itself can be cleared.
220 pub(super) fn expire_step(&mut self, steps: &[Step], idx: usize) -> bool {
221 if self.matches(steps) {
222 drop(self.map.remove(&idx));
223 }
224 self.map.is_empty()
225 }
226}
227
228impl<P: ProcessManager> ExecState<P> {
229 pub(super) fn command_ctx(&self) -> Result<CommandContext> {
230 // Resolve the working directory through the snapshot choke point: a
231 // snapshot-rooted cwd materializes here (so `RUN` executes against a
232 // real directory) while a local cwd resolves purely lexically with
233 // zero I/O. `CARGO_TARGET_DIR` is the pre-reserved scratch name (never
234 // ensured by us); callers may still override it via the env map, which
235 // apply_ctx respects when spawning processes.
236 let cwd = self.fs.resolve_write(&self.cwd, ".")?;
237 Ok(CommandContext::new(
238 &cwd.into(),
239 Arc::clone(&self.envs),
240 &self.cargo_scratch,
241 self.fs.root(),
242 self.fs.build_context(),
243 ))
244 }
245
246 /// Fork the execution state for a child thread. The child gets:
247 /// - A cloned filesystem handle (shared snapshot backing, independent root selection)
248 /// - Cloned envs, cwd, cargo scratch name, var_scopes
249 /// - Fresh bg_children, scope_stack (empty -- child manages its own)
250 /// - Shared assert_windows, assert_windows_stderr, exact_stdout (Arc clones)
251 /// - Cloned io configuration
252 /// - Independent cancel_token, active_process (child manages its own)
253 /// - Shared named_tasks and next_task_id (via Arc clone)
254 #[allow(dead_code)]
255 pub(super) fn fork(&self) -> Self {
256 Self {
257 fs: self.fs.clone_box(),
258 cargo_scratch: self.cargo_scratch.clone(),
259 cwd: self.cwd.clone(),
260 envs: Arc::clone(&self.envs),
261 bg_children: Vec::new(),
262 scope_stack: Vec::new(),
263 io: self.io.clone(),
264 assert_windows: Arc::clone(&self.assert_windows),
265 assert_windows_stderr: Arc::clone(&self.assert_windows_stderr),
266 exact_stdout: Arc::clone(&self.exact_stdout),
267 var_scopes: self.var_scopes.clone(),
268 cancel_token: Arc::new(AtomicBool::new(false)),
269 active_process: Arc::new(Mutex::new(None)),
270 named_tasks: Arc::clone(&self.named_tasks),
271 next_task_id: Arc::clone(&self.next_task_id),
272 inside_async: true,
273 keeper_expiry: None,
274 cancellable: self.cancellable,
275 functions: self.functions.clone(),
276 types: self.types.clone(),
277 call_depth: self.call_depth,
278 _marker: PhantomData,
279 }
280 }
281
282 pub(super) fn push_var_scope(&mut self) {
283 self.var_scopes.push(HashMap::new());
284 }
285
286 pub(super) fn pop_var_scope(&mut self) {
287 self.var_scopes.pop();
288 }
289
290 /// Enter a lexical scope: snapshot cwd/root/envs and open a fresh
291 /// variable scope. Blocks scope everything (LET/ENV/WORKDIR/WORKSPACE);
292 /// only pipes (ExecIo) and filesystem effects cross scope boundaries.
293 /// Function definitions scope through the registry's own frames.
294 pub(super) fn push_scope(&mut self) {
295 self.scope_stack.push(ScopeSnapshot {
296 cwd: self.cwd.clone(),
297 root: self.fs.root().clone(),
298 envs: Arc::clone(&self.envs),
299 });
300 self.functions.push_scope();
301 self.push_var_scope();
302 }
303
304 /// Exit a lexical scope, restoring everything `push_scope` saved.
305 pub(super) fn pop_scope(&mut self) -> Result<()> {
306 let snapshot = self
307 .scope_stack
308 .pop()
309 .ok_or_else(|| anyhow::anyhow!("scope stack underflow during pop"))?;
310 self.fs.set_root(&snapshot.root);
311 self.cwd = snapshot.cwd;
312 self.envs = snapshot.envs;
313 self.functions.pop_scope();
314 self.pop_var_scope();
315 Ok(())
316 }
317 pub(super) fn declare_var(&mut self, key: String, kind: String, value: Value) -> Result<()> {
318 if self
319 .var_scopes
320 .last()
321 .map(|s| s.contains_key(&key))
322 .unwrap_or(false)
323 {
324 anyhow::bail!(
325 "redeclaration error: ${} already declared in this scope; use ${} = ... to mutate",
326 key,
327 key
328 );
329 }
330 let coerced = super::args::coerce_value(value, &kind, &*self)?;
331 let scope = self
332 .var_scopes
333 .last_mut()
334 .ok_or_else(|| anyhow::anyhow!("no variable scope for declaration"))?;
335 scope.insert(key, (kind, coerced));
336 Ok(())
337 }
338
339 pub(super) fn mutate_var(&mut self, key: &str, value: Value) -> Result<()> {
340 let kind = self
341 .var_scopes
342 .iter()
343 .rev()
344 .find_map(|s| s.get(key).map(|(k, _)| k.clone()))
345 .ok_or_else(|| {
346 anyhow::anyhow!(
347 "undeclared variable ${key}: declare it first with LET ${key}: TYPE = ..."
348 )
349 })?;
350 let coerced = super::args::coerce_value(value, &kind, &*self)?;
351 for scope in self.var_scopes.iter_mut().rev() {
352 if let Some(slot) = scope.get_mut(key) {
353 slot.1 = coerced;
354 return Ok(());
355 }
356 }
357 anyhow::bail!("undeclared variable ${key}");
358 }
359
360 pub(super) fn get_var(&self, key: &str) -> Option<Value> {
361 // Walk scopes from innermost to outermost
362 for scope in self.var_scopes.iter().rev() {
363 if let Some((_, value)) = scope.get(key) {
364 return Some(value.clone());
365 }
366 }
367 None
368 }
369
370 pub(super) fn get_var_typed(&self, key: &str) -> Option<(String, Value)> {
371 for scope in self.var_scopes.iter().rev() {
372 if let Some(entry) = scope.get(key) {
373 return Some(entry.clone());
374 }
375 }
376 None
377 }
378
379 /// Get a flattened view of all variables across all scopes.
380 /// Inner scopes take precedence over outer scopes.
381 pub(super) fn all_vars(&self) -> HashMap<String, Value> {
382 let mut result = HashMap::new();
383 for scope in self.var_scopes.iter().rev() {
384 for (k, (_, v)) in scope {
385 result.entry(k.clone()).or_insert_with(|| v.clone());
386 }
387 }
388 result
389 }
390}