Skip to main content

cbf_chrome/
backend.rs

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/// Backend implementation that speaks the Chromium IPC protocol.
30#[derive(Debug)]
31pub struct ChromiumBackend {
32    _options: ChromiumBackendOptions,
33    client: IpcClient,
34}
35
36/// Options for controlling the Chromium backend.
37#[derive(Debug, Default, Clone)]
38pub struct ChromiumBackendOptions {}
39
40impl ChromiumBackendOptions {
41    /// Create default backend options.
42    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/// Decision returned from [`ChromeRawDelegate::on_raw_command`].
103#[derive(Debug)]
104pub enum RawCommandDecision {
105    /// Forward the raw command to Chromium transport.
106    Forward,
107    /// Drop the raw command and continue processing.
108    Drop,
109    /// Stop backend processing with the given reason.
110    Stop(BackendStopReason),
111}
112
113/// Hook-based interface for mediating Chromium raw command flow.
114pub trait ChromeRawDelegate: Send + 'static {
115    /// Called for each raw command sent through `send_raw`.
116    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    /// Create a backend from a pre-connected IPC client.
476    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        // Client is already connected; emit BackendReady and start the event loop.
493        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        // Notify that the backend is ready. The connection is already established.
515        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        // SAFETY: `IpcClient` is a raw pointer wrapper. A null pointer is a valid
1265        // inert state for this test path because `poll_event`/`drop` both handle null.
1266        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}