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}