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::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}