Skip to main content

cuttlefish_host/
runner.rs

1//! The reactor loop: the host drives the guest, one command at a time.
2//!
3//! This module is the shape of the whole system. The guest never calls the host
4//! and waits — it returns a [`Command`], the host carries it out, and the host
5//! steps the guest again with the resulting [`Event`]. Two things follow from
6//! that inversion, and both are why it is worth the awkwardness:
7//!
8//! - **Cancellation needs no guest cooperation.** The host simply stops
9//!   stepping. A guest cannot ignore, delay, or trap its way out of it.
10//! - **Every iteration is observable.** Progress, token counts, and capability
11//!   decisions all pass through the host, even for a block whose internal loop
12//!   the DAG cannot see.
13//!
14//! The alternative — host functions the guest imports and blocks on — is not
15//! merely less tidy, it does not work: a single-threaded core-wasm guest offers
16//! no execution context for the host to call back into, and the wasmtime `Store`
17//! is `!Sync` while inference must run on a separate thread.
18
19use crate::caps::Capabilities;
20use crate::handles::Handles;
21use crate::infer::{InferBackend, InferResult};
22use cuttlefish_abi::{error_codes, Command, Envelope, Event, JobError, JobStatus, Usage};
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::Arc;
25use std::time::Instant;
26use tokio::sync::mpsc;
27use tokio_util::sync::CancellationToken;
28use wasmtime::{Engine, Instance, Linker, Memory, Module, Store, TypedFunc};
29
30/// Something worth telling a watcher about while a job runs.
31#[derive(Debug, Clone)]
32pub enum JobEvent {
33    /// One generated token.
34    Token(String),
35    /// Guest-supplied progress.
36    Progress(serde_json::Value),
37}
38
39/// Everything needed to run one job.
40pub struct JobSpec {
41    /// The compiled guest module.
42    pub module_bytes: Vec<u8>,
43    /// The job's input, handed to the guest's `init`.
44    pub input: serde_json::Value,
45    /// What this job is permitted to reach.
46    pub caps: Capabilities,
47}
48
49/// Pointer width of a guest module, read from the module rather than assumed.
50///
51/// Only [`Abi::W32`] is supported today; 64-bit guests are rejected with a clear
52/// message. The enum exists anyway so that adding wasm64 later is a new arm plus
53/// a second set of [`TypedFunc`] signatures, rather than a hunt through this
54/// file for every place a pointer was assumed to be four bytes wide.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Abi {
57    /// 32-bit linear memory.
58    W32,
59    /// 64-bit linear memory (memory64).
60    W64,
61}
62
63impl Abi {
64    /// Size of one pointer-sized field, and so half a descriptor.
65    fn ptr_size(self) -> usize {
66        match self {
67            Abi::W32 => 4,
68            Abi::W64 => 8,
69        }
70    }
71}
72
73struct Guest {
74    store: Store<()>,
75    memory: Memory,
76    abi: Abi,
77    alloc: TypedFunc<u32, u32>,
78    init: TypedFunc<(u32, u32), u32>,
79    step: TypedFunc<(u32, u32), u32>,
80    on_token: Option<TypedFunc<(u32, u32), i32>>,
81}
82
83impl Guest {
84    fn new(engine: &Engine, module_bytes: &[u8]) -> anyhow::Result<Self> {
85        let module = Module::new(engine, module_bytes)?;
86
87        // An empty linker, deliberately. Guest blocks are built for
88        // `wasm32-unknown-unknown` and import nothing at all — a wasip1 guest
89        // would drag in `fd_write` and `proc_exit` through its panic path alone
90        // and fail to instantiate here.
91        let linker: Linker<()> = Linker::new(engine);
92        let mut store = Store::new(engine, ());
93        let instance: Instance = linker.instantiate(&mut store, &module)?;
94
95        let memory = instance
96            .get_memory(&mut store, "memory")
97            .ok_or_else(|| anyhow::anyhow!("guest exports no memory"))?;
98
99        // Width comes from the module itself. A 64-bit guest exports `cf_init`
100        // as `(i64, i64) -> i64`, so the typed lookups below would otherwise
101        // fail with a signature mismatch that says nothing about the real cause.
102        let abi = if memory.ty(&store).is_64() {
103            Abi::W64
104        } else {
105            Abi::W32
106        };
107        if abi == Abi::W64 {
108            anyhow::bail!("guest uses 64-bit memory; only 32-bit guests are supported");
109        }
110
111        Ok(Self {
112            alloc: instance.get_typed_func(&mut store, "cf_alloc")?,
113            init: instance.get_typed_func(&mut store, "cf_init")?,
114            step: instance.get_typed_func(&mut store, "cf_step")?,
115            // Optional: a block indifferent to streaming need not export it.
116            on_token: instance.get_typed_func(&mut store, "cf_on_token").ok(),
117            memory,
118            abi,
119            store,
120        })
121    }
122
123    fn write(&mut self, bytes: &[u8]) -> anyhow::Result<(u32, u32)> {
124        let len = bytes.len() as u32;
125        let ptr = self.alloc.call(&mut self.store, len)?;
126        self.memory.write(&mut self.store, ptr as usize, bytes)?;
127        Ok((ptr, len))
128    }
129
130    /// Read the descriptor the guest returned, then the payload it points at.
131    ///
132    /// Two reads rather than unpacking one integer — the cost of keeping these
133    /// signatures identical across pointer widths.
134    fn read_desc(&mut self, desc_ptr: u32) -> anyhow::Result<Vec<u8>> {
135        let w = self.abi.ptr_size();
136        let mut desc = vec![0u8; 2 * w];
137        self.memory
138            .read(&mut self.store, desc_ptr as usize, &mut desc)?;
139
140        let field = |bytes: &[u8]| -> u64 {
141            match w {
142                4 => u32::from_le_bytes(bytes.try_into().expect("4 bytes")) as u64,
143                _ => u64::from_le_bytes(bytes.try_into().expect("8 bytes")),
144            }
145        };
146        let ptr = field(&desc[..w]) as usize;
147        let len = field(&desc[w..]) as usize;
148
149        let mut buf = vec![0u8; len];
150        self.memory.read(&mut self.store, ptr, &mut buf)?;
151        Ok(buf)
152    }
153
154    fn call_init(&mut self, input: &serde_json::Value) -> anyhow::Result<Command> {
155        let bytes = serde_json::to_vec(input)?;
156        let (ptr, len) = self.write(&bytes)?;
157        let desc = self.init.call(&mut self.store, (ptr, len))?;
158        Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
159    }
160
161    fn call_step(&mut self, event: &Event) -> anyhow::Result<Command> {
162        let bytes = serde_json::to_vec(event)?;
163        let (ptr, len) = self.write(&bytes)?;
164        let desc = self.step.call(&mut self.store, (ptr, len))?;
165        Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
166    }
167
168    /// Ask the guest whether generation should continue.
169    fn call_on_token(&mut self, token: &str) -> anyhow::Result<bool> {
170        // Cloned rather than moved: wasmtime's TypedFunc is Clone but not Copy,
171        // and cloning also ends the borrow of `self` before `write` needs it
172        // mutably.
173        let Some(f) = self.on_token.clone() else {
174            return Ok(true);
175        };
176        let (ptr, len) = self.write(token.as_bytes())?;
177        Ok(f.call(&mut self.store, (ptr, len))? == 0)
178    }
179}
180
181fn fail(code: &str, message: impl Into<String>, usage: Usage) -> Envelope {
182    Envelope {
183        status: JobStatus::Failed,
184        result: None,
185        error: Some(JobError {
186            code: code.into(),
187            message: message.into(),
188        }),
189        usage,
190    }
191}
192
193fn cancelled(usage: Usage, message: &str) -> Envelope {
194    Envelope {
195        status: JobStatus::Cancelled,
196        result: None,
197        error: Some(JobError {
198            code: error_codes::CANCELLED.into(),
199            message: message.into(),
200        }),
201        usage,
202    }
203}
204
205/// Drive one job to completion.
206///
207/// Always returns an [`Envelope`]; failures are values, not errors, because the
208/// caller has to report *something* to whoever submitted the job.
209pub async fn run_job(
210    engine: Arc<Engine>,
211    backend: Arc<dyn InferBackend>,
212    job: JobSpec,
213    events: mpsc::Sender<JobEvent>,
214    cancel: CancellationToken,
215) -> Envelope {
216    let started = Instant::now();
217    let mut usage = Usage {
218        model: backend.model_name(),
219        ..Usage::default()
220    };
221
222    // Dropped when this function returns, closing every file the job opened.
223    // That job-scoped lifetime is what makes handles unforgeable across jobs.
224    let mut handles = Handles::default();
225
226    let mut guest = match Guest::new(&engine, &job.module_bytes) {
227        Ok(g) => g,
228        Err(e) => return fail(error_codes::WASM_TRAP, e.to_string(), usage),
229    };
230
231    let mut command = match guest.call_init(&job.input) {
232        Ok(c) => c,
233        Err(e) => return fail(error_codes::WASM_TRAP, e.to_string(), usage),
234    };
235
236    loop {
237        if cancel.is_cancelled() {
238            usage.duration_ms = started.elapsed().as_millis() as u64;
239            return cancelled(usage, "job cancelled");
240        }
241
242        let event = match command {
243            Command::Done { result } => {
244                usage.duration_ms = started.elapsed().as_millis() as u64;
245                return Envelope {
246                    status: JobStatus::Completed,
247                    result: Some(result),
248                    error: None,
249                    usage,
250                };
251            }
252            Command::Fail { code, message } => {
253                usage.duration_ms = started.elapsed().as_millis() as u64;
254                return fail(&code, message, usage);
255            }
256            Command::Emit { progress } => {
257                let _ = events.send(JobEvent::Progress(progress)).await;
258                Event::Emitted
259            }
260
261            // The capability check lives here, at Open, and nowhere else. Slice
262            // takes a handle rather than a path, and handles are job-scoped, so
263            // there is no second place a path can enter the system.
264            Command::Open { path } => {
265                let p = std::path::PathBuf::from(&path);
266                if !job.caps.allows_read(&p) {
267                    usage.duration_ms = started.elapsed().as_millis() as u64;
268                    return fail(
269                        error_codes::CAPABILITY_DENIED,
270                        format!("read not permitted: {path}"),
271                        usage,
272                    );
273                }
274                match handles.open(&p) {
275                    Ok((handle, len)) => Event::Opened { handle, len },
276                    Err(e) => {
277                        usage.duration_ms = started.elapsed().as_millis() as u64;
278                        return fail(error_codes::CAPABILITY_DENIED, e.to_string(), usage);
279                    }
280                }
281            }
282
283            Command::Slice {
284                handle,
285                offset,
286                len,
287            } => match handles.slice(handle, offset, len) {
288                Ok(w) => Event::Sliced {
289                    text: w.text,
290                    next_offset: w.next_offset,
291                },
292                Err(e) => {
293                    usage.duration_ms = started.elapsed().as_millis() as u64;
294                    return fail(error_codes::CAPABILITY_DENIED, e.to_string(), usage);
295                }
296            },
297
298            Command::Infer { prompt, max_tokens } => {
299                // Tokens must reach the guest *while* generation runs, because
300                // the guest's Stop verdict is what ends it early. The wasmtime
301                // Store is !Sync and cannot be touched from inside the backend's
302                // callback, so a channel carries tokens out and a shared flag
303                // carries the verdict back — without sharing the Store.
304                let (tx, mut rx) = mpsc::unbounded_channel::<String>();
305                let stop = Arc::new(AtomicBool::new(false));
306                let sink_stop = stop.clone();
307                let mut sink = move |t: &str| {
308                    tx.send(t.to_string()).is_ok() && !sink_stop.load(Ordering::Relaxed)
309                };
310
311                let mut trap: Option<String> = None;
312                let outcome: Option<anyhow::Result<InferResult>> = {
313                    let infer = backend.infer(&prompt, max_tokens, &mut sink);
314                    tokio::pin!(infer);
315                    loop {
316                        tokio::select! {
317                            biased;
318                            _ = cancel.cancelled() => break None,
319                            Some(tok) = rx.recv() => {
320                                let _ = events.send(JobEvent::Token(tok.clone())).await;
321                                match guest.call_on_token(&tok) {
322                                    Ok(true) => {}
323                                    Ok(false) => stop.store(true, Ordering::Relaxed),
324                                    Err(e) => {
325                                        trap = Some(e.to_string());
326                                        break None;
327                                    }
328                                }
329                            }
330                            r = &mut infer => break Some(r),
331                        }
332                    }
333                };
334
335                if let Some(message) = trap {
336                    usage.duration_ms = started.elapsed().as_millis() as u64;
337                    return fail(error_codes::WASM_TRAP, message, usage);
338                }
339
340                // Tokens generated in the same poll as the last one are still
341                // queued; forward them so the stream is complete.
342                while let Ok(tok) = rx.try_recv() {
343                    let _ = events.send(JobEvent::Token(tok)).await;
344                }
345
346                match outcome {
347                    None => {
348                        usage.duration_ms = started.elapsed().as_millis() as u64;
349                        return cancelled(usage, "cancelled during inference");
350                    }
351                    Some(Err(e)) => {
352                        usage.duration_ms = started.elapsed().as_millis() as u64;
353                        return fail(error_codes::MODEL_LOAD_FAILED, e.to_string(), usage);
354                    }
355                    Some(Ok(r)) => {
356                        usage.tokens_in += r.tokens_in;
357                        usage.tokens_out += r.tokens_out;
358                        Event::InferDone {
359                            text: r.text,
360                            tokens_out: r.tokens_out,
361                        }
362                    }
363                }
364            }
365        };
366
367        command = match guest.call_step(&event) {
368            Ok(c) => c,
369            Err(e) => {
370                usage.duration_ms = started.elapsed().as_millis() as u64;
371                return fail(error_codes::WASM_TRAP, e.to_string(), usage);
372            }
373        };
374    }
375}