Skip to main content

oxdock_pipe/
backend.rs

1//! Script-pipe backend: an in-memory (spillable) byte channel with
2//! writer/keeper accounting, blocking reads, and explicit close.
3//!
4//! A [`ScriptPipe`] bundles one [`PipeInner`] with its shared reader; writer
5//! halves attach per binding through [`ScriptPipeEndpoint`]. [`KeeperGuard`]
6//! pins transient gaps for background tasks. Moved verbatim from
7//! `oxdock-core` so pipe handles (`crate::Slot`) can own backends without a
8//! dependency cycle; behavior is unchanged.
9
10use std::io::{self, Read, Write};
11use std::sync::{Arc, Condvar, Mutex};
12use std::time::Duration;
13
14use crate::slot::{SharedInput, SharedOutput};
15use crate::spill::SpillBuffer;
16
17/// One script pipe: shared backend plus its shared reader half.
18pub struct ScriptPipe {
19    inner: Arc<PipeInner>,
20    reader: SharedInput,
21}
22
23impl ScriptPipe {
24    /// Production pipe: default spill threshold and backlog cap.
25    pub fn new() -> Self {
26        let inner = Arc::new(PipeInner::new());
27        let reader: SharedInput = Arc::new(Mutex::new(PipeReader::new(inner.clone())));
28        Self { inner, reader }
29    }
30    /// Pipe with explicit thresholds. Tests use small values to exercise
31    /// the spill path without multi-megabyte payloads.
32    pub fn with_thresholds(spill_threshold: usize, max_backlog: u64) -> Self {
33        let inner = Arc::new(PipeInner::with_thresholds(spill_threshold, max_backlog));
34        let reader: SharedInput = Arc::new(Mutex::new(PipeReader::new(inner.clone())));
35        Self { inner, reader }
36    }
37
38    /// Shared reader half for `stdin` bindings.
39    pub fn reader(&self) -> SharedInput {
40        self.reader.clone()
41    }
42
43    /// Fresh writer-side endpoint for `stdout`/`stderr` bindings. Each
44    /// endpoint mints one attached writer when resolved to a stream.
45    pub fn endpoint(&self) -> ScriptPipeEndpoint {
46        ScriptPipeEndpoint::new(self.inner.clone())
47    }
48
49    /// Backend for keepers, peeks, and diagnostics.
50    pub fn pipe_inner(&self) -> Arc<PipeInner> {
51        self.inner.clone()
52    }
53
54    /// Spill-file path when spilled, else `None`. Diagnostics for tests.
55    #[allow(clippy::disallowed_types)]
56    pub fn temp_path(&self) -> Option<std::path::PathBuf> {
57        self.inner.temp_path()
58    }
59}
60
61impl Default for ScriptPipe {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67/// Writer-side endpoint of a [`ScriptPipe`]. Resolving it to a stream
68/// attaches one writer; dropping the stream detaches.
69#[derive(Clone)]
70pub struct ScriptPipeEndpoint {
71    inner: Arc<PipeInner>,
72}
73
74impl ScriptPipeEndpoint {
75    fn new(inner: Arc<PipeInner>) -> Self {
76        Self { inner }
77    }
78
79    /// Fresh writer-side endpoint over an existing backend, for resolution
80    /// paths that hold the backend directly instead of a `ScriptPipe`.
81    pub fn for_backend(inner: &Arc<PipeInner>) -> Self {
82        Self::new(Arc::clone(inner))
83    }
84
85    /// One attached writer half for subprocess stdio or DSL output.
86    pub fn stream_handle(&self) -> SharedOutput {
87        Arc::new(Mutex::new(PipeWriter::new(self.inner.clone())))
88    }
89}
90
91/// Shared script-pipe backend: buffer plus writer/keeper accounting.
92/// Clone the `Arc`, never the state: every handle aliases one channel.
93pub struct PipeInner {
94    state: Mutex<PipeState>,
95    ready: Condvar,
96}
97
98struct PipeState {
99    buffer: SpillBuffer,
100    writers: usize,
101    keepers: usize,
102    closed: bool,
103}
104
105impl PipeState {
106    fn new(spill_threshold: usize, max_backlog: u64) -> Self {
107        Self {
108            buffer: SpillBuffer::with_thresholds(spill_threshold, max_backlog),
109            writers: 0,
110            keepers: 0,
111            closed: false,
112        }
113    }
114}
115
116impl PipeInner {
117    fn new() -> Self {
118        Self {
119            state: Mutex::new(PipeState::new(
120                crate::spill::DEFAULT_SPILL_THRESHOLD,
121                crate::spill::DEFAULT_MAX_BACKLOG,
122            )),
123            ready: Condvar::new(),
124        }
125    }
126
127    /// One shared reader half over this backend. Each call mints a fresh
128    /// `PipeReader` over the same buffer, so readers share bytes (not
129    /// positions) exactly like the registry-era shared input.
130    pub fn reader_handle(self: &Arc<Self>) -> crate::slot::SharedInput {
131        Arc::new(Mutex::new(PipeReader::new(Arc::clone(self))))
132    }
133
134    /// One attached writer half over this backend. Dropping the handle
135    /// detaches, preserving EOF-from-detach semantics.
136    pub fn writer_handle(self: &Arc<Self>) -> crate::slot::SharedOutput {
137        Arc::new(Mutex::new(PipeWriter::new(Arc::clone(self))))
138    }
139
140    fn with_thresholds(spill_threshold: usize, max_backlog: u64) -> Self {
141        Self {
142            state: Mutex::new(PipeState::new(spill_threshold, max_backlog)),
143            ready: Condvar::new(),
144        }
145    }
146
147    #[allow(clippy::disallowed_types)]
148    fn temp_path(&self) -> Option<std::path::PathBuf> {
149        self.lock_state().buffer.temp_path()
150    }
151
152    fn attach_writer(&self) {
153        let mut state = self.lock_state();
154        state.writers += 1;
155        state.closed = false;
156    }
157
158    /// Live data-writer attachments (excludes keeper pins). Used for
159    /// `INSPECT()` diagnostics; never blocks.
160    pub fn writer_count(&self) -> usize {
161        self.lock_state().writers
162    }
163
164    /// Bytes currently buffered for readers. Used for diagnostics.
165    pub fn buffered_bytes(&self) -> u64 {
166        self.lock_state().buffer.buffered_bytes()
167    }
168
169    /// Non-destructive snapshot of buffered bytes for pipe-content
170    /// assertions. Never waits: returns what is buffered right now.
171    pub fn peek_bytes(&self) -> io::Result<Vec<u8>> {
172        self.lock_state().buffer.peek_bytes()
173    }
174
175    fn detach_writer(&self) {
176        let mut state = self.lock_state();
177        state.writers = state.writers.saturating_sub(1);
178        if state.writers == 0 && state.keepers == 0 {
179            state.closed = true;
180        }
181        drop(state);
182        self.ready.notify_all();
183    }
184
185    /// Explicitly close the pipe: readers drain buffered bytes, then observe
186    /// EOF regardless of live writers or keeper pins. General primitive
187    /// (sockets have `shutdown`, files have `close`); pipes previously had
188    /// detach-only EOF. A later writer attachment resurrects the pipe per
189    /// standard attach semantics, so callers must not reuse closed pipes
190    /// for new sessions.
191    pub fn force_close(&self) {
192        let mut state = self.lock_state();
193        state.closed = true;
194        drop(state);
195        self.ready.notify_all();
196    }
197
198    /// Pin a keeper slot so transient writer churn can never observe zero
199    /// writers. Called synchronously on the spawning thread before an
200    /// `ASYNC` worker starts; the returned guard unpins on drop when the
201    /// worker exits, restoring normal EOF semantics afterwards.
202    /// Never touches `closed`: pinning a pipe that already reached EOF
203    /// must not resurrect it into a blocking pipe.
204    pub fn pin_keeper(&self) {
205        let mut state = self.lock_state();
206        state.keepers += 1;
207    }
208
209    /// Release one keeper slot. When the last transient writer and the
210    /// last keeper are both gone the pipe closes and blocked readers see
211    /// EOF.
212    pub fn unpin_keeper(&self) {
213        let mut state = self.lock_state();
214        state.keepers = state.keepers.saturating_sub(1);
215        if state.writers == 0 && state.keepers == 0 {
216            state.closed = true;
217        }
218        drop(state);
219        self.ready.notify_all();
220    }
221
222    fn push_bytes(&self, data: &[u8]) -> io::Result<()> {
223        let state = self.lock_state();
224        let res = state.buffer.push_bytes(data);
225        drop(state);
226        self.ready.notify_all();
227        res
228    }
229
230    fn read_into(&self, buf: &mut [u8]) -> io::Result<usize> {
231        if buf.is_empty() {
232            return Ok(0);
233        }
234        let mut state = self.lock_state();
235        loop {
236            let n = state.buffer.read_into(buf)?;
237            if n > 0 {
238                return Ok(n);
239            }
240            if state.closed {
241                return Ok(0);
242            }
243            state = self
244                .ready
245                .wait(state)
246                .map_err(|_| io::Error::other("pipe wait poisoned"))?;
247        }
248    }
249
250    /// Timeout-bounded variant of the blocking buffer read for bridge
251    /// worker loops: returns `Ok(None)` when the backstop elapses with no
252    /// data and no close, so cancellation resolves on a tick instead of
253    /// hanging on a condvar. Bridge-only caller; every DSL reader keeps
254    /// the blocking read with unchanged semantics.
255    pub fn read_into_timeout(
256        &self,
257        buf: &mut [u8],
258        backstop: Duration,
259    ) -> io::Result<Option<usize>> {
260        if buf.is_empty() {
261            return Ok(Some(0));
262        }
263        let mut state = self.lock_state();
264        loop {
265            let n = state.buffer.read_into(buf)?;
266            if n > 0 {
267                return Ok(Some(n));
268            }
269            if state.closed {
270                return Ok(Some(0));
271            }
272            let (guard, waited) = self
273                .ready
274                .wait_timeout(state, backstop)
275                .map_err(|_| io::Error::other("pipe wait poisoned"))?;
276            state = guard;
277            if waited.timed_out() {
278                return Ok(None);
279            }
280        }
281    }
282
283    fn lock_state(&self) -> std::sync::MutexGuard<'_, PipeState> {
284        self.state.lock().expect("script pipe state poisoned")
285    }
286}
287
288struct PipeReader {
289    inner: Arc<PipeInner>,
290}
291
292impl PipeReader {
293    fn new(inner: Arc<PipeInner>) -> Self {
294        Self { inner }
295    }
296}
297
298impl Read for PipeReader {
299    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
300        self.inner.read_into(buf)
301    }
302}
303
304struct PipeWriter {
305    inner: Arc<PipeInner>,
306}
307
308impl PipeWriter {
309    fn new(inner: Arc<PipeInner>) -> Self {
310        inner.attach_writer();
311        Self { inner }
312    }
313}
314
315impl Write for PipeWriter {
316    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
317        self.inner.push_bytes(buf)?;
318        Ok(buf.len())
319    }
320
321    fn flush(&mut self) -> io::Result<()> {
322        Ok(())
323    }
324}
325
326impl Drop for PipeWriter {
327    fn drop(&mut self) {
328        self.inner.detach_writer();
329    }
330}
331
332/// Snapshot of one pipe handle for `INSPECT()` diagnostics.
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334pub enum PipeKindDesc {
335    /// Declared but never bound: no backend, no bytes.
336    Unbound,
337    /// In-memory store-and-forward buffer (possibly spilled to disk).
338    Script,
339    /// Zero-copy OS kernel pair. Kernel-side bytes are invisible, so
340    /// buffered counts stay 0 and reader/writer counts report pair
341    /// presence, not live takes. Never constructed under Miri, where
342    /// promotion is compiled out.
343    #[cfg_attr(miri, allow(dead_code))]
344    Os,
345}
346
347impl PipeKindDesc {
348    /// Stable lowercase name for `INSPECT()` maps and fixtures.
349    pub fn as_str(self) -> &'static str {
350        match self {
351            PipeKindDesc::Unbound => "unbound",
352            PipeKindDesc::Script => "script",
353            PipeKindDesc::Os => "os",
354        }
355    }
356
357    /// Whether the handle materialized to a kernel pair.
358    pub fn is_os(self) -> bool {
359        matches!(self, PipeKindDesc::Os)
360    }
361}
362
363/// Point-in-time pipe stats, queried after releasing the cell lock so no
364/// lock is ever held across both the cell and the backend.
365#[derive(Debug, Clone)]
366pub struct PipeInfo {
367    /// Materialization kind.
368    pub kind: PipeKindDesc,
369    /// Bytes currently buffered for script pipes; always 0 for OS pairs
370    /// (kernel bytes are invisible) and unbound handles.
371    pub buffered: u64,
372    /// Bound script pipes report 1 (the reader half is live by
373    /// construction); OS pairs report presence, not live takes.
374    pub readers: usize,
375    /// Live data-writer attachments for script pipes (keeper pins excluded);
376    /// OS pairs report presence, not live takes.
377    pub writers: usize,
378}
379
380/// Script backend behind a handle, if materialized as script. Never
381/// creates a backend: unbound and OS-materialized handles yield `None`.
382/// Keeper pins and backend clones route through here so pinning never
383/// forces a pipe into existence.
384pub fn script_backend(handle: &crate::slot::PipeHandle) -> Option<Arc<PipeInner>> {
385    use crate::slot::Slot;
386    let guard = handle.cell().lock().expect("pipe handle lock poisoned");
387    match &*guard {
388        Slot::Script { backend } => Some(Arc::clone(backend)),
389        Slot::Unbound => None,
390        #[cfg(not(miri))]
391        Slot::Os { .. } => None,
392    }
393}
394
395/// Non-destructive snapshot of a script handle's buffered bytes for
396/// pipe-content assertions. Never waits and never creates: unbound
397/// handles bail (nothing was ever bound), and OS pairs bail (kernel
398/// bytes are invisible — drain the stream instead).
399pub fn peek(handle: &crate::slot::PipeHandle) -> anyhow::Result<Vec<u8>> {
400    use crate::slot::Slot;
401    let backend = {
402        let guard = handle.cell().lock().expect("pipe handle lock poisoned");
403        match &*guard {
404            Slot::Script { backend } => Some(Arc::clone(backend)),
405            Slot::Unbound => {
406                return Err(anyhow::anyhow!(
407                    "cannot peek unbound pipe: bind it to a command first"
408                ));
409            }
410            #[cfg(not(miri))]
411            Slot::Os { .. } => {
412                return Err(anyhow::anyhow!(
413                    "cannot peek OS-materialized pipe: drain it through a bound command instead"
414                ));
415            }
416        }
417    };
418    let Some(inner) = backend else {
419        unreachable!("unbound/os arms return above");
420    };
421    inner
422        .peek_bytes()
423        .map_err(|e| anyhow::anyhow!("failed to peek pipe: {e}"))
424}
425
426/// Snapshot one handle for diagnostics. Unbound handles report kind
427/// `unbound` with zeroed stats; script backends are cloned out from under
428/// the cell lock, then queried.
429pub fn inspect(handle: &crate::slot::PipeHandle) -> PipeInfo {
430    use crate::slot::Slot;
431    let backend = {
432        let guard = handle.cell().lock().expect("pipe handle lock poisoned");
433        match &*guard {
434            Slot::Unbound => None,
435            Slot::Script { backend } => Some(backend.clone()),
436            #[cfg(not(miri))]
437            Slot::Os { .. } => {
438                return PipeInfo {
439                    kind: PipeKindDesc::Os,
440                    buffered: 0,
441                    readers: 1,
442                    writers: 1,
443                };
444            }
445        }
446    };
447    match backend {
448        Some(inner) => PipeInfo {
449            kind: PipeKindDesc::Script,
450            buffered: inner.buffered_bytes(),
451            readers: 1,
452            writers: inner.writer_count(),
453        },
454        None => PipeInfo {
455            kind: PipeKindDesc::Unbound,
456            buffered: 0,
457            readers: 0,
458            writers: 0,
459        },
460    }
461}
462
463/// Pre-allocated keeper handle for `ASYNC` tasks. Created synchronously
464/// on the spawning thread before the worker starts so the pipe can never
465/// observe zero writers mid-flight; released when the worker exits.
466pub struct KeeperGuard {
467    inner: Option<Arc<PipeInner>>,
468}
469
470impl KeeperGuard {
471    /// Pin a keeper slot on a script backend.
472    pub fn new(inner: Arc<PipeInner>) -> Self {
473        inner.pin_keeper();
474        Self { inner: Some(inner) }
475    }
476}
477
478impl Drop for KeeperGuard {
479    fn drop(&mut self) {
480        if let Some(inner) = self.inner.take() {
481            inner.unpin_keeper();
482        }
483    }
484}