Skip to main content

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}