Skip to main content

cuttlefish_sdk/
lib.rs

1//! Guest-side library for writing cuttlefish proc-blocks.
2//!
3//! A block is a state machine that the host drives. It never calls the host and
4//! waits; it *returns* a [`Command`] saying what it wants, and the host — having
5//! done that thing — steps it again with an [`Event`]. See [`cuttlefish_abi`]
6//! for why control is inverted, and what that buys: chiefly that cancellation
7//! needs no cooperation from the guest, because the host simply stops stepping.
8//!
9//! This crate exists so block authors do not hand-write that inversion.
10//! Implement [`Block`], call [`export_block!`], and the macro emits the raw wasm
11//! exports the host expects.
12//!
13//! # Writing a block
14//!
15//! ```
16//! use cuttlefish_sdk::{Block, Command, Event};
17//!
18//! #[derive(Default)]
19//! struct Shout;
20//!
21//! impl Block for Shout {
22//!     fn start(&mut self, input: serde_json::Value) -> Command {
23//!         match input.get("path").and_then(|v| v.as_str()) {
24//!             Some(path) => Command::Open { path: path.to_string() },
25//!             None => Command::Fail {
26//!                 code: "schema_validation_failed".into(),
27//!                 message: "input needs a string `path`".into(),
28//!             },
29//!         }
30//!     }
31//!
32//!     fn step(&mut self, event: Event) -> Command {
33//!         match event {
34//!             Event::Opened { handle, len, .. } => Command::Slice { handle, offset: 0, len },
35//!             Event::Sliced { text, .. } => Command::Done {
36//!                 result: serde_json::json!({ "shouted": text.to_uppercase() }),
37//!             },
38//!             other => Command::Fail {
39//!                 code: "unexpected_event".into(),
40//!                 message: format!("{other:?}"),
41//!             },
42//!         }
43//!     }
44//! }
45//!
46//! // A real block adds this to emit the wasm exports:
47//! //     cuttlefish_sdk::export_block!(Shout);
48//! ```
49//!
50//! Because a block is an ordinary Rust type, it can be unit-tested natively with
51//! no wasm involved: construct it, call `start`, then feed it the events its
52//! commands would produce. Only the boundary itself needs a wasm harness.
53//!
54//! # Memory ownership across the boundary
55//!
56//! Values handed to the host are leaked on purpose. The host reads them
57//! immediately after the call returns, and the entire instance is destroyed when
58//! the job ends, so there is nothing to reclaim and no cross-language allocator
59//! coordination to get wrong.
60//!
61//! Do not "fix" this by freeing. The host would then read freed memory, and on
62//! wasm that is a silent wrong answer rather than a segfault — the linear memory
63//! is still perfectly valid to read, it just no longer holds what anyone thinks.
64
65#![forbid(unsafe_op_in_unsafe_fn)]
66#![warn(missing_docs)]
67
68pub use cuttlefish_abi::{
69    Command, Event, Handle, ImageOperation, MediaKind, Signature, TokenAction, Ty,
70};
71
72/// How a guest hands a (pointer, length) pair back to the host.
73///
74/// The obvious alternative is packing both into the single `i64` a wasm export
75/// can return. That works only while pointers are 32 bits. Returning a pointer
76/// to this struct instead costs one extra memory read per call and keeps every
77/// export signature unchanged under a 64-bit guest, where `usize` simply widens.
78///
79/// Do not replace this with bit-packing; that is precisely what it exists to
80/// avoid. The host reads exactly `2 * size_of::<usize>()` bytes at the returned
81/// address and splits them down the middle, so this layout is load-bearing —
82/// hence `#[repr(C)]`, and hence the tests pinning its size and field order.
83#[repr(C)]
84pub struct Desc {
85    /// Address of the payload.
86    pub ptr: usize,
87    /// Payload length, in bytes.
88    pub len: usize,
89}
90
91/// What a proc-block author implements.
92///
93/// The host calls [`start`](Block::start) once with the job's input, then
94/// [`step`](Block::step) after each command it carries out, until the block
95/// returns [`Command::Done`] or [`Command::Fail`].
96pub trait Block: Default {
97    /// What this block accepts and produces.
98    ///
99    /// Declared here rather than in a file beside the block, so it cannot
100    /// disagree with the code below it. The host reads this to typecheck a
101    /// pipeline's seams before running anything.
102    ///
103    /// The default is permissive — JSON in, JSON out — so an existing block
104    /// keeps working. That is deliberately the weakest useful answer: a pipeline
105    /// of `Json` seams typechecks unconditionally, so a block that means to be
106    /// composed should say something more specific.
107    fn signature() -> Signature {
108        Signature {
109            input: Ty::Json,
110            output: Ty::Json,
111        }
112    }
113
114    /// Produce the first command from the job's input.
115    ///
116    /// Prefer returning [`Command::Fail`] to panicking on malformed input: a
117    /// panic becomes an opaque wasm trap, whereas a `Fail` carries a code and a
118    /// message the caller can act on.
119    fn start(&mut self, input: serde_json::Value) -> Command;
120
121    /// Produce the next command, given the result of the previous one.
122    fn step(&mut self, event: Event) -> Command;
123
124    /// Decide whether generation should continue, once per streamed token.
125    ///
126    /// Defaults to [`TokenAction::Continue`], so a block indifferent to
127    /// streaming can ignore it. A token or two may still arrive after returning
128    /// [`TokenAction::Stop`], because the verdict has to travel back to the
129    /// thread doing the generating.
130    fn on_token(&mut self, _token: &str) -> TokenAction {
131        TokenAction::Continue
132    }
133}
134
135/// Allocate a buffer for the host to write into, returning its address.
136///
137/// Leaked deliberately; see the crate docs on memory ownership.
138#[doc(hidden)]
139pub fn __alloc(len: usize) -> usize {
140    let mut buf = Vec::<u8>::with_capacity(len);
141    let ptr = buf.as_mut_ptr() as usize;
142    std::mem::forget(buf);
143    ptr
144}
145
146/// Decode JSON the host wrote at `ptr`.
147///
148/// # Safety
149///
150/// `ptr` must point at `len` initialized bytes written by the host, normally
151/// into a buffer obtained from [`__alloc`].
152#[doc(hidden)]
153pub unsafe fn __read_json<T: serde::de::DeserializeOwned>(ptr: usize, len: usize) -> T {
154    let slice = unsafe { std::slice::from_raw_parts(ptr as *const u8, len) };
155    serde_json::from_slice(slice).expect("host sent malformed JSON")
156}
157
158/// Serialize `value` into guest memory, returning the address of a [`Desc`]
159/// describing it.
160///
161/// Both the payload and the descriptor are leaked; see the crate docs.
162#[doc(hidden)]
163pub fn __write_json<T: serde::Serialize>(value: &T) -> usize {
164    let bytes = serde_json::to_vec(value).expect("guest produced unserializable value");
165    let len = bytes.len();
166    let ptr = Box::into_raw(bytes.into_boxed_slice()) as *mut u8 as usize;
167    Box::into_raw(Box::new(Desc { ptr, len })) as usize
168}
169
170/// Emit the wasm exports for a [`Block`] implementation.
171///
172/// The block's state lives in a thread-local because the host instantiates one
173/// module per job and never shares it — so there is exactly one block instance
174/// per module instance, and no cross-job state that could leak between them.
175///
176/// Every export takes and returns `usize` rather than a packed integer. On
177/// `wasm32` that lowers to `i32` parameters and results; on a 64-bit guest the
178/// same source compiles to `i64` with nothing here changing.
179#[macro_export]
180macro_rules! export_block {
181    ($ty:ty) => {
182        thread_local! {
183            static __CF_STATE: ::std::cell::RefCell<$ty> =
184                ::std::cell::RefCell::new(<$ty as ::core::default::Default>::default());
185        }
186
187        #[no_mangle]
188        pub extern "C" fn cf_alloc(len: usize) -> usize {
189            $crate::__alloc(len)
190        }
191
192        /// Report this block's type signature. Read at build time, before any
193        /// job runs, to check that a pipeline's seams line up.
194        #[no_mangle]
195        pub extern "C" fn cf_signature() -> usize {
196            $crate::__write_json(&<$ty as $crate::Block>::signature())
197        }
198
199        #[no_mangle]
200        pub extern "C" fn cf_init(ptr: usize, len: usize) -> usize {
201            let input: ::serde_json::Value = unsafe { $crate::__read_json(ptr, len) };
202            let cmd = __CF_STATE.with(|s| $crate::Block::start(&mut *s.borrow_mut(), input));
203            $crate::__write_json(&cmd)
204        }
205
206        #[no_mangle]
207        pub extern "C" fn cf_step(ptr: usize, len: usize) -> usize {
208            let event: $crate::Event = unsafe { $crate::__read_json(ptr, len) };
209            let cmd = __CF_STATE.with(|s| $crate::Block::step(&mut *s.borrow_mut(), event));
210            $crate::__write_json(&cmd)
211        }
212
213        #[no_mangle]
214        pub extern "C" fn cf_on_token(ptr: usize, len: usize) -> i32 {
215            // Lossy on purpose: a model can emit a token split mid-character,
216            // and mangling one character is a far better outcome than trapping
217            // the whole job over it.
218            let token: ::std::string::String = unsafe {
219                let slice = ::std::slice::from_raw_parts(ptr as *const u8, len);
220                ::std::string::String::from_utf8_lossy(slice).into_owned()
221            };
222            $crate::TokenAction::as_i32(
223                __CF_STATE.with(|s| $crate::Block::on_token(&mut *s.borrow_mut(), &token)),
224            )
225        }
226    };
227}