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 checked graph, in topological order — safe to execute
48 /// front-to-back, threading `outputs` forward.
49 pub nodes: Vec<crate::dag::CheckedNode>,
50 /// Which nodes are exclusive to which branch decision+label — see
51 /// `crate::dag::BranchExclusivity`.
52 pub exclusive_to: std::collections::HashMap<String, crate::dag::BranchExclusivity>,
53 /// The job's input, handed to every entry node (a node with no `input`
54 /// expression — `node.input.is_none()`).
55 pub input: serde_json::Value,
56 /// What this job is permitted to reach.
57 pub caps: Capabilities,
58}
59
60/// Pointer width of a guest module, read from the module rather than assumed.
61///
62/// Only [`Abi::W32`] is supported today; 64-bit guests are rejected with a clear
63/// message. The enum exists anyway so that adding wasm64 later is a new arm plus
64/// a second set of [`TypedFunc`] signatures, rather than a hunt through this
65/// file for every place a pointer was assumed to be four bytes wide.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Abi {
68 /// 32-bit linear memory.
69 W32,
70 /// 64-bit linear memory (memory64).
71 W64,
72}
73
74impl Abi {
75 /// Size of one pointer-sized field, and so half a descriptor.
76 fn ptr_size(self) -> usize {
77 match self {
78 Abi::W32 => 4,
79 Abi::W64 => 8,
80 }
81 }
82}
83
84struct Guest {
85 store: Store<()>,
86 memory: Memory,
87 abi: Abi,
88 alloc: TypedFunc<u32, u32>,
89 init: TypedFunc<(u32, u32), u32>,
90 step: TypedFunc<(u32, u32), u32>,
91 on_token: Option<TypedFunc<(u32, u32), i32>>,
92}
93
94impl Guest {
95 fn new(engine: &Engine, module_bytes: &[u8]) -> anyhow::Result<Self> {
96 let module = Module::new(engine, module_bytes)?;
97
98 // An empty linker, deliberately. Guest blocks are built for
99 // `wasm32-unknown-unknown` and import nothing at all — a wasip1 guest
100 // would drag in `fd_write` and `proc_exit` through its panic path alone
101 // and fail to instantiate here.
102 let linker: Linker<()> = Linker::new(engine);
103 let mut store = Store::new(engine, ());
104 let instance: Instance = linker.instantiate(&mut store, &module)?;
105
106 let memory = instance
107 .get_memory(&mut store, "memory")
108 .ok_or_else(|| anyhow::anyhow!("guest exports no memory"))?;
109
110 // Width comes from the module itself. A 64-bit guest exports `cf_init`
111 // as `(i64, i64) -> i64`, so the typed lookups below would otherwise
112 // fail with a signature mismatch that says nothing about the real cause.
113 let abi = if memory.ty(&store).is_64() {
114 Abi::W64
115 } else {
116 Abi::W32
117 };
118 if abi == Abi::W64 {
119 anyhow::bail!("guest uses 64-bit memory; only 32-bit guests are supported");
120 }
121
122 Ok(Self {
123 alloc: instance.get_typed_func(&mut store, "cf_alloc")?,
124 init: instance.get_typed_func(&mut store, "cf_init")?,
125 step: instance.get_typed_func(&mut store, "cf_step")?,
126 // Optional: a block indifferent to streaming need not export it.
127 on_token: instance.get_typed_func(&mut store, "cf_on_token").ok(),
128 memory,
129 abi,
130 store,
131 })
132 }
133
134 fn write(&mut self, bytes: &[u8]) -> anyhow::Result<(u32, u32)> {
135 let len = bytes.len() as u32;
136 let ptr = self.alloc.call(&mut self.store, len)?;
137 self.memory.write(&mut self.store, ptr as usize, bytes)?;
138 Ok((ptr, len))
139 }
140
141 /// Read the descriptor the guest returned, then the payload it points at.
142 ///
143 /// Two reads rather than unpacking one integer — the cost of keeping these
144 /// signatures identical across pointer widths.
145 fn read_desc(&mut self, desc_ptr: u32) -> anyhow::Result<Vec<u8>> {
146 let w = self.abi.ptr_size();
147 let mut desc = vec![0u8; 2 * w];
148 self.memory
149 .read(&mut self.store, desc_ptr as usize, &mut desc)?;
150
151 let field = |bytes: &[u8]| -> u64 {
152 match w {
153 4 => u32::from_le_bytes(bytes.try_into().expect("4 bytes")) as u64,
154 _ => u64::from_le_bytes(bytes.try_into().expect("8 bytes")),
155 }
156 };
157 let ptr = field(&desc[..w]) as usize;
158 let len = field(&desc[w..]) as usize;
159
160 let mut buf = vec![0u8; len];
161 self.memory.read(&mut self.store, ptr, &mut buf)?;
162 Ok(buf)
163 }
164
165 fn call_init(&mut self, input: &serde_json::Value) -> anyhow::Result<Command> {
166 let bytes = serde_json::to_vec(input)?;
167 let (ptr, len) = self.write(&bytes)?;
168 let desc = self.init.call(&mut self.store, (ptr, len))?;
169 Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
170 }
171
172 fn call_step(&mut self, event: &Event) -> anyhow::Result<Command> {
173 let bytes = serde_json::to_vec(event)?;
174 let (ptr, len) = self.write(&bytes)?;
175 let desc = self.step.call(&mut self.store, (ptr, len))?;
176 Ok(serde_json::from_slice(&self.read_desc(desc)?)?)
177 }
178
179 /// Ask the guest whether generation should continue.
180 fn call_on_token(&mut self, token: &str) -> anyhow::Result<bool> {
181 // Cloned rather than moved: wasmtime's TypedFunc is Clone but not Copy,
182 // and cloning also ends the borrow of `self` before `write` needs it
183 // mutably.
184 let Some(f) = self.on_token.clone() else {
185 return Ok(true);
186 };
187 let (ptr, len) = self.write(token.as_bytes())?;
188 Ok(f.call(&mut self.store, (ptr, len))? == 0)
189 }
190}
191
192/// Read a block's declared signature out of a compiled module.
193///
194/// Instantiates the module and calls its `cf_signature` export. That is heavier
195/// than parsing a sidecar file, and it is the point: the answer comes from the
196/// artifact that will actually run, so it cannot describe a different version of
197/// the block than the one being checked.
198///
199/// A block built before signatures existed has no such export. That is not an
200/// error — it reports the permissive default, so an older block still composes,
201/// just without the seam being checked.
202pub fn read_signature(
203 engine: &Engine,
204 module_bytes: &[u8],
205) -> anyhow::Result<cuttlefish_abi::Signature> {
206 let permissive = cuttlefish_abi::Signature {
207 input: cuttlefish_abi::Ty::Json,
208 output: cuttlefish_abi::Ty::Json,
209 };
210
211 // Deliberately does not go through `Guest`, which requires the whole reactor
212 // — alloc, init, step. Reading a declaration should not demand that a module
213 // be runnable: a block missing an export has a real problem, but it is one
214 // worth reporting when the job runs, with the job's error handling, rather
215 // than as a confusing failure during a typecheck.
216 let module = Module::new(engine, module_bytes)?;
217 let linker: Linker<()> = Linker::new(engine);
218 let mut store = Store::new(engine, ());
219 let instance = linker.instantiate(&mut store, &module)?;
220
221 let Ok(signature) = instance.get_typed_func::<(), u32>(&mut store, "cf_signature") else {
222 return Ok(permissive);
223 };
224 let Some(memory) = instance.get_memory(&mut store, "memory") else {
225 return Ok(permissive);
226 };
227
228 let desc_ptr = signature.call(&mut store, ())? as usize;
229 let mut desc = [0u8; 8];
230 memory.read(&mut store, desc_ptr, &mut desc)?;
231 let ptr = u32::from_le_bytes(desc[..4].try_into().expect("4 bytes")) as usize;
232 let len = u32::from_le_bytes(desc[4..].try_into().expect("4 bytes")) as usize;
233
234 let mut buf = vec![0u8; len];
235 memory.read(&mut store, ptr, &mut buf)?;
236 Ok(serde_json::from_slice(&buf)?)
237}
238
239fn fail(code: &str, message: impl Into<String>, usage: Usage) -> Envelope {
240 Envelope {
241 status: JobStatus::Failed,
242 result: None,
243 error: Some(JobError {
244 code: code.into(),
245 message: message.into(),
246 }),
247 usage,
248 }
249}
250
251/// Whether an `InputExpr` (transitively) references any node in `skipped`.
252fn references_any(
253 expr: &cuttlefish_core::graph::InputExpr,
254 skipped: &std::collections::HashSet<String>,
255) -> bool {
256 use cuttlefish_core::graph::InputExpr;
257 match expr {
258 InputExpr::FromNode(n) => skipped.contains(n),
259 InputExpr::Record(fields) => fields.values().any(|e| references_any(e, skipped)),
260 InputExpr::List(items) => items.iter().any(|e| references_any(e, skipped)),
261 }
262}
263
264/// Compose an `InputExpr` into an actual JSON value, by looking up each
265/// referenced node's already-produced output. Mirrors `dag::evaluate_expr_ty`
266/// (which does the same composition at the *type* level, at check time) —
267/// this is the runtime analogue.
268fn evaluate_input(
269 expr: &cuttlefish_core::graph::InputExpr,
270 outputs: &std::collections::HashMap<String, serde_json::Value>,
271) -> serde_json::Value {
272 use cuttlefish_core::graph::InputExpr;
273 match expr {
274 InputExpr::FromNode(n) => outputs.get(n).cloned().unwrap_or(serde_json::Value::Null),
275 InputExpr::Record(fields) => {
276 let mut map = serde_json::Map::new();
277 for (k, v) in fields {
278 map.insert(k.clone(), evaluate_input(v, outputs));
279 }
280 serde_json::Value::Object(map)
281 }
282 InputExpr::List(items) => {
283 serde_json::Value::Array(items.iter().map(|e| evaluate_input(e, outputs)).collect())
284 }
285 }
286}
287
288fn cancelled(usage: Usage, message: &str) -> Envelope {
289 Envelope {
290 status: JobStatus::Cancelled,
291 result: None,
292 error: Some(JobError {
293 code: error_codes::CANCELLED.into(),
294 message: message.into(),
295 }),
296 usage,
297 }
298}
299
300/// Drive one job to completion.
301///
302/// Always returns an [`Envelope`]; failures are values, not errors, because the
303/// caller has to report *something* to whoever submitted the job.
304///
305/// `ledger` is consulted before any resume-sensitive decision (branch-skip,
306/// transitive-skip, or actually running a node) and written to immediately
307/// after that decision is made, so that a process restart mid-job — a later
308/// task's concern, not this function's — can resume from exactly the state
309/// this function left behind. The whole body runs inside a single labeled
310/// block (`'run: { ... }`) so that every exit path, however it got there,
311/// still reaches the one `ledger.finish(...)` call at the end.
312pub async fn run_job(
313 engine: Arc<Engine>,
314 backend: Arc<dyn InferBackend>,
315 job: JobSpec,
316 events: mpsc::Sender<JobEvent>,
317 cancel: CancellationToken,
318 ledger: &crate::ledger::Ledger,
319) -> Envelope {
320 let started = Instant::now();
321 let mut usage = Usage {
322 model: backend.model_name(),
323 ..Usage::default()
324 };
325
326 // Dropped when this function returns, closing every file the job opened.
327 // That job-scoped lifetime is what makes handles unforgeable across jobs.
328 //
329 // Shared across stages on purpose: a handle produced by one block — a
330 // rendered page, say — stays usable by the next. Confining it to one stage
331 // would make a pipeline strictly weaker than a single block that did the
332 // same work, while adding nothing, since the job boundary is what the
333 // security property rests on.
334 let mut handles = Handles::default();
335
336 let envelope = 'run: {
337 if job.nodes.is_empty() {
338 usage.duration_ms = started.elapsed().as_millis() as u64;
339 break 'run fail(
340 error_codes::SCHEMA_VALIDATION_FAILED,
341 "this job has no nodes to run",
342 usage,
343 );
344 }
345
346 let total = job.nodes.len();
347 let mut outputs: std::collections::HashMap<String, serde_json::Value> =
348 std::collections::HashMap::new();
349 let mut skipped: std::collections::HashSet<String> = std::collections::HashSet::new();
350 let mut route_taken: std::collections::HashMap<String, String> =
351 std::collections::HashMap::new();
352
353 for (index, node) in job.nodes.iter().enumerate() {
354 // Resume: a node the ledger already marked skipped stays skipped
355 // — its branch decision is not re-evaluated.
356 match ledger.is_skipped(&node.name) {
357 Ok(true) => {
358 skipped.insert(node.name.clone());
359 continue;
360 }
361 Ok(false) => {}
362 Err(e) => {
363 usage.duration_ms = started.elapsed().as_millis() as u64;
364 break 'run fail(
365 error_codes::SCHEMA_VALIDATION_FAILED,
366 format!("reading ledger skip state for node `{}`: {e}", node.name),
367 usage,
368 );
369 }
370 }
371
372 // Resume: a completed checkpoint means reuse the cached output
373 // instead of re-running.
374 match ledger.get_completed(&node.name) {
375 Ok(Some(cached)) => {
376 // If this was a branches decision node, its route must be
377 // recorded in `route_taken` here too — not just after a
378 // fresh `run_stage` call below — or a downstream,
379 // not-yet-reached branch-exclusive node would see no
380 // recorded decision at all on a resumed run and
381 // (incorrectly) never be skipped. The checkpoint only
382 // ever holds a value that already passed this same route
383 // validation the first time it ran, so this is purely
384 // re-deriving `route_taken`, never re-validating.
385 if let Some(route) = cached.get("route").and_then(|v| v.as_str()) {
386 route_taken.insert(node.name.clone(), route.to_string());
387 }
388 outputs.insert(node.name.clone(), cached);
389 continue;
390 }
391 Ok(None) => {}
392 Err(e) => {
393 usage.duration_ms = started.elapsed().as_millis() as u64;
394 break 'run fail(
395 error_codes::SCHEMA_VALIDATION_FAILED,
396 format!("reading ledger checkpoint for node `{}`: {e}", node.name),
397 usage,
398 );
399 }
400 }
401
402 // Tell watchers which node is running. A pipeline that stalls is
403 // much easier to diagnose when the stream says where.
404 if total > 1 {
405 let _ = events
406 .send(JobEvent::Progress(serde_json::json!({
407 "stage": index + 1,
408 "of": total,
409 "node": node.name,
410 })))
411 .await;
412 }
413
414 // Fresh branch-skip decision (only reached if the ledger had no
415 // recorded state for this node — i.e. this is either a fresh
416 // run, or a resumed run that hadn't reached this node yet).
417 //
418 // Branch-skip: this node is exclusive to a decision+label, and
419 // that decision's chosen route (already recorded earlier in this
420 // same loop, since the branching node is always topologically
421 // before the nodes exclusive to its labels) doesn't match.
422 if let Some(ex) = job.exclusive_to.get(&node.name) {
423 if let Some(taken) = route_taken.get(&ex.decision) {
424 if taken != &ex.label {
425 skipped.insert(node.name.clone());
426 if let Err(e) = ledger.write_skipped(&node.name) {
427 usage.duration_ms = started.elapsed().as_millis() as u64;
428 break 'run fail(
429 error_codes::SCHEMA_VALIDATION_FAILED,
430 format!("recording skip for node `{}` in ledger: {e}", node.name),
431 usage,
432 );
433 }
434 continue;
435 }
436 }
437 }
438 // Transitive skip: this node's input needs a skipped node's
439 // output.
440 if let Some(expr) = &node.input {
441 if references_any(expr, &skipped) {
442 skipped.insert(node.name.clone());
443 if let Err(e) = ledger.write_skipped(&node.name) {
444 usage.duration_ms = started.elapsed().as_millis() as u64;
445 break 'run fail(
446 error_codes::SCHEMA_VALIDATION_FAILED,
447 format!("recording skip for node `{}` in ledger: {e}", node.name),
448 usage,
449 );
450 }
451 continue;
452 }
453 }
454
455 let node_input = match &node.input {
456 None => job.input.clone(),
457 Some(expr) => evaluate_input(expr, &outputs),
458 };
459
460 // Bounded loop: repeat_until re-invokes this node on its own
461 // prior output, up to max_iterations. A node without
462 // repeat_until runs exactly once (the loop's `None` arm breaks
463 // immediately).
464 let mut current_input = node_input;
465 let mut iterations: u32 = 0;
466 let result = loop {
467 let r = match run_stage(
468 &engine,
469 &backend,
470 &node.module_bytes,
471 current_input.clone(),
472 &job.caps,
473 &mut handles,
474 &events,
475 &cancel,
476 &mut usage,
477 started,
478 index,
479 )
480 .await
481 {
482 Ok(v) => v,
483 Err(envelope) => break 'run envelope,
484 };
485 match &node.repeat_until {
486 None => break r,
487 Some(field) => {
488 iterations += 1;
489 let done = r.get(field).and_then(|v| v.as_str()) == Some("done");
490 if done {
491 break r;
492 }
493 let max = node
494 .max_iterations
495 .expect("repeat_until requires max_iterations, enforced at parse time");
496 if iterations >= max {
497 usage.duration_ms = started.elapsed().as_millis() as u64;
498 break 'run fail(
499 error_codes::SCHEMA_VALIDATION_FAILED,
500 format!(
501 "node `{}` did not reach repeat_until=\"done\" within max_iterations={max}",
502 node.name
503 ),
504 usage,
505 );
506 }
507 current_input = r;
508 }
509 }
510 };
511
512 // A branching node: read its `route` field and record the
513 // decision for later nodes' branch-skip check (top of this
514 // loop).
515 //
516 // Whether this node is actually a `branches` decision at all is
517 // determined from `job.exclusive_to` rather than by threading
518 // `Branches` itself into `JobSpec`: `dag::compute_branch_exclusivity`
519 // seeds an entry for every `(label, target)` pair of every declared
520 // decision, so the set of `label`s across all `exclusive_to` entries
521 // whose `decision` names this node IS exactly this decision's full,
522 // valid label set. An unmatched or missing `route` on a genuine
523 // decision node is a job failure — loud, not a silent no-op — per
524 // the design spec's "Conditional dispatch" section.
525 let valid_labels: Vec<&str> = job
526 .exclusive_to
527 .values()
528 .filter(|ex| ex.decision == node.name)
529 .map(|ex| ex.label.as_str())
530 .collect();
531 if !valid_labels.is_empty() {
532 match result.get("route").and_then(|v| v.as_str()) {
533 Some(route) if valid_labels.contains(&route) => {
534 route_taken.insert(node.name.clone(), route.to_string());
535 }
536 Some(route) => {
537 usage.duration_ms = started.elapsed().as_millis() as u64;
538 break 'run fail(
539 error_codes::SCHEMA_VALIDATION_FAILED,
540 format!(
541 "node `{}` produced route \"{route}\", which doesn't match any \
542 declared label for this branches decision",
543 node.name
544 ),
545 usage,
546 );
547 }
548 None => {
549 usage.duration_ms = started.elapsed().as_millis() as u64;
550 break 'run fail(
551 error_codes::SCHEMA_VALIDATION_FAILED,
552 format!(
553 "node `{}` is a branches decision but its output has no `route` field",
554 node.name
555 ),
556 usage,
557 );
558 }
559 }
560 } else if let Some(route) = result.get("route").and_then(|v| v.as_str()) {
561 // Not a declared decision node, but its output happens to
562 // carry a route field anyway — harmless, record it
563 // defensively (no downstream node's exclusive_to can
564 // reference this decision name if it isn't a real decision,
565 // so this is inert either way, just avoids silently
566 // dropping information that happens to be present).
567 route_taken.insert(node.name.clone(), route.to_string());
568 }
569
570 // Checkpoint a genuinely-executed node's result before recording
571 // it in `outputs` — the ledger write must happen before the
572 // in-memory outputs map update so a crash between the two can't
573 // leave the ledger silently behind the in-memory state (the
574 // in-memory state doesn't survive a crash anyway, so
575 // ledger-first is the only order that matters for durability;
576 // the reverse order would just be a smaller window for the same
577 // underlying property, not correctness).
578 if let Err(e) = ledger.write_completed(&node.name, &result) {
579 usage.duration_ms = started.elapsed().as_millis() as u64;
580 break 'run fail(
581 error_codes::SCHEMA_VALIDATION_FAILED,
582 format!(
583 "recording checkpoint for node `{}` in ledger: {e}",
584 node.name
585 ),
586 usage,
587 );
588 }
589 outputs.insert(node.name.clone(), result);
590 }
591
592 usage.duration_ms = started.elapsed().as_millis() as u64;
593
594 // What is "the" job result for a graph? Convention: the output of the
595 // LAST node in topological order that wasn't skipped. This exactly
596 // matches today's linear-pipeline behavior in the degenerate case (a
597 // linear chain's last node in topo order is its sole sink), and
598 // generalizes sensibly to a branching graph (exactly one path executes,
599 // so there's still a well-defined "last node that actually ran") and to
600 // a fan-in graph without branches (the join/sink node is last in topo
601 // order, since nothing depends on it).
602 //
603 // Known limitation: for a graph with two or more independent sinks (no
604 // edge between them at all — `cuttlefishd`'s daemon path genuinely
605 // accepts such graphs, unlike `cuttlefish build`, which restricts itself
606 // to linear graphs), "last in topological order" is decided by the
607 // topological sort's tie-break (alphabetical node name among ready
608 // nodes), not by any deliberate semantic answer about which sink's
609 // output should represent the job. Don't mistake that tie-break for a
610 // considered multi-sink policy — it isn't one.
611 let result = job
612 .nodes
613 .iter()
614 .rev()
615 .find_map(|n| outputs.get(&n.name).cloned());
616
617 match result {
618 Some(value) => Envelope {
619 status: JobStatus::Completed,
620 result: Some(value),
621 error: None,
622 usage,
623 },
624 None => fail(
625 error_codes::SCHEMA_VALIDATION_FAILED,
626 "every node in this job was skipped; there is no result",
627 usage,
628 ),
629 }
630 };
631
632 let ledger_status = match envelope.status {
633 JobStatus::Completed => "completed",
634 JobStatus::Failed => "failed",
635 JobStatus::Cancelled => "cancelled",
636 _ => "running", // shouldn't occur — defensive only
637 };
638 if let Err(e) = ledger.finish(ledger_status) {
639 // A ledger write failure at this final step doesn't invalidate the
640 // job's already-computed, correct result — losing it here means a
641 // wrong Interrupted-detection on next restart, not a wrong result
642 // now. Loud enough to notice, not loud enough to discard real work
643 // that already succeeded or failed for its own, unrelated reason.
644 eprintln!("warning: failed to record job {ledger_status} status in ledger: {e}");
645 }
646 envelope
647}
648
649/// Run one block to completion, returning what it produced.
650///
651/// `Err` carries a finished [`Envelope`]: a stage that fails ends the whole job,
652/// because a later stage's input is the earlier one's output and there is
653/// nothing sensible to feed it.
654#[allow(clippy::too_many_arguments)]
655async fn run_stage(
656 engine: &Engine,
657 backend: &Arc<dyn InferBackend>,
658 module_bytes: &[u8],
659 input: serde_json::Value,
660 caps: &Capabilities,
661 handles: &mut Handles,
662 events: &mpsc::Sender<JobEvent>,
663 cancel: &CancellationToken,
664 usage: &mut Usage,
665 started: Instant,
666 stage_index: usize,
667) -> Result<serde_json::Value, Envelope> {
668 // Naming the stage turns "the job failed" into "the second block failed",
669 // which is the difference between a usable error and a hunt.
670 let blame = |message: String| -> String {
671 if stage_index == 0 {
672 message
673 } else {
674 format!("block {} of the pipeline: {message}", stage_index + 1)
675 }
676 };
677 // Documents are read from their path rather than their descriptor — both
678 // extraction and rendering want a file. Kept beside the handle table so the
679 // two are dropped together at the end of the job.
680 let mut doc_paths: std::collections::HashMap<u32, std::path::PathBuf> =
681 std::collections::HashMap::new();
682
683 let mut guest = match Guest::new(engine, module_bytes) {
684 Ok(g) => g,
685 Err(e) => {
686 return Err(fail(
687 error_codes::WASM_TRAP,
688 blame(e.to_string()),
689 usage.clone(),
690 ))
691 }
692 };
693
694 let mut command = match guest.call_init(&input) {
695 Ok(c) => c,
696 Err(e) => {
697 return Err(fail(
698 error_codes::WASM_TRAP,
699 blame(e.to_string()),
700 usage.clone(),
701 ))
702 }
703 };
704
705 loop {
706 if cancel.is_cancelled() {
707 usage.duration_ms = started.elapsed().as_millis() as u64;
708 return Err(cancelled(usage.clone(), "job cancelled"));
709 }
710
711 let event = match command {
712 // Ends this stage, not the job: the value becomes the next
713 // block's input, or the job's result if this was the last.
714 Command::Done { result } => return Ok(result),
715 Command::Fail { code, message } => {
716 usage.duration_ms = started.elapsed().as_millis() as u64;
717 return Err(fail(&code, blame(message), usage.clone()));
718 }
719 Command::Emit { progress } => {
720 let _ = events.send(JobEvent::Progress(progress)).await;
721 Event::Emitted
722 }
723
724 // The capability check lives here, at Open, and nowhere else. Slice
725 // takes a handle rather than a path, and handles are job-scoped, so
726 // there is no second place a path can enter the system.
727 Command::Open { path } => {
728 let p = std::path::PathBuf::from(&path);
729 if !caps.allows_read(&p) {
730 usage.duration_ms = started.elapsed().as_millis() as u64;
731 return Err(fail(
732 error_codes::CAPABILITY_DENIED,
733 format!("read not permitted: {path}"),
734 usage.clone(),
735 ));
736 }
737 match handles.open(&p) {
738 Ok((handle, len, kind)) => {
739 // A PDF's page count and text layer need the whole file,
740 // which the handle layer deliberately does not read. Ask
741 // the document layer, and fall back to the plain kind if
742 // it cannot answer — a malformed PDF is still a file a
743 // block may want to read bytes from.
744 let kind = match kind {
745 cuttlefish_abi::MediaKind::Document { .. } => {
746 match crate::documents::inspect(&p) {
747 Ok(info) => cuttlefish_abi::MediaKind::Document {
748 pages: info.pages,
749 has_text_layer: info.has_text_layer,
750 },
751 Err(_) => cuttlefish_abi::MediaKind::Binary,
752 }
753 }
754 other => other,
755 };
756 // Remember the path: rendering and text extraction work
757 // from a file, not from the open descriptor.
758 doc_paths.insert(handle, p.clone());
759 Event::Opened { handle, len, kind }
760 }
761 Err(e) => {
762 usage.duration_ms = started.elapsed().as_millis() as u64;
763 return Err(fail(
764 error_codes::CAPABILITY_DENIED,
765 e.to_string(),
766 usage.clone(),
767 ));
768 }
769 }
770 }
771
772 Command::Slice {
773 handle,
774 offset,
775 len,
776 } => match handles.slice(handle, offset, len) {
777 Ok(w) => Event::Sliced {
778 text: w.text,
779 next_offset: w.next_offset,
780 },
781 Err(e) => {
782 usage.duration_ms = started.elapsed().as_millis() as u64;
783 return Err(fail(
784 error_codes::CAPABILITY_DENIED,
785 e.to_string(),
786 usage.clone(),
787 ));
788 }
789 },
790
791 Command::SliceBytes {
792 handle,
793 offset,
794 len,
795 } => match handles.slice_bytes(handle, offset, len) {
796 Ok((bytes, next_offset)) => {
797 use base64::Engine;
798 Event::SlicedBytes {
799 bytes_base64: base64::engine::general_purpose::STANDARD.encode(&bytes),
800 next_offset,
801 }
802 }
803 Err(e) => {
804 usage.duration_ms = started.elapsed().as_millis() as u64;
805 return Err(fail(
806 error_codes::CAPABILITY_DENIED,
807 e.to_string(),
808 usage.clone(),
809 ));
810 }
811 },
812
813 Command::PageText { handle, page } => {
814 let Some(path) = doc_paths.get(&handle).cloned() else {
815 usage.duration_ms = started.elapsed().as_millis() as u64;
816 return Err(fail(
817 error_codes::CAPABILITY_DENIED,
818 format!("no such handle: {handle}"),
819 usage.clone(),
820 ));
821 };
822 match crate::documents::page_text(&path, page) {
823 Ok(text) => Event::PageTexted { text },
824 Err(e) => {
825 usage.duration_ms = started.elapsed().as_millis() as u64;
826 return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
827 }
828 }
829 }
830
831 Command::PageImage { handle, page } => {
832 let Some(path) = doc_paths.get(&handle).cloned() else {
833 usage.duration_ms = started.elapsed().as_millis() as u64;
834 return Err(fail(
835 error_codes::CAPABILITY_DENIED,
836 format!("no such handle: {handle}"),
837 usage.clone(),
838 ));
839 };
840 // A rendered page becomes a handle like any other, so it can be
841 // named in Infer exactly as a file-backed image would be.
842 match crate::documents::render_page(&path, page, RENDER_WIDTH) {
843 Ok(png) => {
844 let (handle, len) = handles.insert_bytes(
845 png,
846 cuttlefish_abi::MediaKind::Image {
847 format: "png".into(),
848 },
849 );
850 Event::PageImaged { handle, len }
851 }
852 Err(e) => {
853 usage.duration_ms = started.elapsed().as_millis() as u64;
854 return Err(fail(error_codes::UNSUPPORTED, e.to_string(), usage.clone()));
855 }
856 }
857 }
858
859 Command::Infer {
860 prompt,
861 max_tokens,
862 images,
863 } => {
864 // Images are named by handle; the host loads the bytes, so they
865 // never pass through guest memory.
866 // Refuse rather than drop. Sending images to a backend that
867 // cannot use them produces a confident answer about nothing,
868 // which reads as a bad model rather than a misconfigured job —
869 // and the caller has no way to tell the difference.
870 if !images.is_empty() && !backend.supports_images() {
871 usage.duration_ms = started.elapsed().as_millis() as u64;
872 return Err(fail(
873 error_codes::UNSUPPORTED,
874 format!(
875 "this job supplied {} image(s), but the backend serving `{}` cannot \
876 accept them. Use a vision-capable model through the `ollama` \
877 provider, or change the block to send text only.",
878 images.len(),
879 backend.model_name()
880 ),
881 usage.clone(),
882 ));
883 }
884 let mut image_bytes = Vec::with_capacity(images.len());
885 for handle in &images {
886 match handles.read_all(*handle) {
887 Ok(bytes) => image_bytes.push(bytes),
888 Err(e) => {
889 usage.duration_ms = started.elapsed().as_millis() as u64;
890 return Err(fail(
891 error_codes::CAPABILITY_DENIED,
892 e.to_string(),
893 usage.clone(),
894 ));
895 }
896 }
897 }
898
899 // Tokens must reach the guest *while* generation runs, because
900 // the guest's Stop verdict is what ends it early. The wasmtime
901 // Store is !Sync and cannot be touched from inside the backend's
902 // callback, so a channel carries tokens out and a shared flag
903 // carries the verdict back — without sharing the Store.
904 let (tx, mut rx) = mpsc::unbounded_channel::<String>();
905 let stop = Arc::new(AtomicBool::new(false));
906 let sink_stop = stop.clone();
907 let mut sink = move |t: &str| {
908 tx.send(t.to_string()).is_ok() && !sink_stop.load(Ordering::Relaxed)
909 };
910
911 let mut trap: Option<String> = None;
912 let outcome: Option<anyhow::Result<InferResult>> = {
913 let request = InferRequest {
914 prompt: &prompt,
915 max_tokens,
916 images: &image_bytes,
917 };
918 let infer = backend.infer(request, &mut sink);
919 tokio::pin!(infer);
920 loop {
921 tokio::select! {
922 biased;
923 _ = cancel.cancelled() => break None,
924 Some(tok) = rx.recv() => {
925 let _ = events.send(JobEvent::Token(tok.clone())).await;
926 match guest.call_on_token(&tok) {
927 Ok(true) => {}
928 Ok(false) => stop.store(true, Ordering::Relaxed),
929 Err(e) => {
930 trap = Some(e.to_string());
931 break None;
932 }
933 }
934 }
935 r = &mut infer => break Some(r),
936 }
937 }
938 };
939
940 if let Some(message) = trap {
941 usage.duration_ms = started.elapsed().as_millis() as u64;
942 return Err(fail(error_codes::WASM_TRAP, message, usage.clone()));
943 }
944
945 // Tokens generated in the same poll as the last one are still
946 // queued; forward them so the stream is complete.
947 while let Ok(tok) = rx.try_recv() {
948 let _ = events.send(JobEvent::Token(tok)).await;
949 }
950
951 match outcome {
952 None => {
953 usage.duration_ms = started.elapsed().as_millis() as u64;
954 return Err(cancelled(usage.clone(), "cancelled during inference"));
955 }
956 Some(Err(e)) => {
957 usage.duration_ms = started.elapsed().as_millis() as u64;
958 return Err(fail(
959 error_codes::MODEL_LOAD_FAILED,
960 e.to_string(),
961 usage.clone(),
962 ));
963 }
964 Some(Ok(r)) => {
965 usage.tokens_in += r.tokens_in;
966 usage.tokens_out += r.tokens_out;
967 Event::InferDone {
968 text: r.text,
969 tokens_out: r.tokens_out,
970 }
971 }
972 }
973 }
974 };
975
976 command = match guest.call_step(&event) {
977 Ok(c) => c,
978 Err(e) => {
979 usage.duration_ms = started.elapsed().as_millis() as u64;
980 return Err(fail(
981 error_codes::WASM_TRAP,
982 blame(e.to_string()),
983 usage.clone(),
984 ));
985 }
986 };
987 }
988}