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 scripts(&self, page: PageHandle) -> EngineResult<Vec<crate::inspector::ScriptEntry>> {
243        self.dispatch(move |e| e.scripts(page))
244    }
245
246    pub fn script_source(
247        &self,
248        page: PageHandle,
249        seq: u64,
250    ) -> EngineResult<Option<crate::inspector::ScriptSource>> {
251        self.dispatch(move |e| e.script_source(page, seq))
252    }
253
254    pub fn dom(
255        &self,
256        page: PageHandle,
257        r: vs_protocol::Ref,
258        extra_props: Vec<String>,
259    ) -> EngineResult<Option<crate::inspector::DomDetail>> {
260        self.dispatch(move |e| e.dom(page, r, &extra_props))
261    }
262
263    pub fn performance(
264        &self,
265        page: PageHandle,
266    ) -> EngineResult<crate::inspector::PerformanceMetrics> {
267        self.dispatch(move |e| e.performance(page))
268    }
269
270    pub fn capabilities(&self) -> EngineResult<EngineCapabilities> {
271        self.dispatch(|e| Ok(e.capabilities()))
272    }
273}
274
275impl Drop for EngineRuntime {
276    fn drop(&mut self) {
277        self.shutdown();
278    }
279}
280
281/// Drives an engine that lives on the caller'\''s thread (typically the
282/// OS main thread on macOS, since `WKWebView` is pinned there). The
283/// driver owns the engine and a receiver for jobs sent by the runtime
284/// handle that the daemon holds.
285pub struct MainThreadDispatcher {
286    engine: Box<dyn Engine>,
287    rx: mpsc::Receiver<Job>,
288}
289
290impl MainThreadDispatcher {
291    /// Drain one job if available. Returns `Ok(true)` if a job was
292    /// executed, `Ok(false)` if the queue is currently empty,
293    /// `Err(())` if the channel is closed (the runtime was dropped —
294    /// the dispatcher should exit its loop).
295    pub fn tick(&mut self) -> Result<bool, ()> {
296        match self.rx.try_recv() {
297            Ok(job) => {
298                job(self.engine.as_mut());
299                Ok(true)
300            }
301            Err(mpsc::TryRecvError::Empty) => Ok(false),
302            Err(mpsc::TryRecvError::Disconnected) => Err(()),
303        }
304    }
305
306    /// Block until a job arrives or the channel is closed; execute one
307    /// job. Returns `Ok(true)` after running, `Err(())` on closed.
308    pub fn tick_blocking(&mut self) -> Result<bool, ()> {
309        match self.rx.recv() {
310            Ok(job) => {
311                job(self.engine.as_mut());
312                Ok(true)
313            }
314            Err(_) => Err(()),
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use std::path::PathBuf;
322    use std::time::Duration;
323
324    use vs_protocol::{Node, Ref, Role, Tree};
325
326    use super::*;
327    use crate::engine::{
328        ActTarget, Action, AuthBlob, CaptureScope, EngineCapabilities, LayoutBox, Viewport,
329        WaitCondition,
330    };
331
332    /// Minimal in-process `Engine` impl used only to exercise the
333    /// runtime's spawn / dispatch / shutdown plumbing. Lives in the
334    /// same `cfg(test)` block as the tests so it can never be reached
335    /// from production code.
336    #[derive(Default)]
337    struct TestEngine {
338        next_handle: u64,
339        last_url: String,
340    }
341
342    impl Engine for TestEngine {
343        fn open(&mut self, url: &str) -> EngineResult<PageHandle> {
344            self.next_handle += 1;
345            self.last_url = url.to_string();
346            Ok(PageHandle(self.next_handle))
347        }
348        fn close(&mut self, _page: PageHandle) -> EngineResult<()> {
349            Ok(())
350        }
351        fn snapshot(&mut self, _page: PageHandle) -> EngineResult<Tree> {
352            Ok(Tree::from_root(Node::leaf(
353                Ref(1),
354                Role::Doc,
355                &self.last_url,
356            )))
357        }
358        fn act(&mut self, _: PageHandle, _: ActTarget, _: Action) -> EngineResult<()> {
359            Ok(())
360        }
361        fn wait(&mut self, _: PageHandle, _: WaitCondition, _: Duration) -> EngineResult<()> {
362            Ok(())
363        }
364        fn capture(&mut self, _: PageHandle, _: CaptureScope) -> EngineResult<PathBuf> {
365            Ok(PathBuf::from("/tmp/test.png"))
366        }
367        fn layout(&mut self, _: PageHandle, refs: &[Ref]) -> EngineResult<Vec<LayoutBox>> {
368            Ok(refs
369                .iter()
370                .map(|r| LayoutBox {
371                    r: *r,
372                    x: 0.0,
373                    y: 0.0,
374                    width: 1.0,
375                    height: 1.0,
376                    visible: true,
377                    z_index: 0,
378                })
379                .collect())
380        }
381        fn set_viewport(&mut self, _: PageHandle, _: Viewport) -> EngineResult<()> {
382            Ok(())
383        }
384        fn save_auth(&mut self, _: PageHandle) -> EngineResult<AuthBlob> {
385            Ok(AuthBlob {
386                bytes: self.last_url.as_bytes().to_vec(),
387            })
388        }
389        fn load_auth(&mut self, _: PageHandle, _: &AuthBlob) -> EngineResult<()> {
390            Ok(())
391        }
392        fn capabilities(&self) -> EngineCapabilities {
393            EngineCapabilities {
394                renders: false,
395                honors_viewport: false,
396                measures_layout: false,
397                persists_auth: false,
398                inspector_console: false,
399                inspector_network: false,
400                inspector_cookie_events: false,
401                name: "test",
402                version: "runtime-tests",
403            }
404        }
405    }
406
407    fn spawn_test_runtime() -> EngineRuntime {
408        EngineRuntime::spawn(|| Ok(Box::new(TestEngine::default()) as Box<dyn Engine>))
409            .expect("spawn")
410    }
411
412    #[test]
413    fn spawn_and_shutdown_cleanly() {
414        let mut rt = spawn_test_runtime();
415        rt.shutdown();
416        rt.shutdown();
417    }
418
419    #[test]
420    fn dispatch_blocks_until_reply() {
421        let rt = spawn_test_runtime();
422        let caps = rt.capabilities().unwrap();
423        assert_eq!(caps.name, "test");
424    }
425
426    #[test]
427    fn engine_construction_failure_reported() {
428        let err =
429            EngineRuntime::spawn(|| Err::<Box<dyn Engine>, _>(EngineError::Other("nope".into())))
430                .unwrap_err();
431        assert!(matches!(err, EngineError::Other(_)));
432    }
433
434    #[test]
435    fn calls_after_drop_error_with_closed() {
436        let mut rt = spawn_test_runtime();
437        rt.shutdown();
438        let err = rt.capabilities().unwrap_err();
439        assert!(matches!(err, EngineError::Closed));
440    }
441
442    /// Cover the round-trip path that used to live in
443    /// `tests/runtime_round_trip.rs`. The Engine impl is intentionally
444    /// trivial — this test verifies the dispatch channel, not engine
445    /// behavior.
446    #[test]
447    fn full_primitive_sequence_via_runtime() {
448        let rt = spawn_test_runtime();
449        let page = rt.open("https://example.com/login").unwrap();
450        rt.wait(page, WaitCondition::Stable, Duration::from_millis(0))
451            .unwrap();
452        let tree = rt.snapshot(page).unwrap();
453        assert!(tree.roots[0].label.contains("https://example.com/login"));
454        rt.act(
455            page,
456            ActTarget::Ref(Ref(3)),
457            Action::Fill { value: "x".into() },
458        )
459        .unwrap();
460        let auth = rt.save_auth(page).unwrap();
461        rt.load_auth(page, auth).unwrap();
462        rt.close(page).unwrap();
463        rt.close(page).unwrap();
464    }
465
466    #[test]
467    fn dispatch_serializes_calls() {
468        let rt = spawn_test_runtime();
469        let mut handles = Vec::new();
470        for i in 0..32 {
471            handles.push(rt.open(&format!("https://example.com/{i}")).unwrap());
472        }
473        let mut sorted = handles.clone();
474        sorted.sort();
475        sorted.dedup();
476        assert_eq!(sorted.len(), handles.len());
477    }
478}