1#![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
29type Job = Box<dyn FnOnce(&mut dyn Engine) + Send>;
31
32pub 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 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 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 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 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 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
290pub struct MainThreadDispatcher {
295 engine: Box<dyn Engine>,
296 rx: mpsc::Receiver<Job>,
297}
298
299impl MainThreadDispatcher {
300 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 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 #[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 #[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}