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