Skip to main content

vs_engine_webkit/
runtime.rs

1//! Engine thread + channel bridge.
2//!
3//! WebKit on either platform must be driven from the thread that owns
4//! its run loop (`GMainLoop` on Linux, `CFRunLoop` on macOS). The
5//! daemon's Tokio workers can not call WebKit directly. [`EngineRuntime`]
6//! owns a dedicated OS thread, lets the platform construct the engine
7//! on that thread, and dispatches every call from the daemon over an
8//! `mpsc` channel.
9//!
10//! Every wrapper method on [`EngineRuntime`] (e.g. [`EngineRuntime::open`])
11//! is synchronous and blocks until the engine thread replies. The
12//! daemon wraps these in `tokio::task::spawn_blocking` to keep them
13//! off Tokio's worker threads.
14
15#![allow(clippy::result_unit_err, clippy::must_use_candidate)]
16
17use std::path::PathBuf;
18use std::sync::mpsc;
19use std::thread::{self, JoinHandle};
20use std::time::Duration;
21
22use vs_protocol::{Ref, Tree};
23
24use crate::engine::{
25    ActTarget, Action, AuthBlob, CaptureScope, Engine, EngineCapabilities, EngineError,
26    EngineResult, LayoutBox, PageHandle, Viewport, WaitCondition,
27};
28
29/// One unit of work for the engine thread.
30type Job = Box<dyn FnOnce(&mut dyn Engine) + Send>;
31
32/// Owns the engine thread and exposes a synchronous facade.
33///
34/// Drop semantics: dropping the runtime closes the command channel,
35/// which causes the engine thread to exit its loop and drop the
36/// engine. The destructor joins the thread.
37pub struct EngineRuntime {
38    sender: Option<mpsc::Sender<Job>>,
39    handle: Option<JoinHandle<()>>,
40}
41
42impl std::fmt::Debug for EngineRuntime {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("EngineRuntime")
45            .field("running", &self.sender.is_some())
46            .finish_non_exhaustive()
47    }
48}
49
50impl EngineRuntime {
51    /// Spawn the engine thread and construct the engine *on that thread*
52    /// via `make`. This is the only correct construction point: the
53    /// platform run loop must be initialized on the same thread that
54    /// later drives WebKit.
55    ///
56    /// Returns once the engine thread has signaled it is ready.
57    pub fn spawn<F>(make: F) -> EngineResult<Self>
58    where
59        F: FnOnce() -> EngineResult<Box<dyn Engine>> + Send + 'static,
60    {
61        let (tx, rx) = mpsc::channel::<Job>();
62        let (ready_tx, ready_rx) = mpsc::sync_channel::<EngineResult<()>>(1);
63
64        let handle = thread::Builder::new()
65            .name("vibesurfer-engine".into())
66            .spawn(move || {
67                let mut engine = match make() {
68                    Ok(e) => {
69                        let _ = ready_tx.send(Ok(()));
70                        e
71                    }
72                    Err(e) => {
73                        let _ = ready_tx.send(Err(e));
74                        return;
75                    }
76                };
77
78                while let Ok(job) = rx.recv() {
79                    job(engine.as_mut());
80                }
81                // Channel closed: drop the engine on this thread and exit.
82                drop(engine);
83            })
84            .map_err(|e| EngineError::Other(format!("spawn engine thread: {e}")))?;
85
86        match ready_rx.recv() {
87            Ok(Ok(())) => {}
88            Ok(Err(e)) => {
89                let _ = handle.join();
90                return Err(e);
91            }
92            Err(_) => {
93                let _ = handle.join();
94                return Err(EngineError::Crashed);
95            }
96        }
97
98        Ok(Self {
99            sender: Some(tx),
100            handle: Some(handle),
101        })
102    }
103
104    /// Construct a runtime whose engine runs on the *caller'\''s* thread.
105    /// No internal thread is spawned. The returned [`MainThreadDispatcher`]
106    /// drives the engine: call [`MainThreadDispatcher::tick`] in the
107    /// caller'\''s loop to drain one queued job, or [`MainThreadDispatcher::run_until`]
108    /// to block until a stop flag fires.
109    ///
110    /// Use this when the engine is bound to a specific OS thread (e.g.
111    /// the macOS Cocoa main thread, where `WKWebView` must run).
112    pub fn dispatcher(engine: Box<dyn Engine>) -> (Self, MainThreadDispatcher) {
113        let (tx, rx) = mpsc::channel::<Job>();
114        let runtime = Self {
115            sender: Some(tx),
116            handle: None,
117        };
118        let dispatcher = MainThreadDispatcher { engine, rx };
119        (runtime, dispatcher)
120    }
121
122    /// Cleanly shut down: close the channel and join the thread.
123    /// Idempotent — calling shutdown twice is a no-op.
124    pub fn shutdown(&mut self) {
125        drop(self.sender.take());
126        if let Some(handle) = self.handle.take() {
127            let _ = handle.join();
128        }
129    }
130
131    fn dispatch<R, F>(&self, f: F) -> EngineResult<R>
132    where
133        F: FnOnce(&mut dyn Engine) -> EngineResult<R> + Send + 'static,
134        R: Send + 'static,
135    {
136        let sender = self.sender.as_ref().ok_or(EngineError::Closed)?;
137        let (reply_tx, reply_rx) = mpsc::sync_channel::<EngineResult<R>>(1);
138        let job: Job = Box::new(move |engine| {
139            let result = f(engine);
140            let _ = reply_tx.send(result);
141        });
142        sender.send(job).map_err(|_| EngineError::Closed)?;
143        reply_rx.recv().map_err(|_| EngineError::Crashed)?
144    }
145
146    // -----------------------------------------------------------------
147    // Synchronous facade for every Engine method.
148    // -----------------------------------------------------------------
149
150    pub fn open(&self, url: &str) -> EngineResult<PageHandle> {
151        let url = url.to_string();
152        self.dispatch(move |e| e.open(&url))
153    }
154
155    pub fn close(&self, page: PageHandle) -> EngineResult<()> {
156        self.dispatch(move |e| e.close(page))
157    }
158
159    pub fn snapshot(&self, page: PageHandle) -> EngineResult<Tree> {
160        self.dispatch(move |e| e.snapshot(page))
161    }
162
163    pub fn act(&self, page: PageHandle, target: ActTarget, action: Action) -> EngineResult<()> {
164        self.dispatch(move |e| e.act(page, target, action))
165    }
166
167    pub fn wait(
168        &self,
169        page: PageHandle,
170        cond: WaitCondition,
171        budget: Duration,
172    ) -> EngineResult<()> {
173        self.dispatch(move |e| e.wait(page, cond, budget))
174    }
175
176    pub fn capture(&self, page: PageHandle, scope: CaptureScope) -> EngineResult<PathBuf> {
177        self.dispatch(move |e| e.capture(page, scope))
178    }
179
180    pub fn layout(&self, page: PageHandle, refs: Vec<Ref>) -> EngineResult<Vec<LayoutBox>> {
181        self.dispatch(move |e| e.layout(page, &refs))
182    }
183
184    pub fn set_viewport(&self, page: PageHandle, viewport: Viewport) -> EngineResult<()> {
185        self.dispatch(move |e| e.set_viewport(page, viewport))
186    }
187
188    pub fn save_auth(&self, page: PageHandle) -> EngineResult<AuthBlob> {
189        self.dispatch(move |e| e.save_auth(page))
190    }
191
192    pub fn load_auth(&self, page: PageHandle, blob: AuthBlob) -> EngineResult<()> {
193        self.dispatch(move |e| e.load_auth(page, &blob))
194    }
195
196    pub fn console_entries(
197        &self,
198        page: PageHandle,
199    ) -> EngineResult<Vec<crate::inspector::ConsoleEntry>> {
200        self.dispatch(move |e| e.console_entries(page))
201    }
202
203    pub fn network_entries(
204        &self,
205        page: PageHandle,
206    ) -> EngineResult<Vec<crate::inspector::NetworkEntry>> {
207        self.dispatch(move |e| e.network_entries(page))
208    }
209
210    pub fn request_detail(
211        &self,
212        page: PageHandle,
213        seq: u64,
214    ) -> EngineResult<Option<crate::inspector::RequestDetail>> {
215        self.dispatch(move |e| e.request_detail(page, seq))
216    }
217
218    pub fn eval_js(
219        &self,
220        page: PageHandle,
221        expr: &str,
222    ) -> EngineResult<crate::inspector::EvalResult> {
223        let expr = expr.to_string();
224        self.dispatch(move |e| e.eval_js(page, &expr))
225    }
226
227    pub fn storage(
228        &self,
229        page: PageHandle,
230        scope: crate::inspector::StorageScope,
231    ) -> EngineResult<Vec<crate::inspector::StorageEntry>> {
232        self.dispatch(move |e| e.storage(page, scope))
233    }
234
235    pub fn cookie_events(
236        &self,
237        page: PageHandle,
238    ) -> EngineResult<Vec<crate::inspector::CookieEvent>> {
239        self.dispatch(move |e| e.cookie_events(page))
240    }
241
242    pub fn cursor_op(
243        &self,
244        page: PageHandle,
245        op: crate::engine::CursorOp,
246        mode: crate::engine::InputMode,
247    ) -> EngineResult<()> {
248        self.dispatch(move |e| e.cursor_op(page, op, mode))
249    }
250
251    pub fn scripts(&self, page: PageHandle) -> EngineResult<Vec<crate::inspector::ScriptEntry>> {
252        self.dispatch(move |e| e.scripts(page))
253    }
254
255    pub fn script_source(
256        &self,
257        page: PageHandle,
258        seq: u64,
259    ) -> EngineResult<Option<crate::inspector::ScriptSource>> {
260        self.dispatch(move |e| e.script_source(page, seq))
261    }
262
263    pub fn dom(
264        &self,
265        page: PageHandle,
266        r: vs_protocol::Ref,
267        extra_props: Vec<String>,
268    ) -> EngineResult<Option<crate::inspector::DomDetail>> {
269        self.dispatch(move |e| e.dom(page, r, &extra_props))
270    }
271
272    pub fn performance(
273        &self,
274        page: PageHandle,
275    ) -> EngineResult<crate::inspector::PerformanceMetrics> {
276        self.dispatch(move |e| e.performance(page))
277    }
278
279    pub fn capabilities(&self) -> EngineResult<EngineCapabilities> {
280        self.dispatch(|e| Ok(e.capabilities()))
281    }
282}
283
284impl Drop for EngineRuntime {
285    fn drop(&mut self) {
286        self.shutdown();
287    }
288}
289
290/// Drives an engine that lives on the caller'\''s thread (typically the
291/// OS main thread on macOS, since `WKWebView` is pinned there). The
292/// driver owns the engine and a receiver for jobs sent by the runtime
293/// handle that the daemon holds.
294pub struct MainThreadDispatcher {
295    engine: Box<dyn Engine>,
296    rx: mpsc::Receiver<Job>,
297}
298
299impl MainThreadDispatcher {
300    /// Drain one job if available. Returns `Ok(true)` if a job was
301    /// executed, `Ok(false)` if the queue is currently empty,
302    /// `Err(())` if the channel is closed (the runtime was dropped —
303    /// the dispatcher should exit its loop).
304    pub fn tick(&mut self) -> Result<bool, ()> {
305        match self.rx.try_recv() {
306            Ok(job) => {
307                job(self.engine.as_mut());
308                Ok(true)
309            }
310            Err(mpsc::TryRecvError::Empty) => Ok(false),
311            Err(mpsc::TryRecvError::Disconnected) => Err(()),
312        }
313    }
314
315    /// Block until a job arrives or the channel is closed; execute one
316    /// job. Returns `Ok(true)` after running, `Err(())` on closed.
317    pub fn tick_blocking(&mut self) -> Result<bool, ()> {
318        match self.rx.recv() {
319            Ok(job) => {
320                job(self.engine.as_mut());
321                Ok(true)
322            }
323            Err(_) => Err(()),
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use std::path::PathBuf;
331    use std::time::Duration;
332
333    use vs_protocol::{Node, Ref, Role, Tree};
334
335    use super::*;
336    use crate::engine::{
337        ActTarget, Action, AuthBlob, CaptureScope, EngineCapabilities, LayoutBox, Viewport,
338        WaitCondition,
339    };
340
341    /// Minimal in-process `Engine` impl used only to exercise the
342    /// runtime's spawn / dispatch / shutdown plumbing. Lives in the
343    /// same `cfg(test)` block as the tests so it can never be reached
344    /// from production code.
345    #[derive(Default)]
346    struct TestEngine {
347        next_handle: u64,
348        last_url: String,
349    }
350
351    impl Engine for TestEngine {
352        fn open(&mut self, url: &str) -> EngineResult<PageHandle> {
353            self.next_handle += 1;
354            self.last_url = url.to_string();
355            Ok(PageHandle(self.next_handle))
356        }
357        fn close(&mut self, _page: PageHandle) -> EngineResult<()> {
358            Ok(())
359        }
360        fn snapshot(&mut self, _page: PageHandle) -> EngineResult<Tree> {
361            Ok(Tree::from_root(Node::leaf(
362                Ref(1),
363                Role::Doc,
364                &self.last_url,
365            )))
366        }
367        fn act(&mut self, _: PageHandle, _: ActTarget, _: Action) -> EngineResult<()> {
368            Ok(())
369        }
370        fn wait(&mut self, _: PageHandle, _: WaitCondition, _: Duration) -> EngineResult<()> {
371            Ok(())
372        }
373        fn capture(&mut self, _: PageHandle, _: CaptureScope) -> EngineResult<PathBuf> {
374            Ok(PathBuf::from("/tmp/test.png"))
375        }
376        fn layout(&mut self, _: PageHandle, refs: &[Ref]) -> EngineResult<Vec<LayoutBox>> {
377            Ok(refs
378                .iter()
379                .map(|r| LayoutBox {
380                    r: *r,
381                    x: 0.0,
382                    y: 0.0,
383                    width: 1.0,
384                    height: 1.0,
385                    visible: true,
386                    z_index: 0,
387                })
388                .collect())
389        }
390        fn set_viewport(&mut self, _: PageHandle, _: Viewport) -> EngineResult<()> {
391            Ok(())
392        }
393        fn save_auth(&mut self, _: PageHandle) -> EngineResult<AuthBlob> {
394            Ok(AuthBlob {
395                bytes: self.last_url.as_bytes().to_vec(),
396            })
397        }
398        fn load_auth(&mut self, _: PageHandle, _: &AuthBlob) -> EngineResult<()> {
399            Ok(())
400        }
401        fn capabilities(&self) -> EngineCapabilities {
402            EngineCapabilities {
403                renders: false,
404                honors_viewport: false,
405                measures_layout: false,
406                persists_auth: false,
407                inspector_console: false,
408                inspector_network: false,
409                inspector_cookie_events: false,
410                name: "test",
411                version: "runtime-tests",
412            }
413        }
414    }
415
416    fn spawn_test_runtime() -> EngineRuntime {
417        EngineRuntime::spawn(|| Ok(Box::new(TestEngine::default()) as Box<dyn Engine>))
418            .expect("spawn")
419    }
420
421    #[test]
422    fn spawn_and_shutdown_cleanly() {
423        let mut rt = spawn_test_runtime();
424        rt.shutdown();
425        rt.shutdown();
426    }
427
428    #[test]
429    fn dispatch_blocks_until_reply() {
430        let rt = spawn_test_runtime();
431        let caps = rt.capabilities().unwrap();
432        assert_eq!(caps.name, "test");
433    }
434
435    #[test]
436    fn engine_construction_failure_reported() {
437        let err =
438            EngineRuntime::spawn(|| Err::<Box<dyn Engine>, _>(EngineError::Other("nope".into())))
439                .unwrap_err();
440        assert!(matches!(err, EngineError::Other(_)));
441    }
442
443    #[test]
444    fn calls_after_drop_error_with_closed() {
445        let mut rt = spawn_test_runtime();
446        rt.shutdown();
447        let err = rt.capabilities().unwrap_err();
448        assert!(matches!(err, EngineError::Closed));
449    }
450
451    /// Cover the round-trip path that used to live in
452    /// `tests/runtime_round_trip.rs`. The Engine impl is intentionally
453    /// trivial — this test verifies the dispatch channel, not engine
454    /// behavior.
455    #[test]
456    fn full_primitive_sequence_via_runtime() {
457        let rt = spawn_test_runtime();
458        let page = rt.open("https://example.com/login").unwrap();
459        rt.wait(page, WaitCondition::Stable, Duration::from_millis(0))
460            .unwrap();
461        let tree = rt.snapshot(page).unwrap();
462        assert!(tree.roots[0].label.contains("https://example.com/login"));
463        rt.act(
464            page,
465            ActTarget::Ref(Ref(3)),
466            Action::Fill { value: "x".into() },
467        )
468        .unwrap();
469        let auth = rt.save_auth(page).unwrap();
470        rt.load_auth(page, auth).unwrap();
471        rt.close(page).unwrap();
472        rt.close(page).unwrap();
473    }
474
475    #[test]
476    fn dispatch_serializes_calls() {
477        let rt = spawn_test_runtime();
478        let mut handles = Vec::new();
479        for i in 0..32 {
480            handles.push(rt.open(&format!("https://example.com/{i}")).unwrap());
481        }
482        let mut sorted = handles.clone();
483        sorted.sort();
484        sorted.dedup();
485        assert_eq!(sorted.len(), handles.len());
486    }
487}