1use std::{
2 collections::VecDeque,
3 sync::{
4 Arc, Condvar, Mutex,
5 atomic::{AtomicBool, Ordering},
6 },
7 thread::{self, JoinHandle},
8 time::{Duration, Instant},
9};
10
11use async_channel::{Receiver, Sender};
12use cbf::{
13 backend_event_loop::{BackendEventLoop, BackendWake},
14 browser::{Backend, CommandEnvelope, CommandSender, EventStream},
15 command::{BrowserCommand, BrowserOperation},
16 data::dialog::DialogResponse,
17 delegate::{BackendDelegate, CommandDecision, DelegateDispatcher, EventDecision},
18 error::{ApiErrorKind, BackendErrorInfo, Error},
19 event::{BackendStopReason, BrowserEvent},
20};
21
22use crate::{
23 command::ChromeCommand,
24 data::prompt_ui::PromptUiResponse,
25 event::{ChromeEvent, to_generic_event},
26 ffi::{Error as IpcError, EventWaitResult, IpcClient, IpcEvent, IpcEventWaitHandle},
27};
28
29#[derive(Debug)]
31pub struct ChromiumBackend {
32 _options: ChromiumBackendOptions,
33 client: IpcClient,
34}
35
36#[derive(Debug, Default, Clone)]
38pub struct ChromiumBackendOptions {}
39
40impl ChromiumBackendOptions {
41 pub fn new() -> Self {
43 Self::default()
44 }
45}
46
47#[derive(Debug)]
48enum CommandExecutionError {
49 IpcCall {
50 operation: Option<BrowserOperation>,
51 source: IpcError,
52 },
53 Unsupported {
54 operation: BrowserOperation,
55 detail: &'static str,
56 },
57}
58
59impl CommandExecutionError {
60 fn from_ipc_call(operation: Option<BrowserOperation>, source: IpcError) -> Self {
61 Self::IpcCall { operation, source }
62 }
63
64 fn into_backend_error_info(self) -> BackendErrorInfo {
65 match self {
66 Self::IpcCall { operation, source } => BackendErrorInfo {
67 kind: match source {
68 IpcError::ConnectionFailed => ApiErrorKind::CommandDispatchFailed,
69 IpcError::InvalidInput => ApiErrorKind::InvalidInput,
70 IpcError::InvalidEvent => ApiErrorKind::ProtocolMismatch,
71 },
72 operation,
73 detail: Some(format!("{source:?}")),
74 },
75 Self::Unsupported { operation, detail } => BackendErrorInfo {
76 kind: ApiErrorKind::Unsupported,
77 operation: Some(operation),
78 detail: Some(detail.to_string()),
79 },
80 }
81 }
82}
83
84fn backend_error_event(source: IpcError) -> BackendErrorInfo {
85 let kind = match source {
86 IpcError::InvalidEvent => ApiErrorKind::ProtocolMismatch,
87 IpcError::InvalidInput => ApiErrorKind::InvalidInput,
88 IpcError::ConnectionFailed => ApiErrorKind::EventProcessingFailed,
89 };
90
91 BackendErrorInfo {
92 kind,
93 operation: None,
94 detail: Some(format!("{source:?}")),
95 }
96}
97
98fn backend_error_terminal_hint(kind: ApiErrorKind) -> bool {
99 matches!(kind, ApiErrorKind::ProtocolMismatch)
100}
101
102#[derive(Debug)]
104pub enum RawCommandDecision {
105 Forward,
107 Drop,
109 Stop(BackendStopReason),
111}
112
113pub trait ChromeRawDelegate: Send + 'static {
115 fn on_raw_command(&mut self, _command: &ChromeCommand) -> RawCommandDecision {
117 RawCommandDecision::Forward
118 }
119}
120
121#[derive(Debug, Default)]
122struct NoopRawDelegate;
123
124impl ChromeRawDelegate for NoopRawDelegate {}
125
126trait BackendInputWaiter: Send + 'static {
127 fn wait_for_input(&self, timeout: Option<Duration>) -> Result<EventWaitResult, IpcError>;
128}
129
130impl BackendInputWaiter for IpcEventWaitHandle {
131 fn wait_for_input(&self, timeout: Option<Duration>) -> Result<EventWaitResult, IpcError> {
132 self.wait_for_event(timeout)
133 }
134}
135
136#[derive(Default)]
137struct WakeStateInner {
138 pending_commands: VecDeque<CommandEnvelope<ChromiumBackend>>,
139 command_channel_closed: bool,
140 backend_input_ready: bool,
141 backend_terminal: Option<EventWaitResult>,
142 wait_error: Option<IpcError>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum DeadlineStatus {
147 None,
148 Pending,
149 Reached,
150}
151
152fn classify_deadline(now: Instant, deadline: Option<Instant>) -> DeadlineStatus {
153 match deadline {
154 None => DeadlineStatus::None,
155 Some(deadline) if now >= deadline => DeadlineStatus::Reached,
156 Some(_) => DeadlineStatus::Pending,
157 }
158}
159
160fn classify_ready_wake(inner: &WakeStateInner) -> Option<BackendWake> {
161 if !inner.pending_commands.is_empty() {
162 return Some(BackendWake::CommandReady);
163 }
164 if inner.backend_input_ready || inner.wait_error.is_some() {
165 return Some(BackendWake::BackendInputReady);
166 }
167 if inner.command_channel_closed || inner.backend_terminal.is_some() {
168 return Some(BackendWake::Stopped);
169 }
170
171 None
172}
173
174fn classify_timeout_wake(inner: &WakeStateInner) -> BackendWake {
175 classify_ready_wake(inner).unwrap_or(BackendWake::DeadlineReached)
176}
177
178fn stop_reason_from_wake_state(inner: &WakeStateInner) -> Option<BackendStopReason> {
179 if !inner.pending_commands.is_empty() || inner.backend_input_ready || inner.wait_error.is_some()
180 {
181 return None;
182 }
183
184 if inner.command_channel_closed {
185 return Some(BackendStopReason::Disconnected);
186 }
187
188 match inner.backend_terminal {
189 Some(EventWaitResult::Disconnected | EventWaitResult::Closed) => {
190 Some(BackendStopReason::Disconnected)
191 }
192 Some(EventWaitResult::EventAvailable | EventWaitResult::TimedOut) | None => None,
193 }
194}
195
196#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
197enum ShutdownState {
198 #[default]
199 Idle,
200 Proceeding {
201 request_id: u64,
202 },
203}
204
205fn normalize_stop_reason(
206 reason: BackendStopReason,
207 shutdown_state: ShutdownState,
208) -> BackendStopReason {
209 match (reason, shutdown_state) {
210 (BackendStopReason::Disconnected, ShutdownState::Proceeding { .. }) => {
211 BackendStopReason::ShutdownRequested
212 }
213 (reason, _) => reason,
214 }
215}
216
217fn update_shutdown_state(shutdown_state: &mut ShutdownState, event: &IpcEvent) {
218 match event {
219 IpcEvent::ShutdownProceeding { request_id } => {
220 *shutdown_state = ShutdownState::Proceeding {
221 request_id: *request_id,
222 };
223 }
224 IpcEvent::ShutdownCancelled { .. } => {
225 *shutdown_state = ShutdownState::Idle;
226 }
227 _ => {}
228 }
229}
230
231#[derive(Default)]
232struct WakeState {
233 inner: Mutex<WakeStateInner>,
234 cv: Condvar,
235 stop_requested: AtomicBool,
236}
237
238impl WakeState {
239 fn push_command(&self, envelope: CommandEnvelope<ChromiumBackend>) {
240 let mut inner = self.inner.lock().unwrap();
241 inner.pending_commands.push_back(envelope);
242 self.cv.notify_all();
243 }
244
245 fn mark_command_channel_closed(&self) {
246 let mut inner = self.inner.lock().unwrap();
247 inner.command_channel_closed = true;
248 self.cv.notify_all();
249 }
250
251 fn mark_backend_input_ready(&self) {
252 let mut inner = self.inner.lock().unwrap();
253 inner.backend_input_ready = true;
254 self.cv.notify_all();
255 }
256
257 fn mark_backend_terminal(&self, wait_result: EventWaitResult) {
258 let mut inner = self.inner.lock().unwrap();
259 inner.backend_terminal = Some(wait_result);
260 self.cv.notify_all();
261 }
262
263 fn mark_wait_error(&self, err: IpcError) {
264 let mut inner = self.inner.lock().unwrap();
265 inner.wait_error = Some(err);
266 inner.backend_input_ready = true;
267 self.cv.notify_all();
268 }
269
270 fn wait_for_backend_input_release(&self) {
271 let mut inner = self.inner.lock().unwrap();
272 while !self.stop_requested.load(Ordering::Acquire)
273 && (inner.backend_input_ready || inner.wait_error.is_some())
274 {
275 inner = self.cv.wait(inner).unwrap();
276 }
277 }
278
279 fn take_pending_commands(&self) -> Vec<CommandEnvelope<ChromiumBackend>> {
280 let mut inner = self.inner.lock().unwrap();
281 inner.pending_commands.drain(..).collect()
282 }
283
284 fn take_wait_error(&self) -> Option<IpcError> {
285 let mut inner = self.inner.lock().unwrap();
286 let err = inner.wait_error.take();
287 if inner.wait_error.is_none() && !inner.backend_input_ready {
288 self.cv.notify_all();
289 }
290 err
291 }
292
293 fn acknowledge_backend_input(&self) {
294 let mut inner = self.inner.lock().unwrap();
295 inner.backend_input_ready = false;
296 self.cv.notify_all();
297 }
298
299 fn stop_reason(&self) -> Option<BackendStopReason> {
300 let inner = self.inner.lock().unwrap();
301 stop_reason_from_wake_state(&inner)
302 }
303}
304
305struct ChromiumBackendEventLoop<W: BackendInputWaiter = IpcEventWaitHandle> {
306 wake_state: Arc<WakeState>,
307 command_rx: Receiver<CommandEnvelope<ChromiumBackend>>,
308 command_thread: Option<JoinHandle<()>>,
309 ipc_thread: Option<JoinHandle<()>>,
310 _backend_input_waiter: std::marker::PhantomData<W>,
311}
312
313impl<W: BackendInputWaiter> ChromiumBackendEventLoop<W> {
314 const IPC_WATCH_STOP_POLL_INTERVAL: Duration = Duration::from_millis(50);
315
316 fn new(
317 command_rx: Receiver<CommandEnvelope<ChromiumBackend>>,
318 backend_input_waiter: W,
319 ) -> Self {
320 let wake_state = Arc::new(WakeState::default());
321
322 let command_thread = {
323 let wake_state = Arc::clone(&wake_state);
324 let command_rx = command_rx.clone();
325 thread::spawn(move || {
326 loop {
327 match command_rx.recv_blocking() {
328 Ok(envelope) => wake_state.push_command(envelope),
329 Err(_) => {
330 wake_state.mark_command_channel_closed();
331 break;
332 }
333 }
334 }
335 })
336 };
337
338 let ipc_thread = {
339 let wake_state = Arc::clone(&wake_state);
340 thread::spawn(move || {
341 while !wake_state.stop_requested.load(Ordering::Acquire) {
342 match backend_input_waiter
343 .wait_for_input(Some(Self::IPC_WATCH_STOP_POLL_INTERVAL))
344 {
345 Ok(EventWaitResult::EventAvailable) => {
346 wake_state.mark_backend_input_ready();
347 wake_state.wait_for_backend_input_release();
348 }
349 Ok(EventWaitResult::TimedOut) => {}
350 Ok(wait_result @ EventWaitResult::Disconnected)
351 | Ok(wait_result @ EventWaitResult::Closed) => {
352 wake_state.mark_backend_terminal(wait_result);
353 break;
354 }
355 Err(err) => {
356 wake_state.mark_wait_error(err);
357 wake_state.wait_for_backend_input_release();
358 }
359 }
360 }
361 })
362 };
363
364 Self {
365 wake_state,
366 command_rx,
367 command_thread: Some(command_thread),
368 ipc_thread: Some(ipc_thread),
369 _backend_input_waiter: std::marker::PhantomData,
370 }
371 }
372
373 fn take_pending_commands(&self) -> Vec<CommandEnvelope<ChromiumBackend>> {
374 self.wake_state.take_pending_commands()
375 }
376
377 fn take_wait_error(&self) -> Option<IpcError> {
378 self.wake_state.take_wait_error()
379 }
380
381 fn acknowledge_backend_input(&self) {
382 self.wake_state.acknowledge_backend_input();
383 }
384
385 fn stop_reason(&self) -> Option<BackendStopReason> {
386 self.wake_state.stop_reason()
387 }
388}
389
390impl<W: BackendInputWaiter> BackendEventLoop for ChromiumBackendEventLoop<W> {
391 fn wait_until(&self, deadline: Option<Instant>) -> BackendWake {
392 let mut inner = self.wake_state.inner.lock().unwrap();
393
394 loop {
395 if let Some(wake) = classify_ready_wake(&inner) {
396 return wake;
397 }
398
399 match classify_deadline(Instant::now(), deadline) {
400 DeadlineStatus::None => {
401 inner = self.wake_state.cv.wait(inner).unwrap();
402 }
403 DeadlineStatus::Reached => return BackendWake::DeadlineReached,
404 DeadlineStatus::Pending => {
405 let deadline = deadline.expect("pending deadline must exist");
406 let timeout = deadline.saturating_duration_since(Instant::now());
407 let (next_inner, timeout_result) =
408 self.wake_state.cv.wait_timeout(inner, timeout).unwrap();
409 inner = next_inner;
410
411 if timeout_result.timed_out() {
412 return classify_timeout_wake(&inner);
413 }
414 }
415 }
416 }
417 }
418}
419
420impl<W: BackendInputWaiter> Drop for ChromiumBackendEventLoop<W> {
421 fn drop(&mut self) {
422 self.wake_state
423 .stop_requested
424 .store(true, Ordering::Release);
425 _ = self.command_rx.close();
426 self.wake_state.cv.notify_all();
427
428 if let Some(handle) = self.command_thread.take() {
429 handle.join().ok();
430 }
431 if let Some(handle) = self.ipc_thread.take() {
432 handle.join().ok();
433 }
434 }
435}
436
437impl Backend for ChromiumBackend {
438 type RawCommand = ChromeCommand;
439 type RawEvent = ChromeEvent;
440 type RawDelegate = Box<dyn ChromeRawDelegate>;
441
442 fn to_raw_command(command: BrowserCommand) -> Self::RawCommand {
443 command.into()
444 }
445
446 fn to_generic_event(raw: &Self::RawEvent) -> Option<BrowserEvent> {
447 to_generic_event(raw)
448 }
449
450 fn connect<D: BackendDelegate>(
451 self,
452 delegate: D,
453 raw_delegate: Option<Self::RawDelegate>,
454 ) -> Result<(CommandSender<Self>, EventStream<Self>), Error> {
455 let (command_tx, command_rx) = async_channel::unbounded::<CommandEnvelope<Self>>();
456 let (event_tx, event_rx) = async_channel::unbounded::<ChromeEvent>();
457 let ChromiumBackend {
458 _options: _,
459 client,
460 } = self;
461 let raw_delegate = raw_delegate.unwrap_or_else(|| Box::<NoopRawDelegate>::default());
462
463 thread::spawn(move || {
464 Self::run_communication(client, command_rx, event_tx, delegate, raw_delegate)
465 });
466
467 Ok((
468 CommandSender::from_raw_sender(command_tx),
469 EventStream::from_raw_receiver(event_rx),
470 ))
471 }
472}
473
474impl ChromiumBackend {
475 pub fn new(options: ChromiumBackendOptions, client: IpcClient) -> Self {
477 Self {
478 _options: options,
479 client,
480 }
481 }
482
483 fn run_communication(
484 client: IpcClient,
485 command_rx: Receiver<CommandEnvelope<Self>>,
486 event_tx: Sender<ChromeEvent>,
487 delegate: impl BackendDelegate,
488 mut raw_delegate: Box<dyn ChromeRawDelegate>,
489 ) {
490 let mut dispatcher = DelegateDispatcher::new(delegate);
491
492 let Some(mut client) = Self::start_connection(client, &event_tx, &mut dispatcher) else {
494 return;
495 };
496 let event_loop = ChromiumBackendEventLoop::new(command_rx, client.event_wait_handle());
497 let mut shutdown_state = ShutdownState::default();
498
499 while Self::run_iteration(
500 &event_loop,
501 &mut client,
502 &event_tx,
503 &mut dispatcher,
504 raw_delegate.as_mut(),
505 &mut shutdown_state,
506 ) {}
507 }
508
509 fn start_connection(
510 mut client: IpcClient,
511 event_tx: &Sender<ChromeEvent>,
512 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
513 ) -> Option<IpcClient> {
514 if let Some(stop_reason) = Self::handle_raw_event_with_delegate_gate(
516 dispatcher,
517 event_tx,
518 ChromeEvent::BackendReady,
519 ) {
520 Self::stop_backend(stop_reason, dispatcher, Some(&mut client), event_tx);
521 return None;
522 }
523
524 Some(client)
525 }
526
527 fn run_iteration(
528 event_loop: &ChromiumBackendEventLoop,
529 client: &mut IpcClient,
530 event_tx: &Sender<ChromeEvent>,
531 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
532 raw_delegate: &mut dyn ChromeRawDelegate,
533 shutdown_state: &mut ShutdownState,
534 ) -> bool {
535 if let Some(stop_reason) = Self::drain_ready_sources(
536 event_loop,
537 client,
538 event_tx,
539 dispatcher,
540 raw_delegate,
541 shutdown_state,
542 ) {
543 let stop_reason = normalize_stop_reason(stop_reason, *shutdown_state);
544 Self::stop_backend(stop_reason, dispatcher, Some(client), event_tx);
545 return false;
546 }
547
548 let wake = event_loop.wait_until(dispatcher.next_wake_deadline());
549 if matches!(
550 wake,
551 BackendWake::CommandReady
552 | BackendWake::BackendInputReady
553 | BackendWake::DeadlineReached
554 ) {
555 dispatcher.on_wake();
556 }
557
558 if let Some(stop_reason) = Self::drain_ready_sources(
559 event_loop,
560 client,
561 event_tx,
562 dispatcher,
563 raw_delegate,
564 shutdown_state,
565 ) {
566 let stop_reason = normalize_stop_reason(stop_reason, *shutdown_state);
567 Self::stop_backend(stop_reason, dispatcher, Some(client), event_tx);
568 return false;
569 }
570
571 if matches!(wake, BackendWake::Stopped)
572 && let Some(stop_reason) = event_loop.stop_reason()
573 {
574 let stop_reason = normalize_stop_reason(stop_reason, *shutdown_state);
575 Self::stop_backend(stop_reason, dispatcher, Some(client), event_tx);
576 return false;
577 }
578
579 true
580 }
581
582 fn emit_raw_event(event_tx: &Sender<ChromeEvent>, event: ChromeEvent) {
583 _ = event_tx.send_blocking(event);
584 }
585
586 fn handle_raw_event_with_delegate_gate(
587 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
588 event_tx: &Sender<ChromeEvent>,
589 event: ChromeEvent,
590 ) -> Option<BackendStopReason> {
591 if let Some(generic_event) = Self::to_generic_event(&event) {
592 match dispatcher.dispatch_event(&generic_event) {
593 EventDecision::Forward => {
594 Self::emit_raw_event(event_tx, event);
595 None
596 }
597 EventDecision::Stop(reason) => Some(reason),
598 }
599 } else {
600 Self::emit_raw_event(event_tx, event);
601 None
602 }
603 }
604
605 fn run_generic_command_with_delegate(
606 command: BrowserCommand,
607 raw_command: ChromeCommand,
608 client: &mut IpcClient,
609 event_tx: &Sender<ChromeEvent>,
610 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
611 ) -> Option<BackendStopReason> {
612 match dispatcher.dispatch_command(&command) {
613 CommandDecision::Forward => {
614 let operation = Some(BrowserOperation::from_command(&command));
615 let (reason, events) = Self::execute_raw_command(raw_command, operation, client);
616 for event in events {
617 if let Some(reason) =
618 Self::handle_raw_event_with_delegate_gate(dispatcher, event_tx, event)
619 {
620 return Some(reason);
621 }
622 }
623 reason
624 }
625 CommandDecision::Drop => None,
626 CommandDecision::Stop(reason) => Some(reason),
627 }
628 }
629
630 fn run_raw_command(
631 command: ChromeCommand,
632 operation: Option<BrowserOperation>,
633 client: &mut IpcClient,
634 event_tx: &Sender<ChromeEvent>,
635 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
636 ) -> Option<BackendStopReason> {
637 let (reason, events) = Self::execute_raw_command(command, operation, client);
638 for event in events {
639 if let Some(reason) =
640 Self::handle_raw_event_with_delegate_gate(dispatcher, event_tx, event)
641 {
642 return Some(reason);
643 }
644 }
645 reason
646 }
647
648 fn run_raw_command_with_raw_delegate(
649 command: ChromeCommand,
650 client: &mut IpcClient,
651 event_tx: &Sender<ChromeEvent>,
652 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
653 raw_delegate: &mut dyn ChromeRawDelegate,
654 ) -> Option<BackendStopReason> {
655 match raw_delegate.on_raw_command(&command) {
656 RawCommandDecision::Forward => {
657 Self::run_raw_command(command, None, client, event_tx, dispatcher)
658 }
659 RawCommandDecision::Drop => None,
660 RawCommandDecision::Stop(reason) => Some(reason),
661 }
662 }
663
664 fn dispatch_command_envelope(
665 envelope: CommandEnvelope<Self>,
666 client: &mut IpcClient,
667 event_tx: &Sender<ChromeEvent>,
668 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
669 raw_delegate: &mut dyn ChromeRawDelegate,
670 ) -> Option<BackendStopReason> {
671 match envelope {
672 CommandEnvelope::Generic { command, raw } => {
673 Self::run_generic_command_with_delegate(command, raw, client, event_tx, dispatcher)
674 }
675 CommandEnvelope::RawOnly { raw } => Self::run_raw_command_with_raw_delegate(
676 raw,
677 client,
678 event_tx,
679 dispatcher,
680 raw_delegate,
681 ),
682 }
683 }
684
685 fn drain_delegate_queue(
686 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
687 client: &mut IpcClient,
688 event_tx: &Sender<ChromeEvent>,
689 mut pending_commands: Vec<BrowserCommand>,
690 ) -> Option<BackendStopReason> {
691 loop {
692 for command in pending_commands {
693 let raw_command = Self::to_raw_command(command.clone());
694 if let Some(reason) = Self::run_generic_command_with_delegate(
695 command,
696 raw_command,
697 client,
698 event_tx,
699 dispatcher,
700 ) {
701 return Some(reason);
702 }
703 }
704
705 pending_commands = dispatcher.flush();
706 if pending_commands.is_empty() {
707 return None;
708 }
709 }
710 }
711
712 fn stop_backend(
713 reason: BackendStopReason,
714 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
715 client: Option<&mut IpcClient>,
716 event_tx: &Sender<ChromeEvent>,
717 ) {
718 let (mut final_reason, queued_commands) = dispatcher.stop(reason);
719 if let Some(client) = client
720 && let Some(reason) =
721 Self::drain_delegate_queue(dispatcher, client, event_tx, queued_commands)
722 {
723 final_reason = reason;
724 }
725 Self::emit_raw_event(
726 event_tx,
727 ChromeEvent::BackendStopped {
728 reason: final_reason,
729 },
730 );
731 }
732
733 fn process_event_queue(
734 client: &mut IpcClient,
735 event_tx: &Sender<ChromeEvent>,
736 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
737 shutdown_state: &mut ShutdownState,
738 ) -> Option<BackendStopReason> {
739 while let Some(event) = client.poll_event() {
740 match event {
741 Ok(event) => {
742 if let Some(reason) =
743 Self::handle_ipc_event(event, event_tx, dispatcher, shutdown_state)
744 {
745 return Some(reason);
746 }
747 }
748 Err(err) => {
749 let info = backend_error_event(err);
750 let terminal_hint = backend_error_terminal_hint(info.kind);
751 if let Some(reason) = Self::handle_raw_event_with_delegate_gate(
752 dispatcher,
753 event_tx,
754 ChromeEvent::BackendError {
755 info,
756 terminal_hint,
757 },
758 ) {
759 return Some(reason);
760 }
761 }
762 }
763 }
764
765 None
766 }
767
768 fn handle_ipc_event(
769 event: IpcEvent,
770 event_tx: &Sender<ChromeEvent>,
771 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
772 shutdown_state: &mut ShutdownState,
773 ) -> Option<BackendStopReason> {
774 update_shutdown_state(shutdown_state, &event);
775 Self::handle_raw_event_with_delegate_gate(
776 dispatcher,
777 event_tx,
778 ChromeEvent::Ipc(Box::new(event)),
779 )
780 }
781
782 fn drain_pending_command_queue(
783 event_loop: &ChromiumBackendEventLoop,
784 client: &mut IpcClient,
785 event_tx: &Sender<ChromeEvent>,
786 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
787 raw_delegate: &mut dyn ChromeRawDelegate,
788 ) -> Option<BackendStopReason> {
789 for envelope in event_loop.take_pending_commands() {
790 if let Some(reason) = Self::dispatch_command_envelope(
791 envelope,
792 client,
793 event_tx,
794 dispatcher,
795 raw_delegate,
796 ) {
797 return Some(reason);
798 }
799 }
800
801 None
802 }
803
804 fn handle_wait_error(
805 event_loop: &ChromiumBackendEventLoop,
806 event_tx: &Sender<ChromeEvent>,
807 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
808 ) -> Option<BackendStopReason> {
809 if let Some(err) = event_loop.take_wait_error() {
810 let info = backend_error_event(err);
811 let terminal_hint = backend_error_terminal_hint(info.kind);
812 return Self::handle_raw_event_with_delegate_gate(
813 dispatcher,
814 event_tx,
815 ChromeEvent::BackendError {
816 info,
817 terminal_hint,
818 },
819 );
820 }
821
822 None
823 }
824
825 fn drain_ready_sources(
826 event_loop: &ChromiumBackendEventLoop,
827 client: &mut IpcClient,
828 event_tx: &Sender<ChromeEvent>,
829 dispatcher: &mut DelegateDispatcher<impl BackendDelegate>,
830 raw_delegate: &mut dyn ChromeRawDelegate,
831 shutdown_state: &mut ShutdownState,
832 ) -> Option<BackendStopReason> {
833 let queued_commands = dispatcher.flush();
834 if let Some(stop_reason) =
835 Self::drain_delegate_queue(dispatcher, client, event_tx, queued_commands)
836 {
837 return Some(stop_reason);
838 }
839
840 if let Some(stop_reason) = Self::drain_pending_command_queue(
841 event_loop,
842 client,
843 event_tx,
844 dispatcher,
845 raw_delegate,
846 ) {
847 return Some(stop_reason);
848 }
849
850 if let Some(stop_reason) = Self::handle_wait_error(event_loop, event_tx, dispatcher) {
851 event_loop.acknowledge_backend_input();
852 return Some(stop_reason);
853 }
854
855 if let Some(stop_reason) =
856 Self::process_event_queue(client, event_tx, dispatcher, shutdown_state)
857 {
858 event_loop.acknowledge_backend_input();
859 return Some(stop_reason);
860 }
861 event_loop.acknowledge_backend_input();
862
863 let queued_commands = dispatcher.flush();
864 if let Some(stop_reason) =
865 Self::drain_delegate_queue(dispatcher, client, event_tx, queued_commands)
866 {
867 return Some(stop_reason);
868 }
869
870 event_loop.stop_reason()
871 }
872
873 fn execute_raw_command(
874 command: ChromeCommand,
875 operation: Option<BrowserOperation>,
876 client: &mut IpcClient,
877 ) -> (Option<BackendStopReason>, Vec<ChromeEvent>) {
878 match Self::handle_command(command, operation, client) {
879 Ok((reason, events)) => (reason, events),
880 Err(err) => {
881 let info = err.into_backend_error_info();
882 let terminal_hint = backend_error_terminal_hint(info.kind);
883 (
884 None,
885 vec![ChromeEvent::BackendError {
886 info,
887 terminal_hint,
888 }],
889 )
890 }
891 }
892 }
893
894 fn handle_command(
895 command: ChromeCommand,
896 operation: Option<BrowserOperation>,
897 client: &mut IpcClient,
898 ) -> Result<(Option<BackendStopReason>, Vec<ChromeEvent>), CommandExecutionError> {
899 let result = match &command {
900 ChromeCommand::RequestShutdown { request_id } => client
901 .request_shutdown(*request_id)
902 .map(|_| (None, Vec::new())),
903 ChromeCommand::ConfirmShutdown {
904 request_id,
905 proceed,
906 } => client
907 .confirm_shutdown(*request_id, *proceed)
908 .map(|_| (None, Vec::new())),
909 ChromeCommand::ForceShutdown => client.force_shutdown().map(|_| (None, Vec::new())),
910 ChromeCommand::ConfirmBeforeUnload {
911 browsing_context_id,
912 request_id,
913 proceed,
914 } => client
915 .confirm_beforeunload(*browsing_context_id, *request_id, *proceed)
916 .map(|_| (None, Vec::new())),
917 ChromeCommand::RespondJavaScriptDialog {
918 browsing_context_id,
919 request_id,
920 response,
921 } => {
922 let (accept, prompt_text) = dialog_response_parts(response);
923 client
924 .respond_javascript_dialog(
925 *browsing_context_id,
926 *request_id,
927 accept,
928 prompt_text.as_deref(),
929 )
930 .map(|_| (None, Vec::new()))
931 }
932 ChromeCommand::RespondExtensionPopupJavaScriptDialog {
933 popup_id,
934 request_id,
935 response,
936 } => {
937 let (accept, prompt_text) = dialog_response_parts(response);
938 client
939 .respond_extension_popup_javascript_dialog(
940 *popup_id,
941 *request_id,
942 accept,
943 prompt_text.as_deref(),
944 )
945 .map(|_| (None, Vec::new()))
946 }
947 ChromeCommand::ConfirmPermission {
948 browsing_context_id,
949 request_id,
950 allow,
951 } => client
952 .respond_prompt_ui_for_tab(
953 *browsing_context_id,
954 *request_id,
955 &PromptUiResponse::PermissionPrompt { allow: *allow },
956 )
957 .map(|_| (None, Vec::new())),
958 ChromeCommand::CreateTab {
959 request_id,
960 initial_url,
961 profile_id,
962 } => {
963 let url = initial_url
964 .clone()
965 .unwrap_or_else(|| "about:blank".to_string());
966
967 client
968 .create_tab(*request_id, &url, profile_id)
969 .map(|_| (None, Vec::new()))
970 }
971 ChromeCommand::SetTabSize {
972 browsing_context_id,
973 width,
974 height,
975 } => client
976 .set_tab_size(*browsing_context_id, *width, *height)
977 .map(|_| (None, Vec::new())),
978 ChromeCommand::ListProfiles => client
979 .list_profiles()
980 .map(|profiles| (None, vec![ChromeEvent::ProfilesListed { profiles }])),
981 ChromeCommand::ListExtensions { profile_id } => {
982 client.list_extensions(profile_id).map(|extensions| {
983 (
984 None,
985 vec![ChromeEvent::Ipc(Box::new(IpcEvent::ExtensionsListed {
986 profile_id: profile_id.clone(),
987 extensions,
988 }))],
989 )
990 })
991 }
992 ChromeCommand::ActivateExtensionAction {
993 browsing_context_id,
994 extension_id,
995 } => client
996 .activate_extension_action(*browsing_context_id, extension_id)
997 .map(|_| (None, Vec::new())),
998 ChromeCommand::CloseExtensionPopup { popup_id } => client
999 .close_extension_popup(*popup_id)
1000 .map(|_| (None, Vec::new())),
1001 ChromeCommand::SetExtensionPopupSize {
1002 popup_id,
1003 width,
1004 height,
1005 } => client
1006 .set_extension_popup_size(*popup_id, *width, *height)
1007 .map(|_| (None, Vec::new())),
1008 ChromeCommand::SetExtensionPopupFocus { popup_id, focused } => client
1009 .set_extension_popup_focus(*popup_id, *focused)
1010 .map(|_| (None, Vec::new())),
1011 ChromeCommand::SendExtensionPopupKeyEvent {
1012 popup_id,
1013 event,
1014 commands,
1015 } => client
1016 .send_extension_popup_key_event_raw(*popup_id, event, commands)
1017 .map(|_| (None, Vec::new())),
1018 ChromeCommand::ExecuteExtensionPopupEditAction { popup_id, action } => client
1019 .execute_extension_popup_edit_action(*popup_id, *action)
1020 .map(|_| (None, Vec::new())),
1021 ChromeCommand::SendExtensionPopupMouseEvent { popup_id, event } => client
1022 .send_extension_popup_mouse_event(*popup_id, event)
1023 .map(|_| (None, Vec::new())),
1024 ChromeCommand::SendExtensionPopupMouseWheelEvent { popup_id, event } => client
1025 .send_extension_popup_mouse_wheel_event_raw(*popup_id, event)
1026 .map(|_| (None, Vec::new())),
1027 ChromeCommand::SendKeyEvent {
1028 browsing_context_id,
1029 event,
1030 commands,
1031 } => client
1032 .send_key_event_raw(*browsing_context_id, event, commands)
1033 .map(|_| (None, Vec::new())),
1034 ChromeCommand::ExecuteEditAction {
1035 browsing_context_id,
1036 action,
1037 } => client
1038 .execute_edit_action(*browsing_context_id, *action)
1039 .map(|_| (None, Vec::new())),
1040 ChromeCommand::SendMouseEvent {
1041 browsing_context_id,
1042 event,
1043 } => client
1044 .send_mouse_event(*browsing_context_id, event)
1045 .map(|_| (None, Vec::new())),
1046 ChromeCommand::SendMouseWheelEvent {
1047 browsing_context_id,
1048 event,
1049 } => client
1050 .send_mouse_wheel_event_raw(*browsing_context_id, event)
1051 .map(|_| (None, Vec::new())),
1052 ChromeCommand::SendDragUpdate { update } => {
1053 client.send_drag_update(update).map(|_| (None, Vec::new()))
1054 }
1055 ChromeCommand::SendDragDrop { drop } => {
1056 client.send_drag_drop(drop).map(|_| (None, Vec::new()))
1057 }
1058 ChromeCommand::SendDragCancel {
1059 session_id,
1060 browsing_context_id,
1061 } => client
1062 .send_drag_cancel(*session_id, *browsing_context_id)
1063 .map(|_| (None, Vec::new())),
1064 ChromeCommand::SetImeComposition { composition } => client
1065 .set_composition(composition)
1066 .map(|_| (None, Vec::new())),
1067 ChromeCommand::SetExtensionPopupComposition { composition } => client
1068 .set_extension_popup_composition(composition)
1069 .map(|_| (None, Vec::new())),
1070 ChromeCommand::CommitImeText { commit } => {
1071 client.commit_text(commit).map(|_| (None, Vec::new()))
1072 }
1073 ChromeCommand::CommitExtensionPopupText { commit } => client
1074 .commit_extension_popup_text(commit)
1075 .map(|_| (None, Vec::new())),
1076 ChromeCommand::FinishComposingText {
1077 browsing_context_id,
1078 behavior,
1079 } => client
1080 .finish_composing_text(*browsing_context_id, *behavior)
1081 .map(|_| (None, Vec::new())),
1082 ChromeCommand::FinishExtensionPopupComposingText { popup_id, behavior } => client
1083 .finish_extension_popup_composing_text(*popup_id, *behavior)
1084 .map(|_| (None, Vec::new())),
1085 ChromeCommand::ExecuteContextMenuCommand {
1086 menu_id,
1087 command_id,
1088 event_flags,
1089 } => client
1090 .execute_context_menu_command(*menu_id, *command_id, *event_flags)
1091 .map(|_| (None, Vec::new())),
1092 ChromeCommand::AcceptChoiceMenuSelection {
1093 request_id,
1094 indices,
1095 } => client
1096 .accept_choice_menu_selection(*request_id, indices)
1097 .map(|_| (None, Vec::new())),
1098 ChromeCommand::DismissChoiceMenu { request_id } => client
1099 .dismiss_choice_menu(*request_id)
1100 .map(|_| (None, Vec::new())),
1101 ChromeCommand::DismissContextMenu { menu_id } => client
1102 .dismiss_context_menu(*menu_id)
1103 .map(|_| (None, Vec::new())),
1104 ChromeCommand::PauseDownload { download_id } => client
1105 .pause_download(*download_id)
1106 .map(|_| (None, Vec::new())),
1107 ChromeCommand::ResumeDownload { download_id } => client
1108 .resume_download(*download_id)
1109 .map(|_| (None, Vec::new())),
1110 ChromeCommand::CancelDownload { download_id } => client
1111 .cancel_download(*download_id)
1112 .map(|_| (None, Vec::new())),
1113 ChromeCommand::RequestCloseTab {
1114 browsing_context_id,
1115 } => client
1116 .request_close_tab(*browsing_context_id)
1117 .map(|_| (None, Vec::new())),
1118 ChromeCommand::Navigate {
1119 browsing_context_id,
1120 url,
1121 } => client
1122 .navigate(*browsing_context_id, url)
1123 .map(|_| (None, Vec::new())),
1124 ChromeCommand::GoBack {
1125 browsing_context_id,
1126 } => client
1127 .go_back(*browsing_context_id)
1128 .map(|_| (None, Vec::new())),
1129 ChromeCommand::GoForward {
1130 browsing_context_id,
1131 } => client
1132 .go_forward(*browsing_context_id)
1133 .map(|_| (None, Vec::new())),
1134 ChromeCommand::Reload {
1135 browsing_context_id,
1136 ignore_cache,
1137 } => client
1138 .reload(*browsing_context_id, *ignore_cache)
1139 .map(|_| (None, Vec::new())),
1140 ChromeCommand::PrintPreview {
1141 browsing_context_id,
1142 } => client
1143 .print_preview(*browsing_context_id)
1144 .map(|_| (None, Vec::new())),
1145 ChromeCommand::OpenDevTools {
1146 browsing_context_id,
1147 } => client
1148 .open_dev_tools(*browsing_context_id)
1149 .map(|_| (None, Vec::new())),
1150 ChromeCommand::InspectElement {
1151 browsing_context_id,
1152 x,
1153 y,
1154 } => client
1155 .inspect_element(*browsing_context_id, *x, *y)
1156 .map(|_| (None, Vec::new())),
1157 ChromeCommand::GetTabDomHtml {
1158 browsing_context_id,
1159 request_id,
1160 } => client
1161 .get_tab_dom_html(*browsing_context_id, *request_id)
1162 .map(|_| (None, Vec::new())),
1163 ChromeCommand::SetTabFocus {
1164 browsing_context_id,
1165 focused,
1166 } => client
1167 .set_tab_focus(*browsing_context_id, *focused)
1168 .map(|_| (None, Vec::new())),
1169 ChromeCommand::OpenDefaultPromptUi {
1170 profile_id,
1171 request_id,
1172 } => client
1173 .open_default_prompt_ui(profile_id, *request_id)
1174 .map(|_| (None, Vec::new())),
1175 ChromeCommand::RespondPromptUi {
1176 profile_id,
1177 request_id,
1178 response,
1179 } => client
1180 .respond_prompt_ui(profile_id, *request_id, response)
1181 .map(|_| (None, Vec::new())),
1182 ChromeCommand::ClosePromptUi {
1183 profile_id,
1184 prompt_ui_id,
1185 } => client
1186 .close_prompt_ui(profile_id, *prompt_ui_id)
1187 .map(|_| (None, Vec::new())),
1188 ChromeCommand::RespondTabOpen {
1189 request_id,
1190 response,
1191 } => client
1192 .respond_tab_open(*request_id, response)
1193 .map(|_| (None, Vec::new())),
1194 ChromeCommand::RespondWindowOpen {
1195 request_id,
1196 response,
1197 } => client
1198 .respond_window_open(*request_id, response)
1199 .map(|_| (None, Vec::new())),
1200 ChromeCommand::UnsupportedGenericCommand { operation } => {
1201 return Err(CommandExecutionError::Unsupported {
1202 operation: *operation,
1203 detail: "transient browsing context commands are not yet implemented in the Chromium transport",
1204 });
1205 }
1206 };
1207
1208 result.map_err(|source| CommandExecutionError::from_ipc_call(operation, source))
1209 }
1210}
1211
1212fn dialog_response_parts(response: &DialogResponse) -> (bool, Option<String>) {
1213 match response {
1214 DialogResponse::Success { input } => (true, input.clone()),
1215 DialogResponse::Cancel => (false, None),
1216 }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 use std::{
1222 mem::MaybeUninit,
1223 sync::{Arc, Mutex, mpsc},
1224 thread,
1225 time::{Duration, Instant},
1226 };
1227
1228 use async_channel::unbounded;
1229 use cbf::{
1230 backend_event_loop::{BackendEventLoop, BackendWake},
1231 browser::{Backend, EventStream, RawOpaqueEventExt},
1232 delegate::{BackendDelegate, DelegateContext, NoopDelegate},
1233 event::BackendStopReason,
1234 };
1235
1236 use super::{
1237 BackendInputWaiter, ChromeCommand, ChromeEvent, ChromiumBackend, ChromiumBackendEventLoop,
1238 ChromiumBackendOptions, DeadlineStatus, EventWaitResult, IpcClient, ShutdownState,
1239 WakeStateInner, classify_deadline, classify_ready_wake, classify_timeout_wake,
1240 normalize_stop_reason, stop_reason_from_wake_state, update_shutdown_state,
1241 };
1242 use crate::ffi::IpcEvent;
1243
1244 struct StubWaiter {
1245 rx: std::sync::mpsc::Receiver<Result<EventWaitResult, super::IpcError>>,
1246 }
1247
1248 impl BackendInputWaiter for StubWaiter {
1249 fn wait_for_input(
1250 &self,
1251 timeout: Option<Duration>,
1252 ) -> Result<EventWaitResult, super::IpcError> {
1253 match timeout {
1254 Some(timeout) => self
1255 .rx
1256 .recv_timeout(timeout)
1257 .unwrap_or(Ok(EventWaitResult::TimedOut)),
1258 None => self.rx.recv().unwrap_or(Ok(EventWaitResult::Closed)),
1259 }
1260 }
1261 }
1262
1263 fn null_ipc_client() -> IpcClient {
1264 unsafe { MaybeUninit::zeroed().assume_init() }
1267 }
1268
1269 fn sample_pending_command() -> cbf::browser::CommandEnvelope<ChromiumBackend> {
1270 cbf::browser::CommandEnvelope::RawOnly {
1271 raw: ChromeCommand::ForceShutdown,
1272 }
1273 }
1274
1275 fn recv_raw_event_with_timeout(
1276 events: &EventStream<ChromiumBackend>,
1277 timeout: Duration,
1278 ) -> ChromeEvent {
1279 let events = events.clone();
1280 let (tx, rx) = mpsc::channel();
1281 thread::spawn(move || {
1282 let event = events.recv_blocking().map(|opaque| opaque.as_raw().clone());
1283 tx.send(event).ok();
1284 });
1285
1286 rx.recv_timeout(timeout)
1287 .expect("timed out waiting for backend event")
1288 .expect("event stream closed unexpectedly")
1289 }
1290
1291 #[test]
1292 fn dropping_all_command_senders_emits_disconnected_stop_event() {
1293 let backend = ChromiumBackend::new(ChromiumBackendOptions::new(), null_ipc_client());
1294 let (command_tx, events) = backend.connect(NoopDelegate, None).unwrap();
1295 drop(command_tx);
1296
1297 let ready = recv_raw_event_with_timeout(&events, Duration::from_secs(1));
1298 assert!(matches!(ready, ChromeEvent::BackendReady));
1299
1300 let stopped = recv_raw_event_with_timeout(&events, Duration::from_secs(1));
1301 assert!(matches!(
1302 stopped,
1303 ChromeEvent::BackendStopped {
1304 reason: BackendStopReason::Disconnected
1305 }
1306 ));
1307 }
1308
1309 #[test]
1310 fn command_wake_beats_long_deadline() {
1311 let (command_tx, command_rx) =
1312 unbounded::<cbf::browser::CommandEnvelope<ChromiumBackend>>();
1313 let (_wait_tx, wait_rx) = std::sync::mpsc::channel();
1314 let event_loop = ChromiumBackendEventLoop::new(command_rx, StubWaiter { rx: wait_rx });
1315 let (wake_tx, wake_rx) = mpsc::channel();
1316 let waiter_thread = thread::spawn(move || {
1317 let wake = event_loop.wait_until(Some(Instant::now() + Duration::from_secs(1)));
1318 wake_tx.send(wake).unwrap();
1319 });
1320
1321 thread::sleep(Duration::from_millis(20));
1322 command_tx
1323 .send_blocking(cbf::browser::CommandEnvelope::RawOnly {
1324 raw: ChromeCommand::ForceShutdown,
1325 })
1326 .unwrap();
1327
1328 let wake = wake_rx.recv_timeout(Duration::from_millis(250)).unwrap();
1329 assert_eq!(wake, BackendWake::CommandReady);
1330 waiter_thread.join().unwrap();
1331 }
1332
1333 #[test]
1334 fn backend_input_and_terminal_wait_results_map_to_wakes() {
1335 let (_command_tx, command_rx) =
1336 unbounded::<cbf::browser::CommandEnvelope<ChromiumBackend>>();
1337 let (wait_tx, wait_rx) = std::sync::mpsc::channel();
1338 let event_loop = ChromiumBackendEventLoop::new(command_rx, StubWaiter { rx: wait_rx });
1339
1340 wait_tx.send(Ok(EventWaitResult::EventAvailable)).unwrap();
1341 assert_eq!(
1342 event_loop.wait_until(Some(Instant::now() + Duration::from_secs(1))),
1343 BackendWake::BackendInputReady
1344 );
1345 event_loop.acknowledge_backend_input();
1346
1347 wait_tx.send(Ok(EventWaitResult::Disconnected)).unwrap();
1348 assert_eq!(
1349 event_loop.wait_until(Some(Instant::now() + Duration::from_secs(1))),
1350 BackendWake::Stopped
1351 );
1352 assert_eq!(
1353 event_loop.stop_reason(),
1354 Some(BackendStopReason::Disconnected)
1355 );
1356 }
1357
1358 #[test]
1359 fn ready_wake_classification_prefers_pending_command() {
1360 let mut inner = WakeStateInner {
1361 command_channel_closed: true,
1362 backend_input_ready: true,
1363 backend_terminal: Some(EventWaitResult::Disconnected),
1364 wait_error: Some(super::IpcError::ConnectionFailed),
1365 ..WakeStateInner::default()
1366 };
1367 inner.pending_commands.push_back(sample_pending_command());
1368
1369 assert_eq!(classify_ready_wake(&inner), Some(BackendWake::CommandReady));
1370 }
1371
1372 #[test]
1373 fn ready_wake_classification_prefers_backend_input_over_stop() {
1374 let inner = WakeStateInner {
1375 command_channel_closed: true,
1376 backend_input_ready: true,
1377 backend_terminal: Some(EventWaitResult::Closed),
1378 ..WakeStateInner::default()
1379 };
1380
1381 assert_eq!(
1382 classify_ready_wake(&inner),
1383 Some(BackendWake::BackendInputReady)
1384 );
1385 }
1386
1387 #[test]
1388 fn ready_wake_classification_returns_stopped_only_after_work_is_drained() {
1389 let stopped = WakeStateInner {
1390 command_channel_closed: true,
1391 ..WakeStateInner::default()
1392 };
1393 let idle = WakeStateInner::default();
1394
1395 assert_eq!(classify_ready_wake(&stopped), Some(BackendWake::Stopped));
1396 assert_eq!(classify_ready_wake(&idle), None);
1397 }
1398
1399 #[test]
1400 fn deadline_classification_handles_none_due_and_future() {
1401 let now = Instant::now();
1402
1403 assert_eq!(classify_deadline(now, None), DeadlineStatus::None);
1404 assert_eq!(
1405 classify_deadline(now, Some(now - Duration::from_millis(1))),
1406 DeadlineStatus::Reached
1407 );
1408 assert_eq!(
1409 classify_deadline(now, Some(now + Duration::from_secs(1))),
1410 DeadlineStatus::Pending
1411 );
1412 }
1413
1414 #[test]
1415 fn timeout_wake_classification_falls_back_to_deadline_when_idle() {
1416 let idle = WakeStateInner::default();
1417 let mut command_ready = WakeStateInner::default();
1418 command_ready
1419 .pending_commands
1420 .push_back(sample_pending_command());
1421
1422 assert_eq!(classify_timeout_wake(&idle), BackendWake::DeadlineReached);
1423 assert_eq!(
1424 classify_timeout_wake(&command_ready),
1425 BackendWake::CommandReady
1426 );
1427 }
1428
1429 #[test]
1430 fn stop_reason_classification_requires_pending_work_to_be_drained() {
1431 let mut pending_command = WakeStateInner {
1432 command_channel_closed: true,
1433 ..WakeStateInner::default()
1434 };
1435 pending_command
1436 .pending_commands
1437 .push_back(sample_pending_command());
1438 let backend_input = WakeStateInner {
1439 command_channel_closed: true,
1440 backend_input_ready: true,
1441 ..WakeStateInner::default()
1442 };
1443 let wait_error = WakeStateInner {
1444 command_channel_closed: true,
1445 wait_error: Some(super::IpcError::ConnectionFailed),
1446 ..WakeStateInner::default()
1447 };
1448
1449 assert_eq!(stop_reason_from_wake_state(&pending_command), None);
1450 assert_eq!(stop_reason_from_wake_state(&backend_input), None);
1451 assert_eq!(stop_reason_from_wake_state(&wait_error), None);
1452 }
1453
1454 #[test]
1455 fn stop_reason_classification_maps_only_terminal_states() {
1456 let command_closed = WakeStateInner {
1457 command_channel_closed: true,
1458 ..WakeStateInner::default()
1459 };
1460 let disconnected = WakeStateInner {
1461 backend_terminal: Some(EventWaitResult::Disconnected),
1462 ..WakeStateInner::default()
1463 };
1464 let closed = WakeStateInner {
1465 backend_terminal: Some(EventWaitResult::Closed),
1466 ..WakeStateInner::default()
1467 };
1468 let event_available = WakeStateInner {
1469 backend_terminal: Some(EventWaitResult::EventAvailable),
1470 ..WakeStateInner::default()
1471 };
1472 let timed_out = WakeStateInner {
1473 backend_terminal: Some(EventWaitResult::TimedOut),
1474 ..WakeStateInner::default()
1475 };
1476
1477 assert_eq!(
1478 stop_reason_from_wake_state(&command_closed),
1479 Some(BackendStopReason::Disconnected)
1480 );
1481 assert_eq!(
1482 stop_reason_from_wake_state(&disconnected),
1483 Some(BackendStopReason::Disconnected)
1484 );
1485 assert_eq!(
1486 stop_reason_from_wake_state(&closed),
1487 Some(BackendStopReason::Disconnected)
1488 );
1489 assert_eq!(stop_reason_from_wake_state(&event_available), None);
1490 assert_eq!(stop_reason_from_wake_state(&timed_out), None);
1491 }
1492
1493 #[test]
1494 fn normalize_stop_reason_preserves_disconnected_without_shutdown_proceeding() {
1495 assert_eq!(
1496 normalize_stop_reason(BackendStopReason::Disconnected, ShutdownState::Idle),
1497 BackendStopReason::Disconnected
1498 );
1499 }
1500
1501 #[test]
1502 fn normalize_stop_reason_maps_transport_disconnect_after_shutdown_proceeding() {
1503 assert_eq!(
1504 normalize_stop_reason(
1505 BackendStopReason::Disconnected,
1506 ShutdownState::Proceeding { request_id: 7 }
1507 ),
1508 BackendStopReason::ShutdownRequested
1509 );
1510 }
1511
1512 #[test]
1513 fn update_shutdown_state_tracks_proceeding_and_cancelled_events() {
1514 let mut shutdown_state = ShutdownState::Idle;
1515
1516 update_shutdown_state(
1517 &mut shutdown_state,
1518 &IpcEvent::ShutdownProceeding { request_id: 11 },
1519 );
1520 assert_eq!(shutdown_state, ShutdownState::Proceeding { request_id: 11 });
1521
1522 update_shutdown_state(
1523 &mut shutdown_state,
1524 &IpcEvent::ShutdownCancelled { request_id: 11 },
1525 );
1526 assert_eq!(shutdown_state, ShutdownState::Idle);
1527 }
1528
1529 #[test]
1530 fn update_shutdown_state_ignores_shutdown_blocked() {
1531 let mut shutdown_state = ShutdownState::Idle;
1532
1533 update_shutdown_state(
1534 &mut shutdown_state,
1535 &IpcEvent::ShutdownBlocked {
1536 request_id: 5,
1537 dirty_browsing_context_ids: Vec::new(),
1538 },
1539 );
1540
1541 assert_eq!(shutdown_state, ShutdownState::Idle);
1542 }
1543
1544 #[derive(Clone)]
1545 struct RecordTeardownDelegate {
1546 reasons: Arc<Mutex<Vec<BackendStopReason>>>,
1547 }
1548
1549 impl BackendDelegate for RecordTeardownDelegate {
1550 fn on_teardown(&mut self, _ctx: &mut DelegateContext, reason: BackendStopReason) {
1551 self.reasons.lock().unwrap().push(reason);
1552 }
1553 }
1554
1555 #[test]
1556 fn stop_backend_passes_normalized_shutdown_reason_to_teardown() {
1557 let reasons = Arc::new(Mutex::new(Vec::new()));
1558 let delegate = RecordTeardownDelegate {
1559 reasons: Arc::clone(&reasons),
1560 };
1561 let mut dispatcher = cbf::delegate::DelegateDispatcher::new(delegate);
1562 let (event_tx, _event_rx) = async_channel::unbounded();
1563
1564 ChromiumBackend::stop_backend(
1565 normalize_stop_reason(
1566 BackendStopReason::Disconnected,
1567 ShutdownState::Proceeding { request_id: 1 },
1568 ),
1569 &mut dispatcher,
1570 None,
1571 &event_tx,
1572 );
1573
1574 let recorded = reasons.lock().unwrap().clone();
1575 assert_eq!(recorded, vec![BackendStopReason::ShutdownRequested]);
1576 }
1577}