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 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
281pub struct MainThreadDispatcher {
286 engine: Box<dyn Engine>,
287 rx: mpsc::Receiver<Job>,
288}
289
290impl MainThreadDispatcher {
291 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 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 #[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 #[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}