Skip to main content

forge_workspace/
browser.rs

1//! The browser the sessions drive: the port the tools ask through, and the
2//! hand-offs that park on the person.
3//!
4//! **A port, not a dependency.** forge-server owns the browser fleet - the
5//! CEF hosts, the drivers and the profiles - and implements [`BrowserHost`],
6//! so a session's call reaches a browser without this crate knowing where
7//! that browser lives. A workspace with no fleet installed is a test or a
8//! view; its browser tools answer [`NO_BROWSER_HOST`].
9
10use forge_primitives::SessionSlot;
11use forge_primitives::browser::{HandOff, HandOffAnswer, HandOffEnding};
12use serde_json::Value;
13use tokio::sync::oneshot;
14
15use crate::workspace::Workspace;
16
17/// A viewer's handle on one tab, as the transport keeps it.
18pub struct BrowserAttachment {
19    /// The profile's key, for the calls that follow (input, control, nav).
20    pub key: String,
21    /// The id the binary frames carry.
22    pub id: u16,
23    /// The tab this viewer is on, for every call that follows.
24    pub tab: u16,
25    /// The newest frame, and a way to follow it.
26    pub frames: tokio::sync::watch::Receiver<Option<forge_primitives::browser::BrowserFrame>>,
27    /// The tab's controller and the viewer count, as they change.
28    pub view: tokio::sync::watch::Receiver<forge_primitives::browser::BrowserViewState>,
29    /// The profile's tabs, as they change.
30    pub tabs: tokio::sync::watch::Receiver<forge_primitives::browser::BrowserTabList>,
31    /// Where the tab's history sits, as it moves - the bar's arrows read
32    /// this rather than asking after every click.
33    pub nav: tokio::sync::watch::Receiver<Option<forge_primitives::browser::BrowserNavState>>,
34    /// Why the profile's browser stopped, when it did.
35    pub failures: tokio::sync::watch::Receiver<Option<String>>,
36    /// A page's file chooser, while a person has to answer one.
37    pub files: tokio::sync::watch::Receiver<Option<forge_primitives::browser::BrowserFileAsk>>,
38    /// Finished downloads, each once, for the viewer that takes it.
39    pub downloads: tokio::sync::broadcast::Receiver<forge_primitives::browser::BrowserDownload>,
40    /// The page's own audio, streamed while it plays.
41    pub audio: tokio::sync::broadcast::Receiver<forge_primitives::browser::BrowserAudio>,
42}
43
44/// A live-seat snapshot and the generation its capture took.
45///
46/// [`Workspace::live_seats`] serialises captures, so the generation orders
47/// whole seat sets; a sweep applies only a newer one (see [`BrowserHost`]).
48pub struct LiveSeats {
49    pub generation: u64,
50    pub seats: Vec<SessionSlot>,
51}
52
53/// The server's browser fleet, as the workspace calls it.
54#[async_trait::async_trait]
55pub trait BrowserHost: Send + Sync {
56    /// Run one browser tool through the tab the calling session drives.
57    async fn call(
58        &self,
59        seat: &SessionSlot,
60        tool: &str,
61        args: Value,
62    ) -> Result<Vec<forge_primitives::browser::BrowserPart>, String>;
63
64    /// Attach a viewer to one of the seat's tabs. `tab` is `None` for the
65    /// seat's current tab, else its profile's first.
66    ///
67    /// **The attach moves the viewer, and the role with it**: a same-tab
68    /// attach by the viewer already there keeps the controller role it holds
69    /// there, and any other attach (another tab, another profile) releases
70    /// what it held on the one it leaves. An implementation that detaches the
71    /// viewer first turns every re-attach into a move and takes the role with
72    /// it, which is the silent failure this rule exists to prevent.
73    async fn attach(
74        &self,
75        viewer: u64,
76        seat: &SessionSlot,
77        tab: Option<u16>,
78    ) -> Result<BrowserAttachment, String>;
79
80    /// Attach to the tab a page target names, for a hand-off that outlived
81    /// the host which minted its number.
82    async fn attach_page(
83        &self,
84        _viewer: u64,
85        _seat: &SessionSlot,
86        _target: &str,
87    ) -> Result<BrowserAttachment, String> {
88        Err("this host cannot open a page by id".to_owned())
89    }
90
91    /// A viewer's connection ended.
92    async fn detach(&self, viewer: u64);
93
94    /// One input event, only from the viewer that holds the role on that tab.
95    async fn input(
96        &self,
97        profile: &str,
98        tab: u16,
99        viewer: u64,
100        seq: u64,
101        event: &forge_primitives::browser::BrowserInputEvent,
102    ) -> Result<(), String>;
103
104    /// Answer a page's file chooser: the files a person chose, or none.
105    async fn answer_files(
106        &self,
107        profile: &str,
108        tab: u16,
109        id: u64,
110        files: Vec<forge_primitives::browser::BrowserUpload>,
111    ) -> Result<(), String>;
112
113    /// Give `viewer` the controller role on one tab.
114    async fn take_control(&self, profile: &str, tab: u16, viewer: u64) -> Result<(), String>;
115
116    /// Lay one tab out to the controller's surface: device pixels and the
117    /// ratio they draw at. **Only the controller's size applies**; a
118    /// watcher's window does not move the page under the person driving it.
119    async fn resize(
120        &self,
121        profile: &str,
122        tab: u16,
123        viewer: u64,
124        width: u32,
125        height: u32,
126        scale: f64,
127    );
128
129    /// Release the role, when this viewer still holds it on that tab.
130    async fn release_control(&self, profile: &str, tab: u16, viewer: u64);
131
132    /// Move one tab through its history, answering where it now sits. **Only
133    /// the controller moves the page**: a watcher's move would move it under
134    /// the person driving it.
135    async fn nav(
136        &self,
137        profile: &str,
138        tab: u16,
139        viewer: u64,
140        back: bool,
141    ) -> Result<forge_primitives::browser::BrowserNavState, String>;
142
143    /// Where one tab's history sits, when the browser can answer.
144    async fn nav_state(
145        &self,
146        profile: &str,
147        tab: u16,
148    ) -> Option<forge_primitives::browser::BrowserNavState>;
149
150    /// A seat's tabs as its strip draws them.
151    async fn tabs(
152        &self,
153        seat: &SessionSlot,
154    ) -> Result<forge_primitives::browser::BrowserTabList, String>;
155
156    /// A stream of a seat's tab rows, for a strip that follows them.
157    async fn tab_stream(
158        &self,
159        seat: &SessionSlot,
160    ) -> Result<tokio::sync::watch::Receiver<forge_primitives::browser::BrowserTabList>, String>;
161
162    /// The seats that remain: a tab whose session is gone is closed, except a
163    /// tab a parked hand-off holds. Called when a seat is torn down.
164    /// **`generation` orders captures** ([`LiveSeats`]): a sweep at or below
165    /// the last applied one is dropped, so a stale snapshot cannot re-admit
166    /// an ended seat.
167    async fn sweep_sessions(&self, seats: &[SessionSlot], generation: u64);
168
169    /// A `/new` or `/resume`: the slot stays and the previous occupant's tabs
170    /// close, except a tab a parked hand-off holds.
171    async fn replace_seat(&self, seat: &SessionSlot);
172
173    /// **The seat has ended**, as opposed to a sweep's guess: the fleet must
174    /// answer a hand-off resolution from this fact, so a closed lead's held
175    /// tab does not survive Done. Synchronous, so it lands before the close
176    /// work that follows it. A successor clears it by warming or calling.
177    fn session_ended(&self, seat: &SessionSlot);
178
179    /// A hand-off parked on one of the seat's pages, named by the page id its
180    /// listing printed: the tab is held against the sweep and the idle clock
181    /// until it resolves. Answers the tab number the ask raises. **Refused
182    /// when no tab has that page or it is not this seat's**, before any ask
183    /// reaches a person.
184    async fn hand_off_started(&self, seat: &SessionSlot, target: &str) -> Result<u16, String>;
185
186    /// A hand-off left the registry: the tab is released, and a session that
187    /// went while it was held takes its tab with it.
188    async fn hand_off_finished(&self, seat: &SessionSlot, tab: u16);
189
190    /// Create the seat's profile entry and start its host, with no tab. The
191    /// first seat of a profile calls it at spawn; later seats reuse the host.
192    async fn warm(&self, seat: &SessionSlot);
193
194    /// End every open browser and its driver, keeping the profiles on disk.
195    /// **The hosts are the server's children**, so a clean stop ends them; a
196    /// profile's logins stay, and **no tab survives a host** - the next boot's
197    /// first call opens a fresh tab. Answers the profiles whose host outlived
198    /// the teardown, named, so a stop that leaves a process behind cannot
199    /// report success.
200    async fn stop(&self) -> Vec<String>;
201}
202
203/// The error a browser tool answers with when the workspace has no fleet.
204pub const NO_BROWSER_HOST: &str = "this server has no browser host installed";
205
206impl Workspace {
207    /// Hold a browser hand-off for the person at a client, and hand back its
208    /// id plus the receiver the caller awaits.
209    ///
210    /// **No timeout rides the wait.** The ask dock's own timelines say a held
211    /// prompt sits for hours, so nothing here ends it but an answer or the
212    /// caller's own drop. The one case answered up front is a stream nobody
213    /// can answer on: holding a session on a prompt no view can draw would
214    /// park it forever, which is the failure this path exists to refuse.
215    pub(crate) async fn register_browser_hand_off(
216        &self,
217        caller: &SessionSlot,
218        reason: &str,
219        target: &str,
220    ) -> Result<(uuid::Uuid, oneshot::Receiver<HandOffEnding>), String> {
221        let (sender, receiver) = oneshot::channel();
222        let id = uuid::Uuid::new_v4();
223        // **The hold is the service's, and it is awaited.** A sweep that ran
224        // between a detached hold and the ask would see the tab as an orphan
225        // and close it under the person, and a page that is not this seat's
226        // would send the person to a document the session never opened: the
227        // fleet's refusal is the tool call's refusal, before any ask exists.
228        // **The page id becomes the tab number here**, under that same check:
229        // the ask a view draws names the number, and the page id rides beside
230        // it so an open can resolve the tab again after a host replacement.
231        let tab = self.browser_hand_off_started(caller, target).await?;
232        let handoff = HandOff { id, reason: reason.to_owned(), tab, target: target.to_owned() };
233        self.browser_handoffs.lock().insert(id, (caller.clone(), handoff.clone(), sender));
234        let answerable = self.update_sender().send_answering(
235            crate::protocol::SessionUpdate::BrowserHandOffPending { key: caller.clone(), handoff },
236        );
237        if !answerable {
238            self.browser_handoffs.lock().remove(&id);
239            // **A raise no view can show has no ask to resolve**, so the hold
240            // it took is given back here rather than left on the tab forever.
241            self.tell_browser_hand_off_finished(caller, tab);
242            let (_ignored_sender, dead_receiver) = oneshot::channel();
243            return Ok((id, dead_receiver));
244        }
245        self.note_ask(caller);
246        Ok((id, receiver))
247    }
248
249    /// Take the fleet's hold for a hand-off, awaited so its refusal is the
250    /// tool call's. Answers the tab number the fleet resolved the page id to,
251    /// or its own sentence for a page that is not open or not this seat's.
252    async fn browser_hand_off_started(
253        &self,
254        caller: &SessionSlot,
255        target: &str,
256    ) -> Result<u16, String> {
257        let Some(host) = self.browser_host() else {
258            return Err(NO_BROWSER_HOST.to_owned());
259        };
260        host.hand_off_started(caller, target).await
261    }
262
263    /// Tell the fleet a hand-off left its tab: the service acts on a tab that
264    /// is still held, and a resolution for anything else touches nothing.
265    pub(crate) fn tell_browser_hand_off_finished(&self, caller: &SessionSlot, tab: u16) {
266        let (Some(host), Ok(runtime)) =
267            (self.browser_host(), tokio::runtime::Handle::try_current())
268        else {
269            return;
270        };
271        let seat = caller.clone();
272        runtime.spawn(async move { host.hand_off_finished(&seat, tab).await });
273    }
274
275    /// Whether a hand-off with this id is still registered to `caller`,
276    /// without removing it - the read the dispatch guard makes before an
277    /// answer.
278    pub(crate) fn browser_handoff_waiting(&self, id: uuid::Uuid, caller: &SessionSlot) -> bool {
279        self.browser_handoffs.lock().get(&id).is_some_and(|(owner, _, _)| owner == caller)
280    }
281
282    /// Remove a parked hand-off, answer its waiter, and tell every view it is
283    /// gone. `false` when the id is not this caller's to resolve.
284    pub(crate) fn resolve_browser_hand_off(
285        &self,
286        id: uuid::Uuid,
287        caller: &SessionSlot,
288        answer: HandOffAnswer,
289    ) -> bool {
290        let mut parked = self.browser_handoffs.lock();
291        if !parked.get(&id).is_some_and(|(owner, _, _)| owner == caller) {
292            return false;
293        }
294        let Some((owner, handoff, sender)) = parked.remove(&id) else { return false };
295        drop(parked);
296        // **The tab is released with the answer**, whichever ending it
297        // carried: a resolved hand-off holds nothing.
298        self.tell_browser_hand_off_finished(&owner, handoff.tab);
299        // **Every ending names the hand that sent it**: an answer with
300        // nothing attributable behind it is what two live rounds were spent
301        // on (2026-10-07, 2026-10-26), so the door, the connection and the
302        // seat and tab the hand-off held are all here.
303        tracing::debug!(
304            event_name = "browser_hand_off_resolved",
305            id = %id,
306            ending = ?answer.ending,
307            source = ?answer.source,
308            client = answer.client,
309            seat = %owner.display(),
310            tab = handoff.tab,
311            "a parked browser hand-off left the registry"
312        );
313        let _ = sender.send(answer.ending);
314        let _ = self.update_sender().send(crate::protocol::SessionUpdate::BrowserHandOffResolved {
315            key: owner,
316            id,
317            answer,
318        });
319        true
320    }
321}
322
323/// A fleet for the crate's own tests: calls land on a channel, and the
324/// viewer half answers refusals nothing asks for.
325#[cfg(test)]
326pub(crate) mod testing {
327    use super::*;
328    use forge_primitives::browser::{BrowserInputEvent, BrowserNavState, BrowserPart};
329    use tokio::sync::mpsc;
330
331    pub(crate) struct StubHost {
332        pub(crate) calls: mpsc::UnboundedSender<(String, Value)>,
333    }
334
335    impl StubHost {
336        fn heard(&self, what: &str, args: Value) {
337            let _ = self.calls.send((what.to_owned(), args));
338        }
339    }
340
341    #[async_trait::async_trait]
342    impl BrowserHost for StubHost {
343        async fn call(
344            &self,
345            _seat: &SessionSlot,
346            tool: &str,
347            args: Value,
348        ) -> Result<Vec<BrowserPart>, String> {
349            self.heard(tool, args);
350            Ok(vec![BrowserPart::Text { text: "clicked".to_owned() }])
351        }
352
353        async fn attach(
354            &self,
355            _viewer: u64,
356            _seat: &SessionSlot,
357            _tab: Option<u16>,
358        ) -> Result<BrowserAttachment, String> {
359            Err("no viewers here".to_owned())
360        }
361
362        async fn detach(&self, _viewer: u64) {}
363
364        async fn input(
365            &self,
366            _profile: &str,
367            _tab: u16,
368            _viewer: u64,
369            _seq: u64,
370            _event: &BrowserInputEvent,
371        ) -> Result<(), String> {
372            Err("no viewers here".to_owned())
373        }
374
375        async fn answer_files(
376            &self,
377            _profile: &str,
378            _tab: u16,
379            _id: u64,
380            _files: Vec<forge_primitives::browser::BrowserUpload>,
381        ) -> Result<(), String> {
382            Err("no viewers here".to_owned())
383        }
384
385        async fn take_control(
386            &self,
387            _profile: &str,
388            _tab: u16,
389            _viewer: u64,
390        ) -> Result<(), String> {
391            Err("no viewers here".to_owned())
392        }
393
394        async fn resize(
395            &self,
396            _profile: &str,
397            _tab: u16,
398            _viewer: u64,
399            _width: u32,
400            _height: u32,
401            _scale: f64,
402        ) {
403        }
404
405        async fn release_control(&self, _profile: &str, _tab: u16, _viewer: u64) {}
406
407        async fn nav(
408            &self,
409            _profile: &str,
410            _tab: u16,
411            _viewer: u64,
412            _back: bool,
413        ) -> Result<BrowserNavState, String> {
414            Err("no viewers here".to_owned())
415        }
416
417        async fn nav_state(&self, _profile: &str, _tab: u16) -> Option<BrowserNavState> {
418            None
419        }
420
421        async fn tabs(
422            &self,
423            _seat: &SessionSlot,
424        ) -> Result<forge_primitives::browser::BrowserTabList, String> {
425            Err("no viewers here".to_owned())
426        }
427
428        async fn tab_stream(
429            &self,
430            _seat: &SessionSlot,
431        ) -> Result<tokio::sync::watch::Receiver<forge_primitives::browser::BrowserTabList>, String>
432        {
433            Err("no viewers here".to_owned())
434        }
435
436        async fn sweep_sessions(&self, seats: &[SessionSlot], generation: u64) {
437            self.heard(
438                "sweep_sessions",
439                serde_json::json!({ "seats": seats, "generation": generation }),
440            );
441        }
442
443        async fn replace_seat(&self, seat: &SessionSlot) {
444            self.heard("replace_seat", serde_json::json!(seat));
445        }
446
447        fn session_ended(&self, seat: &SessionSlot) {
448            self.heard("session_ended", serde_json::json!(seat));
449        }
450
451        async fn hand_off_started(&self, seat: &SessionSlot, target: &str) -> Result<u16, String> {
452            self.heard("hand_off_started", serde_json::json!({ "seat": seat, "page": target }));
453            // `gone` stands in for a page no tab carries, so the refusal has a
454            // caller; every other id resolves to the tab a hand-off raises.
455            if target == "gone" {
456                return Err(format!("no tab has the page id '{target}'"));
457            }
458            Ok(2)
459        }
460
461        async fn hand_off_finished(&self, seat: &SessionSlot, tab: u16) {
462            self.heard("hand_off_finished", serde_json::json!({ "seat": seat, "tab": tab }));
463        }
464
465        async fn warm(&self, seat: &SessionSlot) {
466            self.heard("warm", serde_json::json!(seat));
467        }
468
469        async fn stop(&self) -> Vec<String> {
470            Vec::new()
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::browser::testing::StubHost;
479    use forge_primitives::browser::HandOffSource;
480
481    /// One answer from the given door, with a connection behind it.
482    fn answer(ending: HandOffEnding, source: HandOffSource) -> HandOffAnswer {
483        HandOffAnswer { ending, source, client: Some(4) }
484    }
485
486    /// A workspace with a fleet installed and the calls it is told.
487    fn with_host(
488        ws: &std::sync::Arc<Workspace>,
489    ) -> tokio::sync::mpsc::UnboundedReceiver<(String, serde_json::Value)> {
490        let (calls, heard) = tokio::sync::mpsc::unbounded_channel();
491        ws.install_browser_host(std::sync::Arc::new(StubHost { calls }));
492        heard
493    }
494
495    /// **A hand-off is addressed to the session that asked**, so the dock the
496    /// person answers belongs to the session waiting on it rather than to
497    /// whichever one happens to be focused.
498    #[tokio::test]
499    async fn a_hand_off_is_addressed_to_the_session_that_asked() {
500        let (ws, mut rx) = Workspace::testing_stub();
501        let _heard = with_host(&ws);
502        let caller = SessionSlot::from_str_for_test("caller-uuid");
503        let (_id, _answer) =
504            ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
505
506        let mut addressed = Vec::new();
507        while let Ok(frame) = rx.try_recv() {
508            if let crate::protocol::SessionUpdate::BrowserHandOffPending { key, .. } =
509                std::sync::Arc::unwrap_or_clone(frame.update)
510            {
511                addressed.push(key);
512            }
513        }
514        assert_eq!(addressed, vec![caller], "the hand-off is addressed to the asker");
515    }
516
517    /// **A parked hand-off holds its tab in the fleet before the ask is
518    /// parked.** A sweep that ran between the two would read the tab as an
519    /// orphan and close it under the person.
520    #[tokio::test]
521    async fn registering_a_hand_off_holds_its_tab_in_the_fleet() {
522        let (ws, _rx) = Workspace::testing_stub();
523        let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
524        ws.install_browser_host(std::sync::Arc::new(StubHost { calls }));
525        let caller = SessionSlot::from_str_for_test("caller-uuid");
526
527        let (_id, _answer) =
528            ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
529
530        let (what, args) = heard.recv().await.expect("the fleet hears the hold");
531        assert_eq!(what, "hand_off_started", "the fleet is told which page is held");
532        assert_eq!(args, serde_json::json!({ "seat": caller, "page": "T2" }));
533    }
534
535    /// **The answer releases the tab.** A resolved hand-off holds nothing, so
536    /// the sweep and the idle clock see it like any other tab again.
537    #[tokio::test]
538    async fn resolving_a_hand_off_releases_its_tab() {
539        let (ws, _rx) = Workspace::testing_stub();
540        let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
541        ws.install_browser_host(std::sync::Arc::new(StubHost { calls }));
542        let caller = SessionSlot::from_str_for_test("caller-uuid");
543        let (id, _answer) =
544            ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
545        let started = heard.recv().await.map(|(what, _)| what);
546        assert_eq!(started, Some("hand_off_started".to_owned()));
547
548        assert!(ws.resolve_browser_hand_off(
549            id,
550            &caller,
551            answer(HandOffEnding::Done, HandOffSource::Takeover),
552        ));
553
554        let (what, args) = heard.recv().await.expect("the fleet hears the release");
555        assert_eq!(what, "hand_off_finished", "the ending releases the tab it held");
556        assert_eq!(args, serde_json::json!({ "seat": caller, "tab": 2 }));
557    }
558
559    /// A raise no view can show still took its hold, so that hold is given
560    /// back rather than left on the tab forever.
561    #[tokio::test]
562    async fn a_hand_off_no_view_can_show_releases_its_tab() {
563        let (ws, rx) = Workspace::testing_stub();
564        drop(rx);
565        let (calls, mut heard) = tokio::sync::mpsc::unbounded_channel();
566        ws.install_browser_host(std::sync::Arc::new(StubHost { calls }));
567        let caller = SessionSlot::from_str_for_test("caller-uuid");
568
569        let (_id, answer) =
570            ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
571        assert!(answer.await.is_err(), "the fail-closed read");
572
573        let mut calls = Vec::new();
574        while let Some((what, _)) = heard.recv().await {
575            calls.push(what);
576            if calls.len() == 2 {
577                break;
578            }
579        }
580        assert_eq!(
581            calls,
582            vec!["hand_off_started".to_owned(), "hand_off_finished".to_owned()],
583            "the hold taken for an unanswerable raise is released",
584        );
585    }
586
587    /// Answering a hand-off nobody parked is refused rather than answered
588    /// into the void.
589    #[tokio::test]
590    async fn answering_a_hand_off_that_is_not_pending_is_refused() {
591        let (ws, _rx) = Workspace::testing_stub();
592        let caller = SessionSlot::from_str_for_test("caller-uuid");
593        assert!(
594            !ws.resolve_browser_hand_off(
595                uuid::Uuid::new_v4(),
596                &caller,
597                answer(HandOffEnding::NotNow, HandOffSource::Takeover),
598            ),
599            "a hand-off that is not parked cannot be resolved",
600        );
601    }
602
603    /// **No view can draw the dock: the caller fails closed rather than
604    /// parking forever.** This is the one case a wait does not hold for, and
605    /// it must not hold the session on a prompt nothing can answer.
606    #[tokio::test]
607    async fn a_hand_off_with_no_ui_to_answer_it_fails_closed() {
608        let (ws, rx) = Workspace::testing_stub();
609        drop(rx);
610        let _heard = with_host(&ws);
611        let caller = SessionSlot::from_str_for_test("caller-uuid");
612        let (_id, answer) =
613            ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
614
615        assert!(
616            ws.browser_handoffs.lock().is_empty(),
617            "an unanswerable hand-off is dropped, not held forever",
618        );
619        assert!(answer.await.is_err(), "a dead receiver is the fail-closed read");
620    }
621
622    /// The resolve answers the blocked waiter AND tells the views it is gone,
623    /// so a dock standing elsewhere retires with the same ending the answer
624    /// carried.
625    #[tokio::test]
626    async fn resolving_a_hand_off_answers_its_waiter_and_tells_the_views() {
627        let (ws, mut rx) = Workspace::testing_stub();
628        let _heard = with_host(&ws);
629        let caller = SessionSlot::from_str_for_test("caller-uuid");
630        let (id, waiter) =
631            ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
632        while rx.try_recv().is_ok() {}
633
634        let taken = answer(HandOffEnding::Done, HandOffSource::Takeover);
635        assert!(ws.resolve_browser_hand_off(id, &caller, taken));
636
637        assert_eq!(
638            waiter.await.expect("the waiter is answered"),
639            HandOffEnding::Done,
640            "the ending the resolve carried is what the blocked handler gets",
641        );
642        let resolved = rx.try_recv().expect("the views hear it left the registry");
643        let crate::protocol::SessionUpdate::BrowserHandOffResolved { key, id: seen, answer: heard } =
644            std::sync::Arc::unwrap_or_clone(resolved.update)
645        else {
646            panic!("the update is the resolved one");
647        };
648        assert_eq!(key, caller);
649        assert_eq!(seen, id);
650        // **The whole answer crosses, not just its meaning**: a view that did
651        // not answer reads the ending, and the log names the door and the
652        // connection that did.
653        assert_eq!(heard, taken, "the door and the client ride the resolved update");
654    }
655
656    /// **A connection going away is not an answer.** The park lives in the
657    /// workspace's registry, not in a subscription: a client whose socket
658    /// drops under a parked ask must leave it waiting, never end it as a
659    /// decline on the person's behalf.
660    #[tokio::test]
661    async fn a_subscriber_going_away_does_not_answer_a_parked_hand_off() {
662        let (ws, rx) = Workspace::testing_stub();
663        let _heard = with_host(&ws);
664        let caller = SessionSlot::from_str_for_test("caller-uuid");
665        let (id, mut waiter) =
666            ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
667
668        drop(rx);
669
670        assert!(ws.browser_handoff_waiting(id, &caller), "the park is still the core's");
671        assert!(
672            matches!(waiter.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty)),
673            "and the blocked call is still waiting for the person",
674        );
675    }
676
677    /// **A person who says something is off is not a person who declined.**
678    /// The note line sends the session their words and ends the park as
679    /// `Feedback`, which the agent addresses before handing the page off
680    /// again; `NotNow` is the deferral, not this.
681    #[tokio::test]
682    async fn a_feedback_answer_reaches_the_waiter_as_feedback() {
683        let (ws, _rx) = Workspace::testing_stub();
684        let _heard = with_host(&ws);
685        let caller = SessionSlot::from_str_for_test("caller-uuid");
686        let (id, waiter) =
687            ws.register_browser_hand_off(&caller, "solve the CAPTCHA", "T2").await.expect("parks");
688
689        assert!(ws.resolve_browser_hand_off(
690            id,
691            &caller,
692            answer(HandOffEnding::Feedback, HandOffSource::Feedback),
693        ));
694
695        assert_eq!(
696            waiter.await.expect("the waiter is answered"),
697            HandOffEnding::Feedback,
698            "the person says something is off: the ending is feedback, not a deferral",
699        );
700    }
701
702    /// Only the session that asked may resolve it: an answer from another
703    /// seat is refused by the owner read, not applied.
704    #[tokio::test]
705    async fn only_the_owner_resolves_a_hand_off() {
706        let (ws, _rx) = Workspace::testing_stub();
707        let _heard = with_host(&ws);
708        let owner = SessionSlot::from_str_for_test("owner-uuid");
709        let other = SessionSlot::from_str_for_test("other-uuid");
710        let (id, _answer) =
711            ws.register_browser_hand_off(&owner, "solve the CAPTCHA", "T2").await.expect("parks");
712
713        assert!(
714            !ws.resolve_browser_hand_off(
715                id,
716                &other,
717                answer(HandOffEnding::Done, HandOffSource::Takeover),
718            ),
719            "another seat's answer is refused",
720        );
721        assert!(ws.browser_handoff_waiting(id, &owner), "and the hand-off stays pending");
722    }
723}