Skip to main content

oxdock_core/exec/
io.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::io::{self, Write};
3use std::sync::{Arc, Mutex};
4
5use anyhow::Result;
6#[cfg(not(miri))]
7use anyhow::bail;
8#[cfg(not(miri))]
9use oxdock_pipe::OsPipeWriter;
10use oxdock_pipe::{
11    KeeperGuard, Materialized, PipeHandle, PipeInfo, PipeInner, SharedInput, SharedOutput,
12    inspect as inspect_handle, materialize, peek as peek_handle, script_backend,
13};
14use oxdock_process::{CommandStderr, CommandStdin, CommandStdout};
15
16/// Handle-scoped pipe resolution. No central index exists: every method
17/// below takes the `PIPE` value's backend cell, materializing it on first
18/// binding (first-binding-wins under the cell lock) and adapting later
19/// bindings through the existing machinery. The `ExecIo` receiver carries
20/// no pipe state; methods live here (rather than as free functions) so
21/// call sites keep their `cx.state.io.*` shape.
22#[derive(Clone, Default)]
23pub(super) struct PipeRegistry;
24
25/// Loud take-twice error for OS handles: a second consumer or producer on
26/// one end can never steal the descriptor, and (unlike the old recycling
27/// behavior) never silently receives a fresh pair either — the remedy is a
28/// fresh declaration. No rebind rule exists by design: no rule can tell
29/// sequential loop reuse apart from concurrent fan-in sharing.
30#[cfg(not(miri))]
31fn spent_handle(idx: usize) -> anyhow::Error {
32    anyhow::anyhow!(
33        "step {}: OS pipe handle has already been consumed by another binding; declare a fresh LET $x: PIPE for a new session",
34        idx + 1,
35    )
36}
37
38impl PipeRegistry {
39    /// First-binding-wins materialization for one binding site: an unbound
40    /// handle decides its kind from `promote` (the caller supplies full
41    /// usage context); a decided handle is returned unchanged. Callers
42    /// adapt mismatches through resolution below, never here.
43    pub(super) fn ensure_handle(handle: &PipeHandle, promote: bool) -> Result<()> {
44        materialize(handle, promote)?;
45        Ok(())
46    }
47
48    /// Non-destructive snapshot of a script handle's buffered bytes for
49    /// pipe-content assertions. Delegates to the backend without creating:
50    /// unbound handles and OS pairs bail loudly instead of yielding empty
51    /// content.
52    ///
53    /// Public so out-of-crate harnesses can assert on script-owned pipes
54    /// after a run completes, via the `PIPE` values in the returned
55    /// bindings. Only meaningful once writers detached (post-run).
56    pub fn peek_pipe_content(handle: &PipeHandle) -> Result<Vec<u8>> {
57        peek_handle(handle)
58    }
59
60    /// Snapshot one handle for `INSPECT()` diagnostics.
61    pub(super) fn inspect_pipe(handle: &PipeHandle) -> PipeInfo {
62        inspect_handle(handle)
63    }
64
65    /// Pin a keeper slot on a script backend so transient writer churn can
66    /// never observe zero writers. Returns `None` for OS-materialized and
67    /// unbound handles, which need no pin. Callers ensure the handle
68    /// first so OS promotion is honored and this never forces a script
69    /// backend into existence.
70    pub(super) fn pin_keeper(handle: &PipeHandle) -> Result<Option<KeeperGuard>> {
71        Ok(script_backend(handle).map(KeeperGuard::new))
72    }
73
74    /// Resolve a stdin binding against a decided handle. Script backends
75    /// hand out a shared reader (plus the backend for timeout-bounded
76    /// bridge reads); OS pairs hand the take-once reader to `RUN` directly
77    /// and bridge it to a shared handle for DSL commands. A second take
78    /// on one end bails loudly with the fresh-declaration remedy instead
79    /// of stealing the descriptor.
80    pub(super) fn resolve_stdin(
81        idx: usize,
82        handle: &PipeHandle,
83        direct: bool,
84        promote: bool,
85    ) -> Result<(CommandStdin, Option<Arc<PipeInner>>)> {
86        // `idx` and `direct` serve only the OS-pipe arms below, which are
87        // compiled out under Miri.
88        let _ = idx;
89        let _ = direct;
90        match materialize(handle, promote)? {
91            Materialized::Script(backend) => {
92                Ok((CommandStdin::Stream(backend.reader_handle()), Some(backend)))
93            }
94            #[cfg(not(miri))]
95            Materialized::Os(entry) => {
96                if direct {
97                    return Ok((CommandStdin::OsPipe(entry.reader.clone()), None));
98                }
99                let owned = entry.reader.take().map_err(|_| spent_handle(idx))?;
100                Ok((
101                    CommandStdin::Stream(Arc::new(std::sync::Mutex::new(owned))),
102                    None,
103                ))
104            }
105        }
106    }
107
108    /// Resolve a stdout binding. Mirrors
109    /// [`PipeRegistry::resolve_stdin`] with `StreamHandle` outputs so
110    /// `RUN` keeps zero copy `Stdio` handoff, plus the script backend for
111    /// the bridge's socket-EOF force-close.
112    pub(super) fn resolve_stdout(
113        idx: usize,
114        handle: &PipeHandle,
115        direct: bool,
116        promote: bool,
117    ) -> Result<(StreamHandle, Option<Arc<PipeInner>>)> {
118        // `idx` and `direct` serve only the OS-pipe arms below, which are
119        // compiled out under Miri.
120        let _ = idx;
121        let _ = direct;
122        match materialize(handle, promote)? {
123            Materialized::Script(backend) => {
124                Ok((StreamHandle::Stream(backend.writer_handle()), Some(backend)))
125            }
126            #[cfg(not(miri))]
127            Materialized::Os(entry) => {
128                if direct {
129                    return Ok((StreamHandle::Os(entry.writer.clone()), None));
130                }
131                let owned = entry.writer.take().map_err(|_| spent_handle(idx))?;
132                Ok((
133                    StreamHandle::Stream(Arc::new(std::sync::Mutex::new(owned))),
134                    None,
135                ))
136            }
137        }
138    }
139
140    /// Resolve a stderr binding. Mirrors
141    /// [`PipeRegistry::resolve_stdout`]: `RUN` keeps zero copy handoff,
142    /// DSL commands get a bridged shared handle. Binding `stdout` and
143    /// `stderr` to one live OS handle takes the same slot twice, so the
144    /// second take bails deterministically; merge in shell via `2>&1`
145    /// instead.
146    pub(super) fn resolve_stderr(
147        idx: usize,
148        handle: &PipeHandle,
149        direct: bool,
150        promote: bool,
151    ) -> Result<StreamHandle> {
152        // `idx` and `direct` serve only the OS-pipe arms below, which are
153        // compiled out under Miri.
154        let _ = idx;
155        let _ = direct;
156        match materialize(handle, promote)? {
157            Materialized::Script(backend) => Ok(StreamHandle::Stream(backend.writer_handle())),
158            #[cfg(not(miri))]
159            Materialized::Os(entry) => {
160                if direct {
161                    return Ok(StreamHandle::Os(entry.writer.clone()));
162                }
163                let owned = entry.writer.take().map_err(|_| spent_handle(idx))?;
164                Ok(StreamHandle::Stream(Arc::new(std::sync::Mutex::new(owned))))
165            }
166        }
167    }
168}
169
170#[derive(Clone, Default)]
171pub struct ExecIo {
172    stdin: Option<SharedInput>,
173    stdout: Option<SharedOutput>,
174    stderr: Option<SharedOutput>,
175    inherit_env_overrides: HashMap<String, String>,
176    inherit_env_removed: HashSet<String>,
177}
178
179/// Standard chunk size for all I/O handlers.
180pub const CHUNK_SIZE: usize = 8192;
181
182/// Minimum ring buffer capacity. Actual capacity scales with needle length.
183const MIN_RING_CAPACITY: usize = 1024;
184
185/// Sliding window for streaming pattern matching in stream assertions.
186/// Maintains a ring buffer and detects matches inline as chunks pass through.
187pub(crate) struct SlidingWindow {
188    pub(crate) needle: Vec<u8>,
189    ring: VecDeque<u8>,
190    pub matched: bool,
191}
192
193impl SlidingWindow {
194    pub fn new(needle: Vec<u8>) -> Self {
195        Self {
196            ring: VecDeque::with_capacity(needle.len().max(MIN_RING_CAPACITY)),
197            needle,
198            matched: false,
199        }
200    }
201
202    pub fn push_chunk(&mut self, chunk: &[u8]) {
203        if self.matched {
204            return;
205        }
206        // Eviction limit scales with needle length, never below MIN_RING_CAPACITY
207        let limit = self.needle.len().max(MIN_RING_CAPACITY);
208        for &byte in chunk {
209            self.ring.push_back(byte);
210            if self.ring.len() > limit {
211                self.ring.pop_front();
212            }
213            self.check_match();
214        }
215    }
216
217    /// Replace needle without discarding ring history.
218    /// Re-evaluates current ring against updated needle.
219    pub fn update_needle(&mut self, new_needle: Vec<u8>) {
220        if self.matched {
221            return;
222        }
223        self.needle = new_needle;
224        self.check_match();
225    }
226
227    fn check_match(&mut self) {
228        if self.matched || self.ring.len() < self.needle.len() {
229            return;
230        }
231        let start = self.ring.len() - self.needle.len();
232        if self
233            .ring
234            .iter()
235            .skip(start)
236            .zip(self.needle.iter())
237            .all(|(a, b)| a == b)
238        {
239            self.matched = true;
240        }
241    }
242
243    /// Return the ring buffer contents for debugging.
244    pub fn ring_buffer(&self) -> Vec<u8> {
245        self.ring.iter().copied().collect()
246    }
247}
248
249#[derive(Clone)]
250pub(super) enum StreamHandle {
251    Stream(SharedOutput),
252    /// Live OS kernel pipe writer handed to one concurrent producer.
253    /// Only `RUN` consumes this directly; DSL commands never observe it
254    /// because `with_io` bridges OS entries to shared handles for them.
255    #[cfg(not(miri))]
256    Os(OsPipeWriter),
257}
258
259impl StreamHandle {
260    pub(super) fn to_stdout(&self) -> CommandStdout {
261        match self {
262            StreamHandle::Stream(writer) => CommandStdout::Stream(writer.clone()),
263            #[cfg(not(miri))]
264            StreamHandle::Os(writer) => CommandStdout::OsPipe(writer.clone()),
265        }
266    }
267
268    pub(super) fn to_stderr(&self) -> CommandStderr {
269        match self {
270            StreamHandle::Stream(writer) => CommandStderr::Stream(writer.clone()),
271            #[cfg(not(miri))]
272            StreamHandle::Os(writer) => CommandStderr::OsPipe(writer.clone()),
273        }
274    }
275}
276
277pub(super) fn write_stdout<F>(handle: Option<StreamHandle>, op: F) -> Result<()>
278where
279    F: FnOnce(&mut dyn Write) -> Result<()>,
280{
281    match handle {
282        Some(StreamHandle::Stream(writer)) => {
283            if let Ok(mut guard) = writer.lock() {
284                op(&mut *guard)?;
285            }
286            Ok(())
287        }
288        // DSL commands never observe a live OS handle: `with_io` bridges
289        // OS entries to shared handles for them, and promotion only fires
290        // for single RUN bodies. This arm is defensive only.
291        #[cfg(not(miri))]
292        Some(StreamHandle::Os(_)) => {
293            bail!("cannot write DSL output to a live OS pipe")
294        }
295        // `None` inherits host stdout: only explicit `Stream` bindings
296        // reroute DSL output.
297        None => {
298            let mut stdout = io::stdout();
299            op(&mut stdout)
300        }
301    }
302}
303
304/// Slice-based byte adapter over pipe halves for host (`#[oxdock_func]`)
305///
306/// stateful functions. All byte movement goes through the standard traits
307/// on caller-owned buffers — `Read::read(&mut [u8])` and
308/// `Write::write(&[u8])` — so hosts can hand pipes directly to `serde_json`,
309/// `flate2`, `tar`, and friends with a single reused stack buffer and zero
310/// per-chunk allocation. `0` read means EOF exactly like `std::io`; never
311/// slurp a stream into one `Vec` (unbounded memory growth — stream it).
312/// `flush()` delegates to backend flush semantics, which for script pipes
313/// is a no-op that loses nothing: every `write()` wakes readers itself.
314///
315/// A host read blocks the calling task thread exactly like a DSL reader;
316/// EOF and half-close map identically to DSL consumers. Take-once applies
317/// like everywhere else: bridging an OS backend takes once, repeats bail.
318pub struct PipeStream {
319    reader: Option<SharedInput>,
320    writer: Option<SharedOutput>,
321}
322
323impl PipeStream {
324    /// Read-half adapter (e.g. over [`StepCtx::pipe_reader`](super::steps::StepCtx::pipe_reader)).
325    pub fn reader(reader: SharedInput) -> Self {
326        Self {
327            reader: Some(reader),
328            writer: None,
329        }
330    }
331
332    /// Write-half adapter (e.g. over [`StepCtx::pipe_writer`](super::steps::StepCtx::pipe_writer)).
333    pub fn writer(writer: SharedOutput) -> Self {
334        Self {
335            reader: None,
336            writer: Some(writer),
337        }
338    }
339
340    /// Both halves (e.g. a filter with separate in/out pipes).
341    pub fn pair(reader: SharedInput, writer: SharedOutput) -> Self {
342        Self {
343            reader: Some(reader),
344            writer: Some(writer),
345        }
346    }
347}
348
349impl std::io::Read for PipeStream {
350    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
351        let Some(reader) = &self.reader else {
352            return Err(std::io::Error::new(
353                std::io::ErrorKind::NotConnected,
354                "pipe stream has no reader half",
355            ));
356        };
357        let mut guard = reader
358            .lock()
359            .map_err(|_| std::io::Error::other("pipe reader lock poisoned"))?;
360        guard.read(buf)
361    }
362}
363
364impl std::io::Write for PipeStream {
365    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
366        let Some(writer) = &self.writer else {
367            return Err(std::io::Error::new(
368                std::io::ErrorKind::NotConnected,
369                "pipe stream has no writer half",
370            ));
371        };
372        let mut guard = writer
373            .lock()
374            .map_err(|_| std::io::Error::other("pipe writer lock poisoned"))?;
375        guard.write(buf)
376    }
377
378    fn flush(&mut self) -> std::io::Result<()> {
379        let Some(writer) = &self.writer else {
380            return Err(std::io::Error::new(
381                std::io::ErrorKind::NotConnected,
382                "pipe stream has no writer half",
383            ));
384        };
385        let mut guard = writer
386            .lock()
387            .map_err(|_| std::io::Error::other("pipe writer lock poisoned"))?;
388        guard.flush()
389    }
390}
391
392/// Hard cap for `ASSERT_EQ stdout` exact accumulators, mirroring the
393/// `SpillBuffer` spill threshold: exact stream matching is a test-time
394/// opt-in, and unbounded trials must use pipe targets or harness captures.
395pub(crate) const EXACT_STDOUT_CAP: usize = 8 * 1024 * 1024;
396
397/// Cumulative stdout record for one execution generation backing
398/// `ASSERT_EQ stdout`. Unlike `SlidingWindow` nothing is ever evicted;
399/// once the cap is exceeded the entry latches `overflowed` and stops
400/// growing, and the asserting step reports it with remediation guidance.
401pub(crate) struct ExactCapture {
402    pub(crate) bytes: Vec<u8>,
403    pub(crate) overflowed: bool,
404}
405
406impl ExactCapture {
407    pub fn new() -> Self {
408        Self {
409            bytes: Vec::new(),
410            overflowed: false,
411        }
412    }
413
414    pub fn push_chunk(&mut self, chunk: &[u8]) {
415        if self.overflowed {
416            return;
417        }
418        if self.bytes.len() + chunk.len() > EXACT_STDOUT_CAP {
419            self.overflowed = true;
420            return;
421        }
422        self.bytes.extend_from_slice(chunk);
423    }
424}
425
426/// Which host stream a tee forwards to when no capture sink is configured.
427#[derive(Clone, Copy)]
428enum TeeStream {
429    Stdout,
430    Stderr,
431}
432
433/// Wraps the configured stdout sink so every byte written to it is also
434/// pushed to all registered SlidingWindow observers for stream assertions.
435/// When no sink is configured (`inner` is `None`) bytes are forwarded to
436/// real stdout so interactive CLI output still reaches the terminal.
437struct TeeWriter {
438    inner: Option<SharedOutput>,
439    stream: TeeStream,
440    windows: Arc<Mutex<HashMap<(usize, usize), SlidingWindow>>>,
441    exact: Arc<Mutex<HashMap<usize, ExactCapture>>>,
442}
443
444impl Write for TeeWriter {
445    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
446        // Forward to downstream (streaming)
447        match &self.inner {
448            Some(inner) => {
449                let mut guard = inner
450                    .lock()
451                    .map_err(|_| io::Error::other("stdout sink poisoned"))?;
452                guard.write_all(buf)?;
453            }
454            None => match self.stream {
455                TeeStream::Stdout => io::stdout().write_all(buf)?,
456                TeeStream::Stderr => io::stderr().write_all(buf)?,
457            },
458        }
459        // Push to ALL registered assertion windows (O(1) per byte per window)
460        if let Ok(mut windows) = self.windows.lock() {
461            for window in windows.values_mut() {
462                window.push_chunk(buf);
463            }
464        }
465        if let Ok(mut exact) = self.exact.lock() {
466            for capture in exact.values_mut() {
467                capture.push_chunk(buf);
468            }
469        }
470        Ok(buf.len())
471    }
472
473    fn flush(&mut self) -> io::Result<()> {
474        match &self.inner {
475            Some(inner) => {
476                let mut guard = inner
477                    .lock()
478                    .map_err(|_| io::Error::other("stdout sink poisoned"))?;
479                guard.flush()?;
480            }
481            None => match self.stream {
482                TeeStream::Stdout => io::stdout().flush()?,
483                TeeStream::Stderr => io::stderr().flush()?,
484            },
485        }
486        Ok(())
487    }
488}
489
490/// Installs the tee around `sink` (or real stdout when absent).
491pub(crate) fn teed_stdout(
492    sink: Option<SharedOutput>,
493    windows: Arc<Mutex<HashMap<(usize, usize), SlidingWindow>>>,
494    exact: Arc<Mutex<HashMap<usize, ExactCapture>>>,
495) -> SharedOutput {
496    Arc::new(Mutex::new(TeeWriter {
497        inner: sink,
498        stream: TeeStream::Stdout,
499        windows,
500        exact,
501    }))
502}
503
504/// Installs the tee around `sink` (or real stderr when absent) for
505/// `ASSERT_CONTAINS stderr` substring observers. Exact matching is not
506/// offered over stderr; use pipe targets or harness captures for that.
507pub(crate) fn teed_stderr(
508    sink: Option<SharedOutput>,
509    windows: Arc<Mutex<HashMap<(usize, usize), SlidingWindow>>>,
510) -> SharedOutput {
511    Arc::new(Mutex::new(TeeWriter {
512        inner: sink,
513        stream: TeeStream::Stderr,
514        windows,
515        exact: Arc::new(Mutex::new(HashMap::new())),
516    }))
517}
518
519impl ExecIo {
520    pub fn new() -> Self {
521        Self::default()
522    }
523
524    pub fn set_stdin(&mut self, stdin: Option<SharedInput>) {
525        self.stdin = stdin;
526    }
527
528    pub fn set_stdout(&mut self, stdout: Option<SharedOutput>) {
529        self.stdout = stdout.clone();
530        if self.stderr.is_none() {
531            self.stderr = stdout;
532        }
533    }
534
535    pub fn set_stderr(&mut self, stderr: Option<SharedOutput>) {
536        self.stderr = stderr;
537    }
538
539    pub fn insert_inherit_env<S: Into<String>, V: Into<String>>(&mut self, key: S, value: V) {
540        let key = key.into();
541        self.inherit_env_removed.remove(&key);
542        self.inherit_env_overrides.insert(key, value.into());
543    }
544
545    pub fn remove_inherit_env<S: Into<String>>(&mut self, key: S) {
546        let key = key.into();
547        self.inherit_env_overrides.remove(&key);
548        self.inherit_env_removed.insert(key);
549    }
550
551    pub fn inherit_env_value(&self, key: &str) -> Option<&String> {
552        self.inherit_env_overrides.get(key)
553    }
554
555    pub fn inherit_env_is_removed(&self, key: &str) -> bool {
556        self.inherit_env_removed.contains(key)
557    }
558
559    pub fn inherit_env_overrides(&self) -> &std::collections::HashMap<String, String> {
560        &self.inherit_env_overrides
561    }
562
563    /// Ensure a handle is materialized for one binding site (see
564    /// [`PipeRegistry::ensure_handle`]). Main-flow bindings call this with
565    /// the per-step trigger; task bodies are pre-decided by the spawn-time
566    /// pin walk, making this a no-op there.
567    pub(super) fn ensure_handle(&self, handle: &PipeHandle, promote: bool) -> Result<()> {
568        PipeRegistry::ensure_handle(handle, promote)
569    }
570
571    /// Snapshot of one handle for `INSPECT()` diagnostics.
572    pub(super) fn inspect_pipe(&self, handle: &PipeHandle) -> PipeInfo {
573        PipeRegistry::inspect_pipe(handle)
574    }
575
576    /// Non-destructive snapshot of a script handle's buffered bytes for
577    /// pipe-content assertions.
578    ///
579    /// Public so out-of-crate harnesses can assert on script-owned pipes
580    /// after a run completes, via the `PIPE` values in the returned
581    /// bindings. Only meaningful once writers detached (post-run);
582    /// unbound handles and OS pairs bail loudly.
583    pub fn peek_pipe_content(&self, handle: &PipeHandle) -> Result<Vec<u8>> {
584        PipeRegistry::peek_pipe_content(handle)
585    }
586
587    /// Pin a keeper slot on a script backend. `None` for OS-materialized
588    /// and unbound handles. Callers ensure the handle first so OS
589    /// promotion is honored and this never forces a backend into
590    /// existence.
591    pub(super) fn pin_keeper(&self, handle: &PipeHandle) -> Result<Option<KeeperGuard>> {
592        PipeRegistry::pin_keeper(handle)
593    }
594
595    /// Resolve a stdin binding to a runnable handle plus the script
596    /// backend (for timeout-bounded bridge reads; `None` for OS pairs).
597    pub(super) fn resolve_stdin(
598        &self,
599        idx: usize,
600        handle: &PipeHandle,
601        direct: bool,
602        promote: bool,
603    ) -> Result<(CommandStdin, Option<Arc<PipeInner>>)> {
604        PipeRegistry::resolve_stdin(idx, handle, direct, promote)
605    }
606
607    /// Resolve a stdout binding to a runnable handle plus the script
608    /// backend (for the bridge's socket-EOF force-close).
609    pub(super) fn resolve_stdout(
610        &self,
611        idx: usize,
612        handle: &PipeHandle,
613        direct: bool,
614        promote: bool,
615    ) -> Result<(StreamHandle, Option<Arc<PipeInner>>)> {
616        PipeRegistry::resolve_stdout(idx, handle, direct, promote)
617    }
618
619    /// Resolve a stderr binding to a runnable handle.
620    pub(super) fn resolve_stderr(
621        &self,
622        idx: usize,
623        handle: &PipeHandle,
624        direct: bool,
625        promote: bool,
626    ) -> Result<StreamHandle> {
627        PipeRegistry::resolve_stderr(idx, handle, direct, promote)
628    }
629
630    pub fn stdin(&self) -> Option<SharedInput> {
631        self.stdin.clone()
632    }
633
634    pub fn stdout(&self) -> Option<SharedOutput> {
635        self.stdout.clone()
636    }
637
638    pub fn stderr(&self) -> Option<SharedOutput> {
639        self.stderr.clone().or_else(|| self.stdout.clone())
640    }
641}
642
643pub(super) fn assemble_default_io(
644    stdin: Option<SharedInput>,
645    stdout: Option<SharedOutput>,
646) -> ExecIo {
647    let mut io = ExecIo::new();
648    io.set_stdin(stdin);
649    io.set_stdout(stdout.clone());
650    io.set_stderr(stdout);
651    io
652}