Skip to main content

oxdock_pipe/
slot.rs

1//! Owned pipe handles: a mutex cell holding a lazily materializing slot.
2//!
3//! A [`PipeHandle`] starts [`Slot::Unbound`] and materializes on first
4//! binding — never eagerly at declaration, so the backend choice always
5//! has full usage context. Cloning the handle shares the backend (natural
6//! fan-out for explicit sharing); the last drop closes. No central index
7//! exists: resolution, keepers, and assertions all operate on handles
8//! already in hand.
9
10use std::sync::{
11    Arc, Mutex,
12    atomic::{AtomicBool, Ordering},
13};
14
15// `Result` threads through the OS-pipe take-once slots, which are
16// compiled out under Miri.
17#[cfg_attr(miri, allow(unused_imports))]
18use anyhow::Result;
19
20use crate::backend::{PipeInner, ScriptPipe};
21
22/// Shared reader half: mutex-guarded `Read` behind an `Arc`, so every
23/// binding aliases one channel.
24pub type SharedInput = Arc<Mutex<dyn std::io::Read + Send>>;
25
26/// Shared writer half: mutex-guarded `Write` behind an `Arc`.
27pub type SharedOutput = Arc<Mutex<dyn std::io::Write + Send>>;
28
29/// Owned OS kernel pipe reader half behind a single use slot. `Clone`
30/// shares the slot; `take` transfers the handle exactly once so no parent
31/// copy survives spawn to starve the consumer of EOF. Backed by
32/// `std::io::pipe` (stable since Rust 1.87): `pipe` on Unix, `CreatePipe`
33/// on Windows. Moved verbatim from `oxdock-process` so handle slots can
34/// own kernel pairs without a dependency cycle; behavior is unchanged.
35#[cfg(not(miri))]
36#[derive(Clone)]
37pub struct OsPipeReader {
38    inner: Arc<Mutex<Option<std::io::PipeReader>>>,
39}
40
41/// Owned OS kernel pipe writer half behind a single use slot. See
42/// [`OsPipeReader`] for the shared slot semantics. Moved verbatim from
43/// `oxdock-process`; behavior is unchanged.
44#[cfg(not(miri))]
45#[derive(Clone)]
46pub struct OsPipeWriter {
47    inner: Arc<Mutex<Option<std::io::PipeWriter>>>,
48}
49
50#[cfg(not(miri))]
51impl OsPipeReader {
52    fn new(reader: std::io::PipeReader) -> Self {
53        Self {
54            inner: Arc::new(Mutex::new(Some(reader))),
55        }
56    }
57
58    /// Take the handle for `Stdio::from`. Bails deterministically if the
59    /// descriptor was already consumed so a second spawn can never reuse a
60    /// spent pipe or leave stdio unbound.
61    pub fn take(&self) -> Result<std::io::PipeReader> {
62        self.inner
63            .lock()
64            .map_err(|_| anyhow::anyhow!("os pipe reader lock poisoned"))?
65            .take()
66            .ok_or_else(|| {
67                anyhow::anyhow!("os pipe handle has already been consumed by another process")
68            })
69    }
70
71    /// Whether this half was already taken. A poisoned slot reports live
72    /// so callers never recycle what they cannot inspect.
73    pub fn is_consumed(&self) -> bool {
74        self.inner.lock().map(|g| g.is_none()).unwrap_or(false)
75    }
76}
77
78#[cfg(not(miri))]
79impl OsPipeWriter {
80    fn new(writer: std::io::PipeWriter) -> Self {
81        Self {
82            inner: Arc::new(Mutex::new(Some(writer))),
83        }
84    }
85
86    /// Take the handle for `Stdio::from`. Bails deterministically if the
87    /// descriptor was already consumed so a second spawn can never reuse a
88    /// spent pipe or leave stdio unbound.
89    pub fn take(&self) -> Result<std::io::PipeWriter> {
90        self.inner
91            .lock()
92            .map_err(|_| anyhow::anyhow!("os pipe writer lock poisoned"))?
93            .take()
94            .ok_or_else(|| {
95                anyhow::anyhow!("os pipe handle has already been consumed by another process")
96            })
97    }
98
99    /// Whether this half was already taken. A poisoned slot reports live
100    /// so callers never recycle what they cannot inspect.
101    pub fn is_consumed(&self) -> bool {
102        self.inner.lock().map(|g| g.is_none()).unwrap_or(false)
103    }
104}
105
106/// Create a cross platform anonymous OS pipe pair for concurrent `ASYNC`
107/// pipelines. The caller moves each half into a spawn and drops any other
108/// copies immediately after spawning, otherwise the reader never sees EOF.
109/// Moved verbatim from `oxdock-process`; behavior is unchanged.
110#[cfg(not(miri))]
111pub fn create_os_pipe() -> Result<(OsPipeReader, OsPipeWriter)> {
112    let (reader, writer) = std::io::pipe()?;
113    Ok((OsPipeReader::new(reader), OsPipeWriter::new(writer)))
114}
115
116/// One anonymous OS kernel pipe pair behind take-once slots. The first
117/// producer and the first consumer each take their half; any further
118/// binding to the same handle bails deterministically instead of
119/// interleaving bytes or stealing the descriptor.
120#[cfg(not(miri))]
121#[derive(Clone)]
122pub struct OsPipeEntry {
123    /// Writer half. Only `RUN` consumes this directly; DSL commands
124    /// observe bridged shared handles instead.
125    pub writer: OsPipeWriter,
126    /// Reader half. Only `RUN` consumes this directly; DSL commands
127    /// observe bridged shared handles instead.
128    pub reader: OsPipeReader,
129}
130
131#[cfg(not(miri))]
132impl OsPipeEntry {
133    /// Mint a fresh kernel pair.
134    pub fn new() -> anyhow::Result<Self> {
135        let (reader, writer) = create_os_pipe()?;
136        Ok(Self { writer, reader })
137    }
138
139    /// Both halves taken. The takers hold raw descriptors outside the
140    /// entry, so a spent entry can never serve another resolve.
141    pub fn is_spent(&self) -> bool {
142        self.reader.is_consumed() && self.writer.is_consumed()
143    }
144}
145
146/// Backend state behind a [`PipeHandle`]. Starts unbound; the first
147/// binding decides the kind under the cell lock and later bindings adapt
148/// through the existing resolution machinery instead of failing or
149/// upgrading in place.
150pub enum Slot {
151    /// Declared but never bound: no backend, no bytes.
152    Unbound,
153    /// Store-and-forward buffer shared by every binding.
154    Script {
155        /// Live backend. Cloned out for keepers, peeks, and diagnostics.
156        backend: Arc<PipeInner>,
157    },
158    /// Zero-copy OS kernel pair behind take-once slots.
159    #[cfg(not(miri))]
160    Os {
161        /// Live kernel pair. Takes are single-use per end.
162        entry: OsPipeEntry,
163    },
164}
165
166impl std::fmt::Debug for Slot {
167    /// Opaque by design: backend internals (buffers, fds) never enter
168    /// debug output; only the materialization kind shows.
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        match self {
171            Slot::Unbound => write!(f, "Unbound"),
172            Slot::Script { .. } => write!(f, "Script(..)"),
173            #[cfg(not(miri))]
174            Slot::Os { .. } => write!(f, "Os(..)"),
175        }
176    }
177}
178
179/// Owned pipe handle: cheap to clone, shared backend, no registry.
180/// `LET $p: PIPE` mints one; `LET $q: PIPE = $p` clones it.
181///
182/// Clones share everything: the backend cell, the declaring task id, and
183/// the escape flag. Identity is the cell (`Arc::ptr_eq` on it), exactly
184/// like the old bare-`Arc` handle.
185#[derive(Clone, Debug)]
186pub struct PipeHandle {
187    cell: Arc<Mutex<Slot>>,
188    declaring_task_id: u64,
189    escaped_to_child: Arc<AtomicBool>,
190}
191
192/// Mint a fresh unbound handle declared by `task_id` (`0` = root flow).
193/// The id travels with every clone so promotion checks always see the
194/// declaration origin, no matter how far the value aliases.
195pub fn new_handle_in_task(task_id: u64) -> PipeHandle {
196    PipeHandle {
197        cell: Arc::new(Mutex::new(Slot::Unbound)),
198        declaring_task_id: task_id,
199        escaped_to_child: Arc::new(AtomicBool::new(false)),
200    }
201}
202
203impl PipeHandle {
204    /// Task that executed the `LET $p: PIPE` declaration (`0` = root flow).
205    pub fn declaring_task(&self) -> u64 {
206        self.declaring_task_id
207    }
208
209    /// Whether any spawned child task can observe this handle. Set at
210    /// `ASYNC` fork time for every pipe visible in scope; sticky, since a
211    /// share, once possible, never un-happens.
212    pub fn has_escaped(&self) -> bool {
213        self.escaped_to_child.load(Ordering::SeqCst)
214    }
215
216    /// Mark escaped (see [`PipeHandle::has_escaped`]). Idempotent.
217    pub fn mark_escaped(&self) {
218        self.escaped_to_child.store(true, Ordering::SeqCst);
219    }
220
221    /// Borrow the backend cell for materialization and lock-step transitions.
222    pub(crate) fn cell(&self) -> &Arc<Mutex<Slot>> {
223        &self.cell
224    }
225
226    /// Handle identity: true iff both handles share the same backend cell
227    /// (aliases of one declaration). Never compares bytes.
228    pub fn ptr_eq(&self, other: &Self) -> bool {
229        Arc::ptr_eq(&self.cell, &other.cell)
230    }
231}
232
233/// Decided backend cloned out from under the cell lock. OS entries clone
234/// their take-once slots (takes still race deterministically at take
235/// time); script backends clone their `Arc`.
236pub enum Materialized {
237    /// Store-and-forward backend.
238    Script(Arc<PipeInner>),
239    /// Kernel pair behind take-once slots.
240    #[cfg(not(miri))]
241    Os(OsPipeEntry),
242}
243
244/// First-binding-wins materialization under the cell lock: an unbound
245/// handle decides its kind from `promote` (the caller supplies full usage
246/// context — RUN-terminated ⇒ OS, else script); a decided handle returns
247/// its kind unchanged. Later bindings with different needs adapt through
248/// the caller's resolution machinery instead of failing or upgrading here.
249pub fn materialize(handle: &PipeHandle, promote: bool) -> anyhow::Result<Materialized> {
250    let mut guard = handle
251        .cell()
252        .lock()
253        .map_err(|_| anyhow::anyhow!("pipe handle lock poisoned"))?;
254    match &*guard {
255        Slot::Script { backend } => Ok(Materialized::Script(Arc::clone(backend))),
256        #[cfg(not(miri))]
257        Slot::Os { entry } => Ok(Materialized::Os(entry.clone())),
258        Slot::Unbound => {
259            #[cfg(not(miri))]
260            if promote {
261                let entry = OsPipeEntry::new()?;
262                let out = entry.clone();
263                *guard = Slot::Os { entry };
264                return Ok(Materialized::Os(out));
265            }
266            #[cfg(miri)]
267            let _ = promote;
268            let pipe = ScriptPipe::new();
269            let backend = pipe.pipe_inner();
270            *guard = Slot::Script {
271                backend: Arc::clone(&backend),
272            };
273            Ok(Materialized::Script(backend))
274        }
275    }
276}