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