Skip to main content

oxide_browser/
worker.rs

1//! Background WebAssembly workers for Oxide guest modules.
2//!
3//! A worker is a separate `.wasm` module instantiated on its own OS thread with
4//! its own Wasmtime [`Store`], fuel budget, and linear memory. It never shares
5//! Rust types or memory with the spawning guest — communication is pure message
6//! passing of byte slices, mirroring the rest of the FFI boundary.
7//!
8//! The spawning ("parent") guest calls:
9//! - `api_spawn_worker(url)` → handle
10//! - `api_worker_post_message(handle, bytes)` — parent → worker inbox
11//! - `api_worker_recv(handle)` — drain one message from the worker's outbox
12//! - `api_worker_terminate(handle)`
13//!
14//! The worker module exports `start_app()` (run once on spawn) and optionally
15//! `on_message(len)` (run for each inbound message). Inside `on_message` it reads
16//! the payload with `api_worker_message_read` and replies with `api_worker_post`.
17//!
18//! All of this is driven by polling from the guest's frame loop, matching the
19//! WebSocket / fetch / RTC subsystems.
20
21use std::collections::{HashMap, VecDeque};
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::mpsc::{Receiver, Sender};
24use std::sync::{Arc, Mutex};
25use std::time::Duration;
26
27use anyhow::Result;
28use wasmtime::*;
29
30use crate::capabilities::{
31    console_log, read_guest_bytes, read_guest_string, register_host_functions, write_guest_bytes,
32    ConsoleEntry, ConsoleLevel, HostState,
33};
34use crate::engine::ModuleLoader;
35use crate::url::OxideUrl;
36
37/// Message handed to a worker thread over its inbox channel.
38enum Inbox {
39    /// A byte payload to deliver to the worker's `on_message` export.
40    Message(Vec<u8>),
41    /// Request the worker thread to stop.
42    Terminate,
43}
44
45/// Parent-side handle to a single running worker.
46struct Worker {
47    /// Sender for the worker's inbox (parent → worker).
48    inbox_tx: Sender<Inbox>,
49    /// Messages the worker posted back (worker → parent), drained by `api_worker_recv`.
50    outbox: Arc<Mutex<VecDeque<Vec<u8>>>>,
51    /// Cleared on terminate so the worker loop exits after its current callback.
52    alive: Arc<AtomicBool>,
53}
54
55/// Registry of workers spawned by one guest. Lazily created on first spawn.
56pub struct WorkerState {
57    workers: HashMap<u32, Worker>,
58    next_id: u32,
59}
60
61impl WorkerState {
62    fn new() -> Self {
63        Self {
64            workers: HashMap::new(),
65            next_id: 1,
66        }
67    }
68
69    fn alloc_id(&mut self) -> u32 {
70        let id = self.next_id;
71        self.next_id = self.next_id.wrapping_add(1).max(1);
72        id
73    }
74
75    /// Spawn a worker for `url`, deriving its sandbox state from `parent`.
76    ///
77    /// Returns a handle (`> 0`) or `0` if no module loader is available. The
78    /// worker shares only the persistent KV store and console with its parent;
79    /// canvas, input, timers, and memory are isolated.
80    fn spawn(&mut self, url: String, parent: &HostState) -> u32 {
81        let loader = match parent.module_loader.clone() {
82            Some(l) => l,
83            None => return 0,
84        };
85
86        let id = self.alloc_id();
87        let (inbox_tx, inbox_rx) = std::sync::mpsc::channel::<Inbox>();
88        let outbox: Arc<Mutex<VecDeque<Vec<u8>>>> = Arc::new(Mutex::new(VecDeque::new()));
89        let alive = Arc::new(AtomicBool::new(true));
90
91        let child = HostState {
92            module_loader: Some(loader.clone()),
93            kv_db: parent.kv_db.clone(),
94            console: parent.console.clone(),
95            current_url: Arc::new(Mutex::new(url.clone())),
96            worker_outbox: Some(outbox.clone()),
97            worker_current_msg: Arc::new(Mutex::new(None)),
98            ..Default::default()
99        };
100
101        let console = parent.console.clone();
102        let alive_thread = alive.clone();
103        std::thread::spawn(move || {
104            worker_main(url, loader, child, inbox_rx, alive_thread, console);
105        });
106
107        self.workers.insert(
108            id,
109            Worker {
110                inbox_tx,
111                outbox,
112                alive,
113            },
114        );
115        id
116    }
117
118    fn post(&self, id: u32, data: Vec<u8>) -> bool {
119        match self.workers.get(&id) {
120            Some(w) => w.inbox_tx.send(Inbox::Message(data)).is_ok(),
121            None => false,
122        }
123    }
124
125    fn recv(&self, id: u32) -> Option<Vec<u8>> {
126        self.workers.get(&id)?.outbox.lock().unwrap().pop_front()
127    }
128
129    fn terminate(&mut self, id: u32) -> bool {
130        match self.workers.remove(&id) {
131            Some(w) => {
132                w.alive.store(false, Ordering::Relaxed);
133                let _ = w.inbox_tx.send(Inbox::Terminate);
134                true
135            }
136            None => false,
137        }
138    }
139}
140
141fn ensure_workers(state: &Arc<Mutex<Option<WorkerState>>>) {
142    let mut g = state.lock().unwrap();
143    if g.is_none() {
144        *g = Some(WorkerState::new());
145    }
146}
147
148/// Synchronously fetch a worker module's bytes. Supports `http(s)` and `file://`.
149fn fetch_worker_bytes(url: &str) -> Result<Vec<u8>> {
150    let parsed = OxideUrl::parse(url).map_err(|e| anyhow::anyhow!("{e}"))?;
151    if parsed.is_fetchable() {
152        let client = reqwest::blocking::Client::builder()
153            .timeout(Duration::from_secs(30))
154            .build()?;
155        let resp = client
156            .get(parsed.as_str())
157            .header("Accept", "application/wasm")
158            .send()?;
159        if !resp.status().is_success() {
160            anyhow::bail!("HTTP {}", resp.status());
161        }
162        Ok(resp.bytes()?.to_vec())
163    } else if parsed.is_local_file() {
164        let path = parsed
165            .to_file_path()
166            .ok_or_else(|| anyhow::anyhow!("cannot convert file URL to path: {url}"))?;
167        Ok(std::fs::read(&path)?)
168    } else {
169        anyhow::bail!("unsupported worker URL scheme: {}", parsed.scheme())
170    }
171}
172
173/// Body of a worker thread: fetch, compile, instantiate, run `start_app`, then
174/// loop delivering inbound messages to `on_message` until terminated.
175fn worker_main(
176    url: String,
177    loader: Arc<ModuleLoader>,
178    host_state: HostState,
179    inbox_rx: Receiver<Inbox>,
180    alive: Arc<AtomicBool>,
181    console: Arc<Mutex<Vec<ConsoleEntry>>>,
182) {
183    let wasm_bytes = match fetch_worker_bytes(&url) {
184        Ok(b) => b,
185        Err(e) => {
186            console_log(
187                &console,
188                ConsoleLevel::Error,
189                format!("[WORKER] fetch failed for {url}: {e}"),
190            );
191            return;
192        }
193    };
194
195    let module = match Module::new(&loader.engine, &wasm_bytes) {
196        Ok(m) => m,
197        Err(e) => {
198            console_log(
199                &console,
200                ConsoleLevel::Error,
201                format!("[WORKER] compile failed: {e}"),
202            );
203            return;
204        }
205    };
206
207    let mut store = Store::new(&loader.engine, host_state);
208    if store.set_fuel(loader.fuel_limit).is_err() {
209        return;
210    }
211
212    let mut linker = Linker::new(&loader.engine);
213    if register_host_functions(&mut linker).is_err() {
214        return;
215    }
216
217    let mem_type = MemoryType::new(1, Some(loader.max_memory_pages));
218    let memory = match Memory::new(&mut store, mem_type) {
219        Ok(m) => m,
220        Err(_) => return,
221    };
222    if linker.define(&store, "oxide", "memory", memory).is_err() {
223        return;
224    }
225    store.data_mut().memory = Some(memory);
226
227    let instance = match linker.instantiate(&mut store, &module) {
228        Ok(i) => i,
229        Err(e) => {
230            console_log(
231                &console,
232                ConsoleLevel::Error,
233                format!("[WORKER] instantiate failed: {e}"),
234            );
235            return;
236        }
237    };
238
239    if let Some(guest_mem) = instance.get_memory(&mut store, "memory") {
240        store.data_mut().memory = Some(guest_mem);
241    }
242
243    if let Ok(start_app) = instance.get_typed_func::<(), ()>(&mut store, "start_app") {
244        let _ = store.set_fuel(loader.fuel_limit);
245        if let Err(e) = start_app.call(&mut store, ()) {
246            console_log(
247                &console,
248                ConsoleLevel::Error,
249                format!("[WORKER] start_app trapped: {e}"),
250            );
251            return;
252        }
253    }
254
255    let on_message = instance
256        .get_typed_func::<u32, ()>(&mut store, "on_message")
257        .ok();
258
259    while alive.load(Ordering::Relaxed) {
260        let bytes = match inbox_rx.recv() {
261            Ok(Inbox::Message(b)) => b,
262            Ok(Inbox::Terminate) | Err(_) => break,
263        };
264        let Some(ref on_message) = on_message else {
265            continue;
266        };
267        let len = bytes.len() as u32;
268        *store.data().worker_current_msg.lock().unwrap() = Some(bytes);
269        let _ = store.set_fuel(loader.fuel_limit);
270        if let Err(e) = on_message.call(&mut store, len) {
271            let msg = if e.to_string().contains("fuel") {
272                "[WORKER] on_message fuel limit exceeded".to_string()
273            } else {
274                format!("[WORKER] on_message trapped: {e}")
275            };
276            console_log(&console, ConsoleLevel::Error, msg);
277            break;
278        }
279        *store.data().worker_current_msg.lock().unwrap() = None;
280    }
281}
282
283/// Register all `api_worker_*` / `api_spawn_worker` host functions.
284pub fn register_worker_functions(linker: &mut Linker<HostState>) -> Result<()> {
285    // ── spawn_worker ────────────────────────────────────────────────────────
286    // api_spawn_worker(url_ptr: u32, url_len: u32) -> i32
287    //   Returns a worker handle (> 0), or -1 on error.
288    linker.func_wrap(
289        "oxide",
290        "api_spawn_worker",
291        |caller: Caller<'_, HostState>, url_ptr: u32, url_len: u32| -> i32 {
292            let console = caller.data().console.clone();
293            let mem = match caller.data().memory {
294                Some(m) => m,
295                None => return -1,
296            };
297            let url = match read_guest_string(&mem, &caller, url_ptr, url_len) {
298                Ok(s) => s,
299                Err(_) => return -1,
300            };
301            let workers = caller.data().workers.clone();
302            ensure_workers(&workers);
303            let id = workers
304                .lock()
305                .unwrap()
306                .as_mut()
307                .unwrap()
308                .spawn(url.clone(), caller.data());
309            if id == 0 {
310                console_log(
311                    &console,
312                    ConsoleLevel::Error,
313                    "[WORKER] spawn failed (no module loader)".into(),
314                );
315                return -1;
316            }
317            console_log(
318                &console,
319                ConsoleLevel::Log,
320                format!("[WORKER] spawned {url} (handle={id})"),
321            );
322            id as i32
323        },
324    )?;
325
326    // ── worker_post_message ───────────────────────────────────────────────
327    // api_worker_post_message(handle: u32, ptr: u32, len: u32) -> i32
328    //   Parent → worker. Returns 0 on success, -1 if the handle is unknown.
329    linker.func_wrap(
330        "oxide",
331        "api_worker_post_message",
332        |caller: Caller<'_, HostState>, handle: u32, ptr: u32, len: u32| -> i32 {
333            let mem = match caller.data().memory {
334                Some(m) => m,
335                None => return -1,
336            };
337            let data = match read_guest_bytes(&mem, &caller, ptr, len) {
338                Ok(b) => b,
339                Err(_) => return -1,
340            };
341            let workers = caller.data().workers.clone();
342            let g = workers.lock().unwrap();
343            match g.as_ref() {
344                Some(s) if s.post(handle, data) => 0,
345                _ => -1,
346            }
347        },
348    )?;
349
350    // ── worker_recv ─────────────────────────────────────────────────────────
351    // api_worker_recv(handle: u32, out_ptr: u32, out_cap: u32) -> i64
352    //   Worker → parent. -1 if no message is queued, else the byte length written.
353    linker.func_wrap(
354        "oxide",
355        "api_worker_recv",
356        |mut caller: Caller<'_, HostState>, handle: u32, out_ptr: u32, out_cap: u32| -> i64 {
357            let workers = caller.data().workers.clone();
358            let msg = {
359                let g = workers.lock().unwrap();
360                g.as_ref().and_then(|s| s.recv(handle))
361            };
362            let msg = match msg {
363                Some(m) => m,
364                None => return -1,
365            };
366            let mem = match caller.data().memory {
367                Some(m) => m,
368                None => return -1,
369            };
370            let to_write = if msg.len() > out_cap as usize {
371                &msg[..out_cap as usize]
372            } else {
373                &msg[..]
374            };
375            if write_guest_bytes(&mem, &mut caller, out_ptr, to_write).is_err() {
376                return -1;
377            }
378            to_write.len() as i64
379        },
380    )?;
381
382    // ── worker_terminate ──────────────────────────────────────────────────
383    // api_worker_terminate(handle: u32) -> i32
384    //   Returns 1 if the worker was running, 0 if the handle is unknown.
385    linker.func_wrap(
386        "oxide",
387        "api_worker_terminate",
388        |caller: Caller<'_, HostState>, handle: u32| -> i32 {
389            let workers = caller.data().workers.clone();
390            let mut g = workers.lock().unwrap();
391            let terminated = g.as_mut().map(|s| s.terminate(handle)).unwrap_or(false);
392            i32::from(terminated)
393        },
394    )?;
395
396    // ── worker_post (worker side) ─────────────────────────────────────────
397    // api_worker_post(ptr: u32, len: u32) -> i32
398    //   Worker → its spawning parent. -1 if not running inside a worker.
399    linker.func_wrap(
400        "oxide",
401        "api_worker_post",
402        |caller: Caller<'_, HostState>, ptr: u32, len: u32| -> i32 {
403            let mem = match caller.data().memory {
404                Some(m) => m,
405                None => return -1,
406            };
407            let data = match read_guest_bytes(&mem, &caller, ptr, len) {
408                Ok(b) => b,
409                Err(_) => return -1,
410            };
411            match caller.data().worker_outbox.clone() {
412                Some(outbox) => {
413                    outbox.lock().unwrap().push_back(data);
414                    0
415                }
416                None => -1,
417            }
418        },
419    )?;
420
421    // ── worker_message_read (worker side) ─────────────────────────────────
422    // api_worker_message_read(out_ptr: u32, out_cap: u32) -> u32
423    //   Copies the current inbound message into guest memory during on_message.
424    //   Returns the number of bytes written (0 if no message is active).
425    linker.func_wrap(
426        "oxide",
427        "api_worker_message_read",
428        |mut caller: Caller<'_, HostState>, out_ptr: u32, out_cap: u32| -> u32 {
429            let msg_arc = caller.data().worker_current_msg.clone();
430            let mem = match caller.data().memory {
431                Some(m) => m,
432                None => return 0,
433            };
434            let guard = msg_arc.lock().unwrap();
435            let bytes = match guard.as_ref() {
436                Some(b) => b,
437                None => return 0,
438            };
439            let to_write = if bytes.len() > out_cap as usize {
440                &bytes[..out_cap as usize]
441            } else {
442                &bytes[..]
443            };
444            if write_guest_bytes(&mem, &mut caller, out_ptr, to_write).is_err() {
445                return 0;
446            }
447            to_write.len() as u32
448        },
449    )?;
450
451    Ok(())
452}