oxdock_parser/value.rs
1//! Value-word core: every DSL value is a fixed-size word (a [`TypeDescriptor`]
2//! vtable pointer plus a 64-bit [`ValuePayload`]) interpreted through that
3//! vtable.
4//!
5//! There is exactly one representation for every type. Payloads that fit in
6//! 64 bits (integers, floats, booleans, handles, and host scalars annotated
7//! `#[oxdock_type(inline)]`) ride directly in the payload; everything else
8//! rides behind a thin pointer to either an owned `Box<T>` (exclusive heaps:
9//! `STRING`, `PATH`, `DURATION`, `PIPE`, most host types) or a shared
10//! `Arc<T>` (shared heaps: `LIST`, `MAP`, and host types annotated
11//! `#[oxdock_type(shared)]`). The vtable owns the lifecycle (`clone`, `drop`)
12//! and operations (`eq`, `fmt`), so `Clone`/`Drop`/`PartialEq`/`Display` on
13//! [`Value`] delegate instead of matching. There are no dynamic trait
14//! objects anywhere in this path: every hook is a monomorphic function
15//! pointer reached directly, with no table lookup and no lock.
16//!
17//! Descriptors are canonical singletons: each `#[oxdock_type]` struct gets
18//! one `&'static TypeDescriptor` (built at compile time, shared by every
19//! word of that type), so words carry their own vtable and no registry of
20//! any kind exists. The twelve startup types (`INT`, `FLOAT`, `STRING`, `BOOL`,
21//! `LIST`, `MAP`, `PATH`, `DURATION`, `PIPE`, `HANDLE`, `SEMAPHORE`, `PERMIT`)
22//! are ordinary Rust structs annotated with `#[oxdock_type]`, exactly as host
23//! types are. Name directories (which descriptor answers for `"TAG"`) live per execution
24//! state in `oxdock-core`, never here: this module knows types, not names.
25//!
26//! Ownership discipline (load-bearing, Miri-verified in
27//! `crates/oxdock-core/tests/miri_value_words.rs`):
28//!
29//! - Exclusive heap [`Value`]s own their box exactly once. `clone` allocates
30//! a new box; `drop` frees it. No sharing, no aliasing. Because each box
31//! holds a concrete sized `T`, its pointer is thin: no double-boxing, no
32//! fat pointer casts, no metadata to lose.
33//! - Shared heap [`Value`]s (`LIST`, `MAP`) co-own an `Arc<T>` buffer.
34//! `clone` bumps the strong count in `O(1)` with no allocation; `drop`
35//! releases one count and frees only the final word's drop. Because the
36//! DSL exposes no interior mutability, aliases, or reference syntax,
37//! container graphs are strictly acyclic trees, so refcounting reclaims
38//! deterministically with no tracing collector. Mutable access goes only
39//! through [`Value::read_heap_mut`], which detaches (clones the buffer)
40//! whenever the strong count exceeds 1, so a writer always exclusively
41//! owns a private buffer and clones never observe each other's writes.
42//! Deriving `&mut` from a payload any other way is unsound.
43//! - Pointer casts are always `Box::into_raw` / `Box::from_raw` (exclusive)
44//! or `Arc::into_raw` / `Arc::from_raw` plus `Arc::increment_strong_count`
45//! (shared) round trips on the same concrete payload type, which preserves
46//! provenance. Inline words never touch the pointer domain; heap words
47//! never touch the integer domain.
48//! - Minting a word with a descriptor built for a different Rust type
49//! misdirects the vtable and is unsound. The `mint_*` constructors
50//! document this contract; hosts mint through the payload type's own
51//! `OxDockType::descriptor()`, which cannot mismatch by construction.
52
53use std::collections::BTreeMap;
54use std::fmt;
55use std::time::Duration;
56
57use oxdock_func_macro::oxdock_type;
58use oxdock_pipe::{PipeHandle, new_handle_in_task};
59
60/// Anchor of a type's reference section, derived from its name the way the
61/// Markdown slugger derives it from the doc title.
62pub fn type_anchor(name: &str) -> String {
63 format!("value-type-{}", name.to_lowercase())
64}
65
66/// Canonical descriptors of the twelve startup types, in a fixed order, for
67/// seeding per-state name directories and static rendering (docs-gen).
68/// Each entry is the payload struct's own singleton: no table, no lock.
69pub fn startup_descriptors() -> [(&'static str, &'static TypeDescriptor); 12] {
70 [
71 ("INT", IntValue::descriptor()),
72 ("FLOAT", FloatValue::descriptor()),
73 ("STRING", StringValue::descriptor()),
74 ("BOOL", BoolValue::descriptor()),
75 ("LIST", ListValue::descriptor()),
76 ("MAP", MapValue::descriptor()),
77 ("PATH", PathValue::descriptor()),
78 ("DURATION", DurationValue::descriptor()),
79 ("PIPE", PipeValue::descriptor()),
80 ("HANDLE", HandleValue::descriptor()),
81 ("SEMAPHORE", SemaphoreValue::descriptor()),
82 ("PERMIT", SemaphorePermit::descriptor()),
83 ]
84}
85
86// ---------------------------------------------------------------------------
87// Payload structs for the startup-registered types. Each carries
88// `#[oxdock_type]` so its descriptor derives from the same macro hosts use;
89// `inline` selects the zero-allocation payload path, exactly as for host
90// scalars. Private: hosts never name these types; they observe them through
91// the word accessors below.
92// ---------------------------------------------------------------------------
93
94/// 64-bit signed integer, e.g. an exit code.
95#[oxdock_type(crate_path = "::oxdock_parser", name = "INT", inline)]
96#[derive(Debug, Clone, Copy, PartialEq)]
97struct IntValue(pub i64);
98
99/// 64-bit float, e.g. a ratio.
100#[oxdock_type(crate_path = "::oxdock_parser", name = "FLOAT", inline)]
101#[derive(Debug, Clone, Copy, PartialEq)]
102struct FloatValue(pub f64);
103
104/// Arbitrary text. Quotes keep exact bytes, lone `$var` evaluates, `{{ ... }}` interpolates.
105#[oxdock_type(
106 crate_path = "::oxdock_parser",
107 name = "STRING",
108 summary = "Arbitrary text."
109)]
110#[derive(Debug, Clone, PartialEq)]
111struct StringValue(pub String);
112
113/// Boolean `true` or `false`.
114#[oxdock_type(crate_path = "::oxdock_parser", name = "BOOL", inline)]
115#[derive(Debug, Clone, Copy, PartialEq)]
116struct BoolValue(pub bool);
117
118/// Ordered list of values. Shared heap: cloning bumps a refcount.
119#[oxdock_type(crate_path = "::oxdock_parser", name = "LIST", shared)]
120#[derive(Debug, Clone, PartialEq)]
121struct ListValue(pub Vec<Value>);
122
123/// String-keyed map of values. Shared heap: cloning bumps a refcount.
124#[oxdock_type(crate_path = "::oxdock_parser", name = "MAP", shared)]
125#[derive(Debug, Clone, PartialEq)]
126struct MapValue(pub BTreeMap<String, Value>);
127
128/// Workspace path, resolved against cwd and guarded against escape.
129#[oxdock_type(crate_path = "::oxdock_parser", name = "PATH")]
130#[derive(Debug, Clone, PartialEq)]
131#[allow(clippy::disallowed_types)]
132struct PathValue(#[allow(clippy::disallowed_types)] pub std::path::PathBuf);
133
134/// Positive time span: `500ms`, `10s`, `2m`, `1h`; bare number means seconds.
135#[oxdock_type(
136 crate_path = "::oxdock_parser",
137 name = "DURATION",
138 summary = "Positive time span."
139)]
140#[derive(Debug, Clone, PartialEq)]
141struct DurationValue(pub Duration);
142
143/// Anonymous pipe handle. The backend materializes lazily on first
144/// binding (never eagerly at declaration), so the choice always has full
145/// usage context. Cloning shares the backend (explicit-sharing fan-out);
146/// equality is handle identity, never byte comparison.
147#[oxdock_type(
148 crate_path = "::oxdock_parser",
149 name = "PIPE",
150 summary = "Anonymous pipe handle.",
151 shared
152)]
153#[derive(Debug, Clone)]
154struct PipeValue(pub PipeHandle);
155
156impl PartialEq for PipeValue {
157 /// Handle identity: two words name the same channel iff they share
158 /// the cell. Never compares bytes (backends may be unbound, and
159 /// locking two cells in `eq` risks ordering deadlocks).
160 fn eq(&self, other: &Self) -> bool {
161 self.0.ptr_eq(&other.0)
162 }
163}
164
165/// Background ASYNC task handle for AWAIT/CANCEL.
166#[oxdock_type(crate_path = "::oxdock_parser", name = "HANDLE", inline)]
167#[derive(Debug, Clone, Copy, PartialEq)]
168struct HandleValue(pub u64);
169
170/// Shared admission-control counter backing `SEMAPHORE` words. Clones
171/// share the backend, so every word naming one semaphore observes the
172/// same count. Lock-free atomics throughout: no mutex exists to poison,
173/// so `Drop` paths stay infallible on every path including unwinding.
174/// No waiter ever sleeps on the counter, so the non-blocking acquire
175/// adds zero wedge surface.
176#[derive(Debug)]
177pub struct SemaphoreState {
178 max: usize,
179 held: std::sync::atomic::AtomicUsize,
180}
181
182impl SemaphoreState {
183 fn new(max: usize) -> Self {
184 Self {
185 max,
186 held: std::sync::atomic::AtomicUsize::new(0),
187 }
188 }
189
190 /// Non-blocking acquire: true and counted when a permit was free.
191 /// Compare-and-swap loop, so only a winning CAS claims a slot.
192 pub fn try_acquire(&self) -> bool {
193 use std::sync::atomic::Ordering;
194 let mut current = self.held.load(Ordering::Acquire);
195 loop {
196 if current >= self.max {
197 return false;
198 }
199 match self.held.compare_exchange_weak(
200 current,
201 current + 1,
202 Ordering::AcqRel,
203 Ordering::Acquire,
204 ) {
205 Ok(_) => return true,
206 Err(actual) => current = actual,
207 }
208 }
209 }
210
211 /// Release one permit. Saturating and infallible: exactly-once
212 /// last-drop release (see `PermitInner`) keeps this exact, and the
213 /// floor (never a wrap, never a panic) is the backstop.
214 pub fn release(&self) {
215 use std::sync::atomic::Ordering;
216 let _ = self
217 .held
218 .try_update(Ordering::AcqRel, Ordering::Acquire, |held| {
219 held.checked_sub(1)
220 });
221 }
222
223 /// Permits currently free. Exact at read time, stale the instant the
224 /// caller acts on it: observability only, never admission input.
225 pub fn available(&self) -> usize {
226 self.max
227 .saturating_sub(self.held.load(std::sync::atomic::Ordering::Acquire))
228 }
229}
230
231/// Counting semaphore for admission control. Cloning shares the backend
232/// (explicit-sharing fan-out); equality is handle identity, never the
233/// count.
234#[oxdock_type(
235 crate_path = "::oxdock_parser",
236 name = "SEMAPHORE",
237 summary = "Counting semaphore for admission control.",
238 shared
239)]
240#[derive(Debug, Clone)]
241struct SemaphoreValue(pub std::sync::Arc<SemaphoreState>);
242
243impl PartialEq for SemaphoreValue {
244 /// Handle identity: two words name the same semaphore iff they share
245 /// the backend. Never compares counts (a racing acquire would make
246 /// equality nondeterministic).
247 fn eq(&self, other: &Self) -> bool {
248 std::sync::Arc::ptr_eq(&self.0, &other.0)
249 }
250}
251
252/// Last-drop releaser for one acquired permit. Lives behind the permit
253/// word's `Arc`: when the final permit clone drops, this drops and hands
254/// the permit back exactly once. No manual release exists, so worker
255/// return (clean, error, panic, cooperative cancel) releases through
256/// ordinary frame teardown with no DSL cleanup code.
257#[derive(Debug)]
258struct PermitInner {
259 sem: std::sync::Arc<SemaphoreState>,
260}
261
262impl Drop for PermitInner {
263 fn drop(&mut self) {
264 self.sem.release();
265 }
266}
267
268/// Opaque admission permit minted by `SEMAPHORE_TRY_ACQUIRE`. Cloning
269/// shares the release obligation (first drops release nothing, the last
270/// releases once); equality is handle identity.
271#[oxdock_type(
272 crate_path = "::oxdock_parser",
273 name = "PERMIT",
274 summary = "Opaque admission permit; last drop releases it.",
275 shared
276)]
277#[derive(Debug, Clone)]
278struct SemaphorePermit(pub std::sync::Arc<PermitInner>);
279
280impl PartialEq for SemaphorePermit {
281 /// Handle identity: two words name the same permit iff they share
282 /// the releaser.
283 fn eq(&self, other: &Self) -> bool {
284 std::sync::Arc::ptr_eq(&self.0, &other.0)
285 }
286}
287
288impl fmt::Display for IntValue {
289 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
290 write!(f, "{}", self.0)
291 }
292}
293
294impl fmt::Display for FloatValue {
295 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
296 write!(f, "{}", self.0)
297 }
298}
299
300impl fmt::Display for BoolValue {
301 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
302 write!(f, "{}", self.0)
303 }
304}
305
306impl fmt::Display for HandleValue {
307 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
308 write!(f, "task#{}", self.0)
309 }
310}
311
312impl fmt::Display for StringValue {
313 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
314 write!(f, "\"{}\"", self.0)
315 }
316}
317
318impl fmt::Display for ListValue {
319 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
320 write!(f, "[")?;
321 for (i, item) in self.0.iter().enumerate() {
322 if i > 0 {
323 write!(f, ", ")?;
324 }
325 write!(f, "{}", item)?;
326 }
327 write!(f, "]")
328 }
329}
330
331impl fmt::Display for MapValue {
332 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
333 write!(f, "{{")?;
334 for (i, (k, v)) in self.0.iter().enumerate() {
335 if i > 0 {
336 write!(f, ", ")?;
337 }
338 write!(f, "{}: {}", k, v)?;
339 }
340 write!(f, "}}")
341 }
342}
343
344impl fmt::Display for DurationValue {
345 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
346 write!(f, "{}", crate::command::format_duration(&self.0))
347 }
348}
349
350impl fmt::Display for PathValue {
351 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
352 write!(f, "{}", self.0.display())
353 }
354}
355
356impl fmt::Display for PipeValue {
357 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
358 write!(f, "<pipe>")
359 }
360}
361
362impl fmt::Display for SemaphoreValue {
363 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
364 write!(f, "<semaphore>")
365 }
366}
367
368impl fmt::Display for SemaphorePermit {
369 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
370 write!(f, "<permit>")
371 }
372}
373
374/// Payload half of a [`Value`] word: either the value's bytes inline or a
375/// thin pointer to an owned `Box<T>` (exclusive heaps) or a shared `Arc<T>`
376/// (shared heaps), as the type's descriptor dictates.
377/// Inline and pointer domains never mix for a given [`TypeDescriptor`].
378///
379/// Fields are private so safe code cannot forge payloads: every payload
380/// enters a word through [`store_inline`] (inline bytes) or the
381/// [`Value::mint_heap`] / [`Value::mint_heap_shared`] / [`Value::mint_inline`]
382/// choke points, where the `Send + Sync + 'static` bounds are enforced.
383/// External code observes payload bits through [`Value::inline_bits`] and
384/// [`Value::heap_ptr`].
385#[repr(C)]
386#[derive(Clone, Copy)]
387pub union ValuePayload {
388 as_u64: u64,
389 as_ptr: *mut (),
390}
391
392// Raw pointers are not `Send`/`Sync`, so both are implemented by hand.
393// Soundness: fields are private, so every exclusive heap [`Value`] owns its
394// box exactly once (mint allocates, `clone` allocates, `drop` frees),
395// shared heap [`Value`]s co-own their `Arc` buffer (mint allocates with
396// count 1, `clone` bumps, `drop` releases), payloads are never mutably
397// aliased, no vtable hook writes through a shared reference, and heap
398// contents are `Send + Sync` by construction (enforced at the `mint_*`
399// choke points, the only construction path; `Arc<T>` itself is `Send + Sync`
400// exactly when `T` is, which the same bounds guarantee).
401unsafe impl Send for ValuePayload {}
402unsafe impl Sync for ValuePayload {}
403
404/// A DSL value: a [`TypeDescriptor`] vtable pointer plus a [`ValuePayload`].
405/// Fixed size (128 bits on 64-bit targets). Lifecycle and operations call
406/// the vtable directly, with no table lookup and no lock; see the module
407/// docs for the ownership discipline.
408//
409// Fields are private so safe code cannot forge words with dangling
410// pointers: construction flows through [`Value::mint_inline`],
411// [`Value::mint_heap`], or the typed constructors below, and typed reads
412// go through [`Value::read_inline`] / [`Value::read_heap`].
413// `Send`/`Sync` follow from the payload impls above plus shared references.
414unsafe impl Send for Value {}
415unsafe impl Sync for Value {}
416#[repr(C)]
417pub struct Value {
418 vtable: &'static TypeDescriptor,
419 payload: ValuePayload,
420}
421
422impl Value {
423 /// The word's canonical descriptor singleton: the vtable backing its
424 /// lifecycle and operations.
425 pub fn descriptor(&self) -> &'static TypeDescriptor {
426 self.vtable
427 }
428
429 /// The word's registered type name (the descriptor's name).
430 pub fn type_name(&self) -> &'static str {
431 self.vtable.name
432 }
433
434 /// Raw payload bits, copied out. Meaningful for inline words (the
435 /// value's bytes); for heap words these are the box pointer's bits.
436 pub fn inline_bits(&self) -> u64 {
437 unsafe { self.payload.as_u64 }
438 }
439
440 /// Heap box (exclusive) or buffer (shared) pointer, copied out. Only
441 /// meaningful for heap words; never dereferenced here. Reading (not
442 /// dereferencing) is safe.
443 pub fn heap_ptr(&self) -> *mut () {
444 unsafe { self.payload.as_ptr }
445 }
446
447 /// Mint an inline word: memcpy the value's bytes into the payload.
448 /// Zero allocation. The descriptor must be the payload type's own
449 /// `OxDockType::descriptor()`; mismatching them misdirects the vtable
450 /// and is unsound.
451 pub fn mint_inline<T>(descriptor: &'static TypeDescriptor, value: T) -> Self
452 where
453 T: Copy + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
454 {
455 Self {
456 vtable: descriptor,
457 payload: store_inline(value),
458 }
459 }
460
461 /// Mint an exclusive heap word: move the value into an owned `Box<T>`
462 /// behind a thin pointer. One box allocation. The descriptor must be the
463 /// payload type's own `OxDockType::descriptor()`; mismatching them
464 /// misdirects the vtable and is unsound.
465 pub fn mint_heap<T>(descriptor: &'static TypeDescriptor, value: T) -> Self
466 where
467 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
468 {
469 Self {
470 vtable: descriptor,
471 payload: ValuePayload {
472 as_ptr: Box::into_raw(Box::new(value)) as *mut (),
473 },
474 }
475 }
476
477 /// Mint a shared heap word: move the value into a reference-counted
478 /// `Arc<T>` behind a thin pointer. One allocation; later clones bump the
479 /// strong count instead of copying. The descriptor must be the payload
480 /// type's own `OxDockType::descriptor()` built for the shared path
481 /// (`#[oxdock_type(shared)]`); mismatching them misdirects the vtable
482 /// and is unsound.
483 pub fn mint_heap_shared<T>(descriptor: &'static TypeDescriptor, value: T) -> Self
484 where
485 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
486 {
487 Self {
488 vtable: descriptor,
489 payload: ValuePayload {
490 as_ptr: std::sync::Arc::into_raw(std::sync::Arc::new(value)) as *mut (),
491 },
492 }
493 }
494
495 /// Read an inline word back out. Returns `None` when the word carries
496 /// a different descriptor; the load itself is infallible for a word
497 /// minted for `T`.
498 pub fn read_inline<T>(&self, expected: &'static TypeDescriptor) -> Option<T>
499 where
500 T: Copy + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
501 {
502 if !std::ptr::eq(self.vtable, expected) {
503 return None;
504 }
505 Some(unsafe { load_inline::<T>(self.payload) })
506 }
507
508 /// Borrow a heap word's concrete value. Returns `None` when the word
509 /// carries a different descriptor.
510 pub fn read_heap<T>(&self, expected: &'static TypeDescriptor) -> Option<&T>
511 where
512 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
513 {
514 if !std::ptr::eq(self.vtable, expected) {
515 return None;
516 }
517 Some(unsafe { &*(self.payload.as_ptr as *const T) })
518 }
519
520 /// Borrow a heap word's concrete value mutably, detaching shared buffers
521 /// first (copy-on-write). Returns `None` when the word carries a
522 /// different descriptor. This is the only sound way to obtain `&mut`
523 /// access to a heap payload: exclusive heaps hand out their box
524 /// directly, shared heaps clone-then-hand-out when the strong count
525 /// exceeds 1 and mutate in place otherwise. Panics when called with an
526 /// inline descriptor, which has no heap buffer.
527 pub fn read_heap_mut<T>(&mut self, expected: &'static TypeDescriptor) -> Option<&mut T>
528 where
529 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
530 {
531 if !std::ptr::eq(self.vtable, expected) {
532 return None;
533 }
534 Some(unsafe { &mut *((self.vtable.unshare)(&mut self.payload) as *mut T) })
535 }
536
537 /// Construct an integer word (inline, zero allocation).
538 pub fn int(n: i64) -> Self {
539 Self::mint_inline(IntValue::descriptor(), IntValue(n))
540 }
541
542 /// Construct a float word (inline, zero allocation).
543 pub fn float(f: f64) -> Self {
544 Self::mint_inline(FloatValue::descriptor(), FloatValue(f))
545 }
546
547 /// Construct a boolean word (inline, zero allocation).
548 pub fn bool(b: bool) -> Self {
549 Self::mint_inline(BoolValue::descriptor(), BoolValue(b))
550 }
551
552 /// Construct a task-handle word (inline, zero allocation).
553 pub fn handle(id: u64) -> Self {
554 Self::mint_inline(HandleValue::descriptor(), HandleValue(id))
555 }
556
557 /// Construct a string word.
558 pub fn string(s: String) -> Self {
559 Self::mint_heap(StringValue::descriptor(), StringValue(s))
560 }
561
562 /// Construct a list word (shared heap: clones share the buffer).
563 pub fn list(items: Vec<Value>) -> Self {
564 Self::mint_heap_shared(ListValue::descriptor(), ListValue(items))
565 }
566
567 /// Construct a map word (shared heap: clones share the buffer).
568 pub fn map(entries: BTreeMap<String, Value>) -> Self {
569 Self::mint_heap_shared(MapValue::descriptor(), MapValue(entries))
570 }
571
572 /// Construct a path word.
573 #[allow(clippy::disallowed_types)]
574 pub fn path(p: std::path::PathBuf) -> Self {
575 Self::mint_heap(PathValue::descriptor(), PathValue(p))
576 }
577
578 /// Construct a duration word.
579 pub fn duration(d: Duration) -> Self {
580 Self::mint_heap(DurationValue::descriptor(), DurationValue(d))
581 }
582
583 /// Construct a fresh unbound pipe handle (`LET $p: PIPE`, host
584 /// `new_pipe()`). Materializes lazily on first binding.
585 pub fn pipe_fresh() -> Self {
586 Self::pipe_fresh_in_task(0)
587 }
588
589 /// Construct a fresh unbound pipe handle declared by `task_id` (`0` =
590 /// root flow). The id travels with every clone so promotion checks
591 /// always see the declaration origin.
592 pub fn pipe_fresh_in_task(task_id: u64) -> Self {
593 Self::mint_heap_shared(
594 PipeValue::descriptor(),
595 PipeValue(new_handle_in_task(task_id)),
596 )
597 }
598
599 /// Wrap an existing handle as a `PIPE` word. Clones share the backend.
600 pub fn pipe_handle(handle: PipeHandle) -> Self {
601 Self::mint_heap_shared(PipeValue::descriptor(), PipeValue(handle))
602 }
603
604 /// Construct a semaphore word admitting at most `max` concurrent
605 /// holders. Every clone names the same backend.
606 pub fn semaphore(max: usize) -> Self {
607 Self::mint_heap_shared(
608 SemaphoreValue::descriptor(),
609 SemaphoreValue(std::sync::Arc::new(SemaphoreState::new(max))),
610 )
611 }
612
613 /// Mint a `PERMIT` word bound to `sem`. The permit returns to the
614 /// semaphore when the last clone of the word drops.
615 pub fn permit(sem: &std::sync::Arc<SemaphoreState>) -> Self {
616 Self::mint_heap_shared(
617 SemaphorePermit::descriptor(),
618 SemaphorePermit(std::sync::Arc::new(PermitInner {
619 sem: std::sync::Arc::clone(sem),
620 })),
621 )
622 }
623
624 /// Borrow the semaphore backend out of a `SEMAPHORE` word. Returns
625 /// `None` for non-`SEMAPHORE` words. The clone shares the backend.
626 pub fn as_semaphore(&self) -> Option<std::sync::Arc<SemaphoreState>> {
627 self.read_heap::<SemaphoreValue>(SemaphoreValue::descriptor())
628 .map(|v| std::sync::Arc::clone(&v.0))
629 }
630
631 /// Read an integer payload. Returns `None` for non-`INT` words.
632 pub fn as_i64(&self) -> Option<i64> {
633 self.read_inline::<IntValue>(IntValue::descriptor())
634 .map(|v| v.0)
635 }
636
637 /// Read a float payload. Returns `None` for non-`FLOAT` words.
638 pub fn as_f64(&self) -> Option<f64> {
639 self.read_inline::<FloatValue>(FloatValue::descriptor())
640 .map(|v| v.0)
641 }
642
643 /// Read a boolean payload. Returns `None` for non-`BOOL` words.
644 pub fn as_bool(&self) -> Option<bool> {
645 self.read_inline::<BoolValue>(BoolValue::descriptor())
646 .map(|v| v.0)
647 }
648
649 /// Read a task-handle payload. Returns `None` for non-`HANDLE` words.
650 pub fn as_handle(&self) -> Option<u64> {
651 self.read_inline::<HandleValue>(HandleValue::descriptor())
652 .map(|v| v.0)
653 }
654
655 /// Borrow a string payload. Returns `None` for non-`STRING` words.
656 pub fn as_str(&self) -> Option<&str> {
657 self.read_heap::<StringValue>(StringValue::descriptor())
658 .map(|v| v.0.as_str())
659 }
660
661 /// Borrow a list payload. Returns `None` for non-`LIST` words.
662 pub fn as_list(&self) -> Option<&Vec<Value>> {
663 self.read_heap::<ListValue>(ListValue::descriptor())
664 .map(|v| &v.0)
665 }
666
667 /// Borrow a list payload mutably, detaching the shared buffer first when
668 /// clones exist. Returns `None` for non-`LIST` words. This is the choke
669 /// point every future in-place container mutation must go through.
670 pub fn as_list_mut(&mut self) -> Option<&mut Vec<Value>> {
671 self.read_heap_mut::<ListValue>(ListValue::descriptor())
672 .map(|v| &mut v.0)
673 }
674
675 /// Borrow a map payload. Returns `None` for non-`MAP` words.
676 pub fn as_map(&self) -> Option<&BTreeMap<String, Value>> {
677 self.read_heap::<MapValue>(MapValue::descriptor())
678 .map(|v| &v.0)
679 }
680
681 /// Borrow a map payload mutably, detaching the shared buffer first when
682 /// clones exist. Returns `None` for non-`MAP` words. This is the choke
683 /// point every future in-place container mutation must go through.
684 pub fn as_map_mut(&mut self) -> Option<&mut BTreeMap<String, Value>> {
685 self.read_heap_mut::<MapValue>(MapValue::descriptor())
686 .map(|v| &mut v.0)
687 }
688
689 /// Clone the pipe handle out of a `PIPE` word. Returns `None` for
690 /// non-`PIPE` words. The clone shares the backend cell.
691 pub fn as_pipe_handle(&self) -> Option<PipeHandle> {
692 self.read_heap::<PipeValue>(PipeValue::descriptor())
693 .map(|v| v.0.clone())
694 }
695
696 /// Read a duration payload. Returns `None` for non-`DURATION` words.
697 pub fn as_duration(&self) -> Option<Duration> {
698 self.read_heap::<DurationValue>(DurationValue::descriptor())
699 .map(|v| v.0)
700 }
701
702 /// Borrow a path payload. Returns `None` for non-`PATH` words.
703 #[allow(clippy::disallowed_types)]
704 pub fn as_path(&self) -> Option<&std::path::Path> {
705 self.read_heap::<PathValue>(PathValue::descriptor())
706 .map(|v| v.0.as_path())
707 }
708}
709
710impl Clone for Value {
711 fn clone(&self) -> Self {
712 let payload = unsafe { (self.vtable.clone)(self.payload) };
713 Self {
714 vtable: self.vtable,
715 payload,
716 }
717 }
718}
719
720impl Drop for Value {
721 fn drop(&mut self) {
722 unsafe { (self.vtable.drop)(self.payload) };
723 }
724}
725
726impl PartialEq for Value {
727 fn eq(&self, other: &Self) -> bool {
728 if !std::ptr::eq(self.vtable, other.vtable) {
729 return false;
730 }
731 unsafe { (self.vtable.eq)(self.payload, other.payload) }
732 }
733}
734
735impl fmt::Debug for Value {
736 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
737 write!(f, "{}(", self.vtable.name)?;
738 unsafe { (self.vtable.fmt)(self.payload, f) }?;
739 write!(f, ")")
740 }
741}
742
743impl fmt::Display for Value {
744 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
745 unsafe { (self.vtable.fmt)(self.payload, f) }
746 }
747}
748
749/// Export hook for a DSL payload type, implemented by `#[oxdock_type]` on
750/// the payload struct itself. The canonical descriptor singleton backs
751/// every word of the type; user code never names a generated symbol.
752pub trait OxDockType {
753 /// The canonical descriptor deriving from the struct's name plus doc
754 /// comments. The same reference every call: pointer-compare words
755 /// against it.
756 fn descriptor() -> &'static TypeDescriptor;
757}
758
759/// Vtable for one type: lifecycle plus operations. All hooks are plain
760/// function pointers (never closures) so descriptors stay `Copy` and the
761/// global table hands them out by value. Every hook documents the payload
762/// domain it expects; calling one with a foreign payload is unsound, and
763/// every call site is a single choke point reviewed with the layout.
764///
765/// `unshare` is the copy-on-write gate: it rewrites the payload to a
766/// uniquely owned buffer when necessary and returns a mutable pointer the
767/// caller exclusively owns. Mutation must always go through
768/// [`Value::read_heap_mut`]; deriving `&mut` from a payload any other way
769/// is unsound for shared heaps.
770#[derive(Clone, Copy)]
771pub struct TypeDescriptor {
772 pub name: &'static str,
773 pub summary: &'static str,
774 pub docs: &'static str,
775 pub clone: unsafe fn(ValuePayload) -> ValuePayload,
776 pub drop: unsafe fn(ValuePayload),
777 pub eq: unsafe fn(ValuePayload, ValuePayload) -> bool,
778 pub fmt: unsafe fn(ValuePayload, &mut fmt::Formatter<'_>) -> fmt::Result,
779 pub unshare: unsafe fn(&mut ValuePayload) -> *mut (),
780}
781
782// ---------------------------------------------------------------------------
783// Payload adapters: one inline set, one exclusive-heap set, and one shared-
784// heap set drive `clone`/`drop`/`eq`/`fmt`/`unshare` for every type through
785// monomorphic function pointers. These are `pub` solely so `#[oxdock_type]`-
786// generated descriptors can name them; hosts never call them directly.
787// ---------------------------------------------------------------------------
788
789/// Copy a `Copy` value's bytes into a payload. Panics when `T` exceeds 64
790/// bits: such types must use the heap path.
791pub fn store_inline<T>(value: T) -> ValuePayload
792where
793 T: Copy + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
794{
795 assert!(
796 std::mem::size_of::<T>() <= 8,
797 "inline payloads hold at most 64 bits"
798 );
799 let mut bits: u64 = 0;
800 unsafe {
801 std::ptr::copy_nonoverlapping(
802 &value as *const T as *const u8,
803 &mut bits as *mut u64 as *mut u8,
804 std::mem::size_of::<T>(),
805 );
806 }
807 // No `mem::forget`: `T: Copy` has no finalizer, so the source needs no
808 // suppression after its bytes are copied out.
809 ValuePayload { as_u64: bits }
810}
811
812/// Reconstruct a `Copy` value from an inline payload.
813///
814/// # Safety
815/// The payload must hold bytes stored by [`store_inline`] for `T`.
816pub unsafe fn load_inline<T>(payload: ValuePayload) -> T
817where
818 T: Copy + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
819{
820 // (Spelled as an explicit gate because `debug_assert!` expands to the
821 // banned `cfg!` macro.)
822 #[cfg(debug_assertions)]
823 if std::mem::size_of::<T>() > 8 {
824 panic!("inline payloads hold at most 64 bits");
825 }
826 let mut value = std::mem::MaybeUninit::<T>::uninit();
827 unsafe {
828 std::ptr::copy_nonoverlapping(
829 &payload.as_u64 as *const u64 as *const u8,
830 value.as_mut_ptr() as *mut u8,
831 std::mem::size_of::<T>(),
832 );
833 value.assume_init()
834 }
835}
836
837/// Inline `clone`: payloads are plain bytes.
838///
839/// # Safety
840/// The payload must hold inline bytes (never a live pointer).
841pub unsafe fn clone_copy(payload: ValuePayload) -> ValuePayload {
842 payload
843}
844
845/// Inline `drop`: nothing owns anything.
846///
847/// # Safety
848/// The payload must hold inline bytes (never a live pointer).
849pub unsafe fn drop_noop(_payload: ValuePayload) {}
850
851/// Inline `eq`: reconstruct both sides and compare.
852///
853/// # Safety
854/// Both payloads must hold bytes stored by [`store_inline`] for `T`.
855pub unsafe fn eq_inline<T>(a: ValuePayload, b: ValuePayload) -> bool
856where
857 T: Copy + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
858{
859 unsafe { load_inline::<T>(a) == load_inline::<T>(b) }
860}
861
862/// Inline `fmt`: reconstruct and render.
863///
864/// # Safety
865/// The payload must hold bytes stored by [`store_inline`] for `T`.
866pub unsafe fn fmt_inline<T>(payload: ValuePayload, f: &mut fmt::Formatter) -> fmt::Result
867where
868 T: Copy + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
869{
870 write!(f, "{}", unsafe { load_inline::<T>(payload) })
871}
872
873/// Heap `clone`: deep-copy the box.
874///
875/// # Safety
876/// The payload must own a `Box<T>` exactly once.
877pub unsafe fn clone_boxed<T>(payload: ValuePayload) -> ValuePayload
878where
879 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
880{
881 let source = unsafe { &*(payload.as_ptr as *const T) };
882 ValuePayload {
883 as_ptr: Box::into_raw(Box::new(source.clone())) as *mut (),
884 }
885}
886/// Heap `drop`: free the box.
887///
888/// # Safety
889/// The payload must own a `Box<T>` exactly once; it must never be used
890/// again afterwards.
891pub unsafe fn drop_boxed<T>(payload: ValuePayload)
892where
893 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
894{
895 drop(unsafe { Box::from_raw(payload.as_ptr as *mut T) });
896}
897
898/// Heap `eq`: compare the boxed values.
899///
900/// # Safety
901/// Both payloads must own a `Box<T>` exactly once.
902pub unsafe fn eq_boxed<T>(a: ValuePayload, b: ValuePayload) -> bool
903where
904 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
905{
906 let left = unsafe { &*(a.as_ptr as *const T) };
907 let right = unsafe { &*(b.as_ptr as *const T) };
908 left == right
909}
910
911/// Heap `fmt`: render the boxed value.
912///
913/// # Safety
914/// The payload must own a `Box<T>` exactly once.
915pub unsafe fn fmt_boxed<T>(payload: ValuePayload, f: &mut fmt::Formatter) -> fmt::Result
916where
917 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
918{
919 let value = unsafe { &*(payload.as_ptr as *const T) };
920 write!(f, "{value}")
921}
922
923/// Shared-heap `clone`: bump the `Arc` strong count, sharing the buffer.
924/// `O(1)` with no allocation.
925///
926/// # Safety
927/// The payload must co-own an `Arc<T>` buffer minted by
928/// [`Value::mint_heap_shared`] and cloned only through this hook, so one
929/// outstanding strong count exists per live word.
930pub unsafe fn clone_shared<T>(payload: ValuePayload) -> ValuePayload
931where
932 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
933{
934 unsafe { std::sync::Arc::increment_strong_count(payload.as_ptr as *const T) };
935 payload
936}
937
938/// Shared-heap `drop`: release one `Arc` strong count, freeing the buffer
939/// only when the final word drops.
940///
941/// # Safety
942/// The payload must co-own an `Arc<T>` buffer; it must never be used again
943/// afterwards.
944pub unsafe fn drop_shared<T>(payload: ValuePayload)
945where
946 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
947{
948 drop(unsafe { std::sync::Arc::from_raw(payload.as_ptr as *const T) });
949}
950
951/// Shared-heap `eq`: compare the shared values.
952///
953/// # Safety
954/// Both payloads must co-own an `Arc<T>` buffer.
955pub unsafe fn eq_shared<T>(a: ValuePayload, b: ValuePayload) -> bool
956where
957 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
958{
959 let left = unsafe { &*(a.as_ptr as *const T) };
960 let right = unsafe { &*(b.as_ptr as *const T) };
961 left == right
962}
963
964/// Shared-heap `fmt`: render the shared value.
965///
966/// # Safety
967/// The payload must co-own an `Arc<T>` buffer.
968pub unsafe fn fmt_shared<T>(payload: ValuePayload, f: &mut fmt::Formatter) -> fmt::Result
969where
970 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
971{
972 let value = unsafe { &*(payload.as_ptr as *const T) };
973 write!(f, "{value}")
974}
975
976/// Inline `unshare`: inline words hold bytes, not a heap buffer, so there
977/// is nothing to hand out mutably. Panics: reaching this hook means
978/// [`Value::read_heap_mut`] was called with an inline descriptor, a caller
979/// bug (mirrors [`store_inline`]'s size assert).
980///
981/// # Safety
982/// The payload must hold inline bytes (never a live pointer).
983pub unsafe fn unshare_inline(payload: &mut ValuePayload) -> *mut () {
984 let _ = payload;
985 panic!("inline words have no heap buffer to unshare");
986}
987
988/// Exclusive-heap `unshare`: the box is already uniquely owned, so the
989/// payload is returned unchanged with no allocation.
990///
991/// # Safety
992/// The payload must own a `Box<T>` exactly once. The returned pointer must
993/// only be written through while this word stays the sole owner.
994pub unsafe fn unshare_boxed<T>(payload: &mut ValuePayload) -> *mut ()
995where
996 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
997{
998 unsafe { payload.as_ptr }
999}
1000
1001/// Shared-heap `unshare`: detach on write. When the strong count is 1 the
1002/// payload is returned unchanged (in-place, no allocation); otherwise the
1003/// buffer is cloned, the word is rewritten to the private buffer, and the
1004/// other clones keep the original. Either way the returned pointer addresses
1005/// a buffer this word uniquely owns.
1006///
1007/// # Safety
1008/// The payload must co-own an `Arc<T>` buffer minted by
1009/// [`Value::mint_heap_shared`] with one outstanding strong count per live
1010/// word. The returned pointer must only be written through while this word
1011/// stays the sole owner of its (possibly fresh) buffer.
1012pub unsafe fn unshare_shared<T>(payload: &mut ValuePayload) -> *mut ()
1013where
1014 T: Clone + PartialEq + fmt::Display + fmt::Debug + Send + Sync + 'static,
1015{
1016 let raw = unsafe { payload.as_ptr } as *const T;
1017 let mut shared = unsafe { std::sync::Arc::from_raw(raw) };
1018 let unique = std::sync::Arc::make_mut(&mut shared);
1019 let out = unique as *mut T;
1020 payload.as_ptr = std::sync::Arc::into_raw(shared) as *mut ();
1021 out as *mut ()
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027
1028 #[test]
1029 fn semaphore_counts_exactly_to_cap() {
1030 let sem = Value::semaphore(2);
1031 let inner = sem.as_semaphore().expect("SEMAPHORE word");
1032 assert_eq!(inner.available(), 2);
1033 assert!(inner.try_acquire());
1034 assert_eq!(inner.available(), 1);
1035 assert!(inner.try_acquire());
1036 assert_eq!(inner.available(), 0);
1037 assert!(!inner.try_acquire());
1038 inner.release();
1039 assert_eq!(inner.available(), 1);
1040 }
1041
1042 #[test]
1043 fn semaphore_words_share_one_backend_by_identity() {
1044 let first = Value::semaphore(1);
1045 let alias = first.clone();
1046 assert_eq!(&alias, &first);
1047 assert_eq!(format!("{first}"), "<semaphore>");
1048 // One acquire through either word exhausts the shared count.
1049 assert!(first.as_semaphore().expect("backend").try_acquire());
1050 assert!(!alias.as_semaphore().expect("backend").try_acquire());
1051 // Distinct declarations never alias; non-words read as absent.
1052 assert_ne!(Value::semaphore(1), first);
1053 assert!(Value::int(1).as_semaphore().is_none());
1054 }
1055
1056 #[test]
1057 fn permit_last_drop_releases_exactly_once() {
1058 let sem = Value::semaphore(1);
1059 let inner = sem.as_semaphore().expect("backend");
1060 assert!(inner.try_acquire());
1061 let first = Value::permit(&inner);
1062 assert_eq!(format!("{first}"), "<permit>");
1063 let second = first.clone();
1064 assert_eq!(&first, &second);
1065 drop(first);
1066 assert_eq!(inner.available(), 0);
1067 drop(second);
1068 assert_eq!(inner.available(), 1);
1069 }
1070
1071 #[test]
1072 fn permit_releases_when_holder_panics() {
1073 let sem = Value::semaphore(1);
1074 let inner = sem.as_semaphore().expect("backend");
1075 let worker = {
1076 let inner = std::sync::Arc::clone(&inner);
1077 std::thread::spawn(move || {
1078 assert!(inner.try_acquire());
1079 let _permit = Value::permit(&inner);
1080 panic!("worker fails holding the permit");
1081 })
1082 };
1083 assert!(worker.join().is_err());
1084 assert_eq!(inner.available(), 1);
1085 }
1086
1087 #[test]
1088 fn semaphore_holds_cap_under_contention() {
1089 use std::sync::atomic::Ordering;
1090 let sem = Value::semaphore(4);
1091 let inner = sem.as_semaphore().expect("backend");
1092 let holders = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1093 let peak = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1094 let mut threads = Vec::new();
1095 for _ in 0..8 {
1096 let inner = std::sync::Arc::clone(&inner);
1097 let holders = std::sync::Arc::clone(&holders);
1098 let peak = std::sync::Arc::clone(&peak);
1099 threads.push(std::thread::spawn(move || {
1100 let mut acquired = 0;
1101 while acquired < 25 {
1102 if inner.try_acquire() {
1103 {
1104 // Release rides the permit word's drop: the
1105 // scope exit below hands the permit back.
1106 let _permit = Value::permit(&inner);
1107 let current = holders.fetch_add(1, Ordering::SeqCst) + 1;
1108 peak.fetch_max(current, Ordering::SeqCst);
1109 std::thread::yield_now();
1110 holders.fetch_sub(1, Ordering::SeqCst);
1111 }
1112 acquired += 1;
1113 } else {
1114 std::thread::yield_now();
1115 }
1116 }
1117 }));
1118 }
1119 for thread in threads {
1120 thread.join().expect("worker joins");
1121 }
1122 // 200 acquisitions, never more than 4 inside at once, every
1123 // permit handed back: exact accounting under contention.
1124 assert!(peak.load(Ordering::SeqCst) <= 4);
1125 assert_eq!(holders.load(Ordering::SeqCst), 0);
1126 assert_eq!(inner.available(), 4);
1127 }
1128}