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, InferRequest, 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/// Width, in pixels, that document pages render to.
31///
32/// Vision models work from a fixed-size input anyway, and a larger raster costs
33/// encode time and tokens without adding detail the model can use.
34const RENDER_WIDTH: u16 = 1024;
35
36/// Something worth telling a watcher about while a job runs.
37#[derive(Debug, Clone)]
38pub enum JobEvent {
39    /// One generated token.
40    Token(String),
41    /// Guest-supplied progress.
42    Progress(serde_json::Value),
43}
44
45/// Everything needed to run one job.
46pub struct JobSpec {
47    /// The compiled guest module.
48    pub module_bytes: Vec<u8>,
49    /// The job's input, handed to the guest's `init`.
50    pub input: serde_json::Value,
51    /// What this job is permitted to reach.
52    pub caps: Capabilities,
53}
54
55/// Pointer width of a guest module, read from the module rather than assumed.
56///
57/// Only [`Abi::W32`] is supported today; 64-bit guests are rejected with a clear
58/// message. The enum exists anyway so that adding wasm64 later is a new arm plus
59/// a second set of [`TypedFunc`] signatures, rather than a hunt through this
60/// file for every place a pointer was assumed to be four bytes wide.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Abi {
63    /// 32-bit linear memory.
64    W32,
65    /// 64-bit linear memory (memory64).
66    W64,
67}
68
69impl Abi {
70    /// Size of one pointer-sized field, and so half a descriptor.
71    fn ptr_size(self) -> usize {
72        match self {
73            Abi::W32 => 4,
74            Abi::W64 => 8,
75        }
76    }
77}
78
79struct Guest {
80    store: Store<()>,
81    memory: Memory,
82    abi: Abi,
83    alloc: TypedFunc<u32, u32>,
84    init: TypedFunc<(u32, u32), u32>,
85    step: TypedFunc<(u32, u32), u32>,
86    on_token: Option<TypedFunc<(u32, u32), i32>>,
87}
88
89impl Guest {
90    fn new(engine: &Engine, module_bytes: &[u8]) -> anyhow::Result<Self> {
91        let module = Module::new(engine, module_bytes)?;
92
93        // An empty linker, deliberately. Guest blocks are built for
94        // `wasm32-unknown-unknown` and import nothing at all — a wasip1 guest
95        // would drag in `fd_write` and `proc_exit` through its panic path alone
96        // and fail to instantiate here.
97        let linker: Linker<()> = Linker::new(engine);
98        let mut store = Store::new(engine, ());
99        let instance: Instance = linker.instantiate(&mut store, &module)?;
100
101        let memory = instance
102            .get_memory(&mut store, "memory")
103            .ok_or_else(|| anyhow::anyhow!("guest exports no memory"))?;
104
105        // Width comes from the module itself. A 64-bit guest exports `cf_init`
106        // as `(i64, i64) -> i64`, so the typed lookups below would otherwise
107        // fail with a signature mismatch that says nothing about the real cause.
108        let abi = if memory.ty(&store).is_64() {
109            Abi::W64
110        } else {
111            Abi::W32
112        };
113        if abi == Abi::W64 {
114            anyhow::bail!("guest uses 64-bit memory; only 32-bit guests are supported");
115        }
116
117        Ok(Self {
118            alloc: instance.get_typed_func(&mut store, "cf_alloc")?,
119            init: instance.get_typed_func(&mut store, "cf_init")?,
120            step: instance.get_typed_func(&mut store, "cf_step")?,
121            // Optional: a block indifferent to streaming need not export it.
122            on_token: instance.get_typed_func(&mut store, "cf_on_token").ok(),
123            memory,
124            abi,
125            store,
126        })
127    }
128
129    fn write(&mut self, bytes: &[u8]) -> anyhow::Result<(u32, u32)> {
130        let len = bytes.len() as u32;
131        let ptr = self.alloc.call(&mut self.store, len)?;
132        self.memory.write(&mut self.store, ptr as usize, bytes)?;
133        Ok((ptr, len))
134    }
135
136    /// Read the descriptor the guest returned, then the payload it points at.
137    ///
138    /// Two reads rather than unpacking one integer — the cost of keeping these
139    /// signatures identical across pointer widths.
140    fn read_desc(&mut self, desc_ptr: u32) -> anyhow::Result<Vec<u8>> {
141        let w = self.abi.ptr_size();
142        let mut desc = vec![0u8; 2 * w];
143        self.memory
144            .read(&mut self.store, desc_ptr as usize, &mut desc)?;
145
146        let field = |bytes: &[u8]| -> u64 {
147            match w {
148                4 => u32::from_le_bytes(bytes.try_into().expect("4 bytes")) as u64,
149                _ => u64::from_le_bytes(bytes.try_into().expect("8 bytes")),
150            }
151        };
152        let ptr = field(&desc[..w]) as usize;
153        let len = field(&desc[w..]) as usize;
154
155        let mut buf = vec![0u8; len];
156        self.memory.read(&mut self.store, ptr, &mut buf)?;
157        Ok(buf)
158    }
159
160    fn call_init(&mut self, input: &serde_json::Value) -> anyhow::Result<Command> {
161        let bytes = serde_json::to_vec(input)?;
162        let (ptr, len) = self.write(&bytes)?;
163        let desc = self.init.call(&mut self.store, (ptr, len))?;
164        Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
165    }
166
167    fn call_step(&mut self, event: &Event) -> anyhow::Result<Command> {
168        let bytes = serde_json::to_vec(event)?;
169        let (ptr, len) = self.write(&bytes)?;
170        let desc = self.step.call(&mut self.store, (ptr, len))?;
171        Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
172    }
173
174    /// Ask the guest whether generation should continue.
175    fn call_on_token(&mut self, token: &str) -> anyhow::Result<bool> {
176        // Cloned rather than moved: wasmtime's TypedFunc is Clone but not Copy,
177        // and cloning also ends the borrow of `self` before `write` needs it
178        // mutably.
179        let Some(f) = self.on_token.clone() else {
180            return Ok(true);
181        };
182        let (ptr, len) = self.write(token.as_bytes())?;
183        Ok(f.call(&mut self.store, (ptr, len))? == 0)
184    }
185}
186
187fn fail(code: &str, message: impl Into<String>, usage: Usage) -> Envelope {
188    Envelope {
189        status: JobStatus::Failed,
190        result: None,
191        error: Some(JobError {
192            code: code.into(),
193            message: message.into(),
194        }),
195        usage,
196    }
197}
198
199fn cancelled(usage: Usage, message: &str) -> Envelope {
200    Envelope {
201        status: JobStatus::Cancelled,
202        result: None,
203        error: Some(JobError {
204            code: error_codes::CANCELLED.into(),
205            message: message.into(),
206        }),
207        usage,
208    }
209}
210
211/// Drive one job to completion.
212///
213/// Always returns an [`Envelope`]; failures are values, not errors, because the
214/// caller has to report *something* to whoever submitted the job.
215pub async fn run_job(
216    engine: Arc<Engine>,
217    backend: Arc<dyn InferBackend>,
218    job: JobSpec,
219    events: mpsc::Sender<JobEvent>,
220    cancel: CancellationToken,
221) -> Envelope {
222    let started = Instant::now();
223    let mut usage = Usage {
224        model: backend.model_name(),
225        ..Usage::default()
226    };
227
228    // Dropped when this function returns, closing every file the job opened.
229    // That job-scoped lifetime is what makes handles unforgeable across jobs.
230    let mut handles = Handles::default();
231    // Documents are read from their path rather than their descriptor — both
232    // extraction and rendering want a file. Kept beside the handle table so the
233    // two are dropped together at the end of the job.
234    let mut doc_paths: std::collections::HashMap<u32, std::path::PathBuf> =
235        std::collections::HashMap::new();
236
237    let mut guest = match Guest::new(&engine, &job.module_bytes) {
238        Ok(g) => g,
239        Err(e) => return fail(error_codes::WASM_TRAP, e.to_string(), usage),
240    };
241
242    let mut command = match guest.call_init(&job.input) {
243        Ok(c) => c,
244        Err(e) => return fail(error_codes::WASM_TRAP, e.to_string(), usage),
245    };
246
247    loop {
248        if cancel.is_cancelled() {
249            usage.duration_ms = started.elapsed().as_millis() as u64;
250            return cancelled(usage, "job cancelled");
251        }
252
253        let event = match command {
254            Command::Done { result } => {
255                usage.duration_ms = started.elapsed().as_millis() as u64;
256                return Envelope {
257                    status: JobStatus::Completed,
258                    result: Some(result),
259                    error: None,
260                    usage,
261                };
262            }
263            Command::Fail { code, message } => {
264                usage.duration_ms = started.elapsed().as_millis() as u64;
265                return fail(&code, message, usage);
266            }
267            Command::Emit { progress } => {
268                let _ = events.send(JobEvent::Progress(progress)).await;
269                Event::Emitted
270            }
271
272            // The capability check lives here, at Open, and nowhere else. Slice
273            // takes a handle rather than a path, and handles are job-scoped, so
274            // there is no second place a path can enter the system.
275            Command::Open { path } => {
276                let p = std::path::PathBuf::from(&path);
277                if !job.caps.allows_read(&p) {
278                    usage.duration_ms = started.elapsed().as_millis() as u64;
279                    return fail(
280                        error_codes::CAPABILITY_DENIED,
281                        format!("read not permitted: {path}"),
282                        usage,
283                    );
284                }
285                match handles.open(&p) {
286                    Ok((handle, len, kind)) => {
287                        // A PDF's page count and text layer need the whole file,
288                        // which the handle layer deliberately does not read. Ask
289                        // the document layer, and fall back to the plain kind if
290                        // it cannot answer — a malformed PDF is still a file a
291                        // block may want to read bytes from.
292                        let kind = match kind {
293                            cuttlefish_abi::MediaKind::Document { .. } => {
294                                match crate::documents::inspect(&p) {
295                                    Ok(info) => cuttlefish_abi::MediaKind::Document {
296                                        pages: info.pages,
297                                        has_text_layer: info.has_text_layer,
298                                    },
299                                    Err(_) => cuttlefish_abi::MediaKind::Binary,
300                                }
301                            }
302                            other => other,
303                        };
304                        // Remember the path: rendering and text extraction work
305                        // from a file, not from the open descriptor.
306                        doc_paths.insert(handle, p.clone());
307                        Event::Opened { handle, len, kind }
308                    }
309                    Err(e) => {
310                        usage.duration_ms = started.elapsed().as_millis() as u64;
311                        return fail(error_codes::CAPABILITY_DENIED, e.to_string(), usage);
312                    }
313                }
314            }
315
316            Command::Slice {
317                handle,
318                offset,
319                len,
320            } => match handles.slice(handle, offset, len) {
321                Ok(w) => Event::Sliced {
322                    text: w.text,
323                    next_offset: w.next_offset,
324                },
325                Err(e) => {
326                    usage.duration_ms = started.elapsed().as_millis() as u64;
327                    return fail(error_codes::CAPABILITY_DENIED, e.to_string(), usage);
328                }
329            },
330
331            Command::SliceBytes {
332                handle,
333                offset,
334                len,
335            } => match handles.slice_bytes(handle, offset, len) {
336                Ok((bytes, next_offset)) => {
337                    use base64::Engine;
338                    Event::SlicedBytes {
339                        bytes_base64: base64::engine::general_purpose::STANDARD.encode(&bytes),
340                        next_offset,
341                    }
342                }
343                Err(e) => {
344                    usage.duration_ms = started.elapsed().as_millis() as u64;
345                    return fail(error_codes::CAPABILITY_DENIED, e.to_string(), usage);
346                }
347            },
348
349            Command::PageText { handle, page } => {
350                let Some(path) = doc_paths.get(&handle).cloned() else {
351                    usage.duration_ms = started.elapsed().as_millis() as u64;
352                    return fail(
353                        error_codes::CAPABILITY_DENIED,
354                        format!("no such handle: {handle}"),
355                        usage,
356                    );
357                };
358                match crate::documents::page_text(&path, page) {
359                    Ok(text) => Event::PageTexted { text },
360                    Err(e) => {
361                        usage.duration_ms = started.elapsed().as_millis() as u64;
362                        return fail(error_codes::UNSUPPORTED, e.to_string(), usage);
363                    }
364                }
365            }
366
367            Command::PageImage { handle, page } => {
368                let Some(path) = doc_paths.get(&handle).cloned() else {
369                    usage.duration_ms = started.elapsed().as_millis() as u64;
370                    return fail(
371                        error_codes::CAPABILITY_DENIED,
372                        format!("no such handle: {handle}"),
373                        usage,
374                    );
375                };
376                // A rendered page becomes a handle like any other, so it can be
377                // named in Infer exactly as a file-backed image would be.
378                match crate::documents::render_page(&path, page, RENDER_WIDTH) {
379                    Ok(png) => {
380                        let (handle, len) = handles.insert_bytes(
381                            png,
382                            cuttlefish_abi::MediaKind::Image {
383                                format: "png".into(),
384                            },
385                        );
386                        Event::PageImaged { handle, len }
387                    }
388                    Err(e) => {
389                        usage.duration_ms = started.elapsed().as_millis() as u64;
390                        return fail(error_codes::UNSUPPORTED, e.to_string(), usage);
391                    }
392                }
393            }
394
395            Command::Infer {
396                prompt,
397                max_tokens,
398                images,
399            } => {
400                // Images are named by handle; the host loads the bytes, so they
401                // never pass through guest memory.
402                // Refuse rather than drop. Sending images to a backend that
403                // cannot use them produces a confident answer about nothing,
404                // which reads as a bad model rather than a misconfigured job —
405                // and the caller has no way to tell the difference.
406                if !images.is_empty() && !backend.supports_images() {
407                    usage.duration_ms = started.elapsed().as_millis() as u64;
408                    return fail(
409                        error_codes::UNSUPPORTED,
410                        format!(
411                            "this job supplied {} image(s), but the backend serving `{}` cannot \
412                             accept them. Use a vision-capable model through the `ollama` \
413                             provider, or change the block to send text only.",
414                            images.len(),
415                            backend.model_name()
416                        ),
417                        usage,
418                    );
419                }
420                let mut image_bytes = Vec::with_capacity(images.len());
421                for handle in &images {
422                    match handles.read_all(*handle) {
423                        Ok(bytes) => image_bytes.push(bytes),
424                        Err(e) => {
425                            usage.duration_ms = started.elapsed().as_millis() as u64;
426                            return fail(error_codes::CAPABILITY_DENIED, e.to_string(), usage);
427                        }
428                    }
429                }
430
431                // Tokens must reach the guest *while* generation runs, because
432                // the guest's Stop verdict is what ends it early. The wasmtime
433                // Store is !Sync and cannot be touched from inside the backend's
434                // callback, so a channel carries tokens out and a shared flag
435                // carries the verdict back — without sharing the Store.
436                let (tx, mut rx) = mpsc::unbounded_channel::<String>();
437                let stop = Arc::new(AtomicBool::new(false));
438                let sink_stop = stop.clone();
439                let mut sink = move |t: &str| {
440                    tx.send(t.to_string()).is_ok() && !sink_stop.load(Ordering::Relaxed)
441                };
442
443                let mut trap: Option<String> = None;
444                let outcome: Option<anyhow::Result<InferResult>> = {
445                    let request = InferRequest {
446                        prompt: &prompt,
447                        max_tokens,
448                        images: &image_bytes,
449                    };
450                    let infer = backend.infer(request, &mut sink);
451                    tokio::pin!(infer);
452                    loop {
453                        tokio::select! {
454                            biased;
455                            _ = cancel.cancelled() => break None,
456                            Some(tok) = rx.recv() => {
457                                let _ = events.send(JobEvent::Token(tok.clone())).await;
458                                match guest.call_on_token(&tok) {
459                                    Ok(true) => {}
460                                    Ok(false) => stop.store(true, Ordering::Relaxed),
461                                    Err(e) => {
462                                        trap = Some(e.to_string());
463                                        break None;
464                                    }
465                                }
466                            }
467                            r = &mut infer => break Some(r),
468                        }
469                    }
470                };
471
472                if let Some(message) = trap {
473                    usage.duration_ms = started.elapsed().as_millis() as u64;
474                    return fail(error_codes::WASM_TRAP, message, usage);
475                }
476
477                // Tokens generated in the same poll as the last one are still
478                // queued; forward them so the stream is complete.
479                while let Ok(tok) = rx.try_recv() {
480                    let _ = events.send(JobEvent::Token(tok)).await;
481                }
482
483                match outcome {
484                    None => {
485                        usage.duration_ms = started.elapsed().as_millis() as u64;
486                        return cancelled(usage, "cancelled during inference");
487                    }
488                    Some(Err(e)) => {
489                        usage.duration_ms = started.elapsed().as_millis() as u64;
490                        return fail(error_codes::MODEL_LOAD_FAILED, e.to_string(), usage);
491                    }
492                    Some(Ok(r)) => {
493                        usage.tokens_in += r.tokens_in;
494                        usage.tokens_out += r.tokens_out;
495                        Event::InferDone {
496                            text: r.text,
497                            tokens_out: r.tokens_out,
498                        }
499                    }
500                }
501            }
502        };
503
504        command = match guest.call_step(&event) {
505            Ok(c) => c,
506            Err(e) => {
507                usage.duration_ms = started.elapsed().as_millis() as u64;
508                return fail(error_codes::WASM_TRAP, e.to_string(), usage);
509            }
510        };
511    }
512}