Skip to main content

car_server_core/assistant/
browser_stream.rs

1//! One CDP screencast per browser, fanned out to the drawer AND to disk
2//! recording — with the privacy blackout applied per consumer.
3//!
4//! `car_browser::ScreencastPump` already fans one capture out to N
5//! consumers, and that is the layer this builds on. Two things it
6//! deliberately does not do, both of which the drawer needs, live here:
7//!
8//! - **Per-consumer gating.** Controller ruling R3 says the blackout
9//!   (user control, or a pending sign-in) must suspend model-facing
10//!   observation AND keep frames out of `browser_record`'s disk output,
11//!   while the drawer keeps streaming throughout. That is an asymmetric
12//!   gate, so it cannot live in the pump's own fan-out loop (which gates
13//!   every consumer at once, by design). [`FrameFanout::subscribe`] takes
14//!   a [`FrameAudience`]; [`publish`] skips [`FrameAudience::Model`]
15//!   consumers while the shared blackout flag is set.
16//! - **Following the active tab.** A pump is attached to ONE page. The
17//!   drawer must follow the agent across tab opens/closes/switches, so the
18//!   supervisor watches `ChromiumBackend::subscribe_tabs()` and re-attaches
19//!   the pump when the active tab changes. Consumers keep their
20//!   subscription across a re-attach — the fan-out list lives here, above
21//!   the pump, and outlives any single pump generation.
22//!
23//! Two properties carried over from the pump and preserved here:
24//!
25//! - **Zero consumers ⇒ zero frames.** The supervisor is spawned on the
26//!   first [`FrameFanout::subscribe`] and exits once the consumer list is
27//!   empty, stopping CDP capture on its way out. A browser nobody is
28//!   watching and nobody is recording pays nothing.
29//! - **Frames are change-driven**, so every frame carries a real wall-clock
30//!   `captured_at`. It is re-stamped PER CONSUMER against the instant that
31//!   consumer subscribed, because consumers arrive at different times and a
32//!   recording's manifest measures durations from its own start — not from
33//!   whenever the drawer happened to open.
34
35use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
36use std::sync::Arc;
37use std::time::{Duration, Instant};
38
39use car_browser::{
40    ChromiumBackend, FrameReceiver, ScreencastFrame, ScreencastPump, FRAME_CHANNEL_CAP,
41};
42use tokio::sync::{mpsc, watch, Mutex};
43
44/// How long [`FrameFanout::stop_supervisor`] waits for a supervisor to tear
45/// its own pump down before falling back to an abort. Long enough for the
46/// `Page.stopScreencast` round trip it is waiting on, short enough that a
47/// wedged CDP connection cannot stall `browser_record_start`.
48const SUPERVISOR_STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
49
50/// Who a frame consumer is, which is what decides whether the privacy
51/// blackout applies to it.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum FrameAudience {
54    /// A human watching the drawer. Never gated — the whole point of the
55    /// blackout is that the person driving keeps seeing the page while the
56    /// model does not.
57    Viewer,
58    /// Anything that outlives the moment or can reach the model — today
59    /// that is `browser_record`'s disk output. Gated: receives nothing
60    /// while a blackout is active (R3).
61    Model,
62}
63
64/// One registered frame consumer.
65struct Consumer {
66    tx: mpsc::Sender<ScreencastFrame>,
67    audience: FrameAudience,
68    /// When this consumer subscribed — the epoch its `captured_at` stamps
69    /// are measured against.
70    epoch: Instant,
71}
72
73/// Fan one frame out to every live consumer, skipping [`FrameAudience::Model`]
74/// consumers while `blackout` is set and pruning consumers whose receiver has
75/// gone away. Returns how many consumers actually received it.
76///
77/// This is the whole R3 recording gate: a dropped frame is never written to
78/// disk, so the produced video contains no frame captured inside the blackout
79/// window. `Viewer` consumers are unaffected.
80fn publish(consumers: &mut Vec<Consumer>, frame: &ScreencastFrame, blackout: bool) -> usize {
81    let mut delivered = 0usize;
82    consumers.retain(|c| {
83        if blackout && c.audience == FrameAudience::Model {
84            // Still a live consumer — just not one that may see this
85            // frame. Keeping it registered is what makes the recording
86            // resume by itself when the blackout lifts.
87            return true;
88        }
89        let stamped = ScreencastFrame {
90            jpeg: frame.jpeg.clone(),
91            viewport: frame.viewport,
92            captured_at: c.epoch.elapsed().as_secs_f64(),
93        };
94        // Bounded and dropping, per `car_browser::FRAME_CHANNEL_CAP`: this is
95        // the hop in front of the recorder's writer task, whose per-frame
96        // `std::fs::write` is the one consumer here that can genuinely fall
97        // behind Chrome. Unbounded, it grew a full-viewport JPEG per frame
98        // for as long as the disk stayed slow. A consumer that is merely
99        // behind stays registered — only a closed channel deregisters it.
100        match c.tx.try_send(stamped) {
101            Ok(()) => {
102                delivered += 1;
103                true
104            }
105            Err(mpsc::error::TrySendError::Full(_)) => true,
106            Err(mpsc::error::TrySendError::Closed(_)) => false,
107        }
108    });
109    delivered
110}
111
112/// The live-frame fan-out for one browser.
113pub struct FrameFanout {
114    consumers: Arc<Mutex<Vec<Consumer>>>,
115    /// Set by [`BrowserTools`](super::browser_tools::BrowserTools) on every
116    /// control-state change. Read per frame by the supervisor — an atomic
117    /// rather than a callback so the gate cannot deadlock against the
118    /// presentation lock the state change is already holding.
119    blackout: Arc<AtomicBool>,
120    /// The browser to capture, once one has launched. `None` until then —
121    /// subscribing before a browser exists is legal (the drawer opens on an
122    /// empty standing session) and simply yields no frames yet.
123    backend: Mutex<Option<Arc<ChromiumBackend>>>,
124    /// The running supervisor, if any. Ends on its own when the consumer
125    /// list empties — and CLEARS THIS SLOT as it goes, under this mutex.
126    ///
127    /// `Arc` because the supervisor task holds it too: liveness cannot be
128    /// read off `JoinHandle::is_finished()`. Every exit path observes "no
129    /// consumers" and then AWAITS (`pump.stop()` is a real CDP round trip)
130    /// before returning, so a consumer subscribing inside that window pushed
131    /// itself onto `consumers`, called `ensure_supervisor`, saw a task that
132    /// had not finished YET, and returned — leaving a registered consumer
133    /// with no supervisor, no capture, and nothing that polls. The slot is
134    /// the liveness signal precisely because the exiting task publishes into
135    /// it while holding this lock, which linearizes "decide to exit" against
136    /// "check the slot and spawn".
137    supervisor: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
138    /// Which supervisor the slot holds. Bumped by every spawn.
139    ///
140    /// The slot alone cannot answer "is this still the one I asked to stop":
141    /// a `subscribe` landing after the cancel legitimately spawns a
142    /// REPLACEMENT, and a live replacement is indistinguishable from a
143    /// still-running original — both are `Some(task)` with `is_finished()`
144    /// false. `stop_supervisor` then waited out its grace period on the wrong
145    /// task and ABORTED it, which is the abort-instead-of-clean-exit path the
146    /// cancel signal exists to avoid, applied to a supervisor that had done
147    /// nothing wrong: its pump's `Drop` stops the screencast on a detached
148    /// task, racing whatever starts next. Same family as `browser_view`'s
149    /// subscriber epoch — an identity that is not unique across the handover.
150    supervisor_generation: AtomicU64,
151    /// JPEG quality the pump captures at. `browser_record_start` can ask
152    /// for a different one; see [`Self::set_quality`].
153    quality: AtomicI64,
154    /// Bumped when the last consumer goes away, so the supervisor can stop
155    /// capture immediately instead of at the next frame.
156    ///
157    /// A `watch` rather than a `Notify`, because `Notify::notify_waiters()`
158    /// stores no permit — it wakes only tasks already parked at the instant
159    /// of the call. The supervisor spends several CDP round trips
160    /// (`page_handle`, `ScreencastPump::attach`, `pump.subscribe`) between
161    /// its emptiness check and the first poll of this signal, and a `prune`
162    /// landing anywhere in that window was dropped on the floor: the
163    /// supervisor then parked on a still page holding an armed screencast
164    /// with zero consumers, which is exactly what "zero consumers ⇒ zero
165    /// frames" exists to prevent. A watch retains the change for a receiver
166    /// created BEFORE it, so the edge cannot be missed.
167    idle: watch::Sender<u64>,
168    /// Bumped to ask the running supervisor to tear its pump down and exit
169    /// CLEANLY — see [`Self::stop_supervisor`]. Aborting the task instead
170    /// left `ScreencastPump::drop` to stop the screencast on a DETACHED
171    /// task, which then races the replacement supervisor's
172    /// `Page.startScreencast` on the same page.
173    cancel: watch::Sender<u64>,
174    /// Serializes [`Self::stop_supervisor`] against itself. Held for the whole
175    /// teardown, unlike the supervisor slot — which must stay populated so a
176    /// concurrent `ensure_supervisor` does not spawn a replacement into the
177    /// window where the old pump is still stopping.
178    teardown: Mutex<()>,
179    width: u32,
180    height: u32,
181}
182
183impl FrameFanout {
184    pub fn new(width: u32, height: u32, default_quality: i64) -> Self {
185        Self {
186            consumers: Arc::new(Mutex::new(Vec::new())),
187            blackout: Arc::new(AtomicBool::new(false)),
188            backend: Mutex::new(None),
189            supervisor: Arc::new(Mutex::new(None)),
190            supervisor_generation: AtomicU64::new(0),
191            quality: AtomicI64::new(default_quality.clamp(1, 100)),
192            idle: watch::channel(0).0,
193            cancel: watch::channel(0).0,
194            teardown: Mutex::new(()),
195            width,
196            height,
197        }
198    }
199
200    /// Mirror of the control state's blackout predicate. Called on every
201    /// control-state change.
202    pub fn set_blackout(&self, active: bool) {
203        self.blackout.store(active, Ordering::SeqCst);
204    }
205
206    pub fn blackout_active(&self) -> bool {
207        self.blackout.load(Ordering::SeqCst)
208    }
209
210    /// Point the fan-out at a launched browser. Called once the assistant's
211    /// Chromium actually exists; starts capture immediately if anyone is
212    /// already subscribed (the drawer opened first, the browser launched
213    /// second — the ordinary case for the standing session).
214    pub async fn bind(&self, backend: Arc<ChromiumBackend>) {
215        *self.backend.lock().await = Some(backend);
216        self.ensure_supervisor().await;
217    }
218
219    /// Register a consumer. The first one starts CDP capture (via the
220    /// supervisor); dropping the returned receiver deregisters it at the
221    /// next frame. Returns the epoch its frames are timestamped against.
222    pub async fn subscribe(&self, audience: FrameAudience) -> (FrameReceiver, Instant) {
223        let (tx, rx) = mpsc::channel(FRAME_CHANNEL_CAP);
224        let epoch = Instant::now();
225        self.consumers.lock().await.push(Consumer {
226            tx,
227            audience,
228            epoch,
229        });
230        self.ensure_supervisor().await;
231        (rx, epoch)
232    }
233
234    /// The quality the pump is currently capturing at.
235    pub fn quality(&self) -> i64 {
236        self.quality.load(Ordering::SeqCst)
237    }
238
239    /// Re-capture at `quality`. A no-op when it already matches; otherwise
240    /// the current pump generation is stopped so the supervisor re-attaches
241    /// at the new quality. Consumers keep their subscriptions across it.
242    ///
243    /// This exists because ONE pump now serves both the drawer and
244    /// `browser_record_start`, whose documented `quality` parameter would
245    /// otherwise silently stop having an effect.
246    pub async fn set_quality(&self, quality: i64) {
247        let quality = quality.clamp(1, 100);
248        if self.quality.swap(quality, Ordering::SeqCst) == quality {
249            return;
250        }
251        // Ending the supervisor is what forces a fresh attach: the next
252        // `subscribe`/`bind` (the caller's own, immediately after) spawns a
253        // new one, which reads the quality atomic on its way in.
254        self.stop_supervisor().await;
255        self.ensure_supervisor().await;
256    }
257
258    /// Drop consumers whose receiver has gone away and, if that was the
259    /// last one, wake the supervisor so it stops CDP capture now rather than
260    /// at whatever future moment the page next happens to change.
261    pub async fn prune(&self) {
262        let empty = {
263            let mut consumers = self.consumers.lock().await;
264            consumers.retain(|c| !c.tx.is_closed());
265            consumers.is_empty()
266        };
267        if empty {
268            self.idle.send_modify(|n| *n = n.wrapping_add(1));
269        }
270    }
271
272    /// The receiver a supervisor would hold. Test-only window into the
273    /// idle signal, so the "a prune landing mid-attach is not lost" property
274    /// can be asserted without a live Chromium to attach to.
275    #[cfg(test)]
276    pub(crate) fn idle_watch_for_test(&self) -> watch::Receiver<u64> {
277        self.idle.subscribe()
278    }
279
280    /// How many consumers are currently registered. Test-only window into
281    /// otherwise-private state, so a caller-level test (`browser_tools`'s
282    /// own suite, which drives `run_record_stop_with`) can assert a dead
283    /// consumer actually got pruned rather than merely trusting that it did.
284    #[cfg(test)]
285    pub async fn consumer_count_for_test(&self) -> usize {
286        self.consumers.lock().await.len()
287    }
288
289    /// Spawn the pump supervisor if a browser is bound, somebody is
290    /// subscribed, and one isn't already running.
291    async fn ensure_supervisor(&self) {
292        let mut running = self.supervisor.lock().await;
293        if running.as_ref().is_some_and(|t| !t.is_finished()) {
294            return;
295        }
296        let Some(backend) = self.backend.lock().await.clone() else {
297            return;
298        };
299        if self.consumers.lock().await.is_empty() {
300            return;
301        }
302        let consumers = Arc::clone(&self.consumers);
303        let blackout = Arc::clone(&self.blackout);
304        // Subscribed HERE, before the spawn — so every `prune` from this
305        // moment on is retained for the supervisor, including the ones that
306        // land while it is still attaching.
307        let idle = self.idle.subscribe();
308        let cancel = self.cancel.subscribe();
309        let quality = self.quality.load(Ordering::SeqCst);
310        let (width, height) = (self.width, self.height);
311        // Cloned before the spawn, and `running` is held across it — so the
312        // task cannot reach `publish_exit` (which wants this same lock)
313        // before the handle below has been stored.
314        let slot = Arc::clone(&self.supervisor);
315        // Bumped under the supervisor lock `running` holds, so a
316        // `stop_supervisor` that snapshots it and then observes a different
317        // value has provably had a replacement spawned underneath it.
318        self.supervisor_generation.fetch_add(1, Ordering::AcqRel);
319        *running = Some(tokio::spawn(async move {
320            supervise(
321                backend, consumers, blackout, idle, cancel, quality, width, height, slot,
322            )
323            .await;
324        }));
325    }
326
327    /// Tear the running supervisor down and WAIT for it to be gone.
328    ///
329    /// `abort()` alone was not enough, and awaiting the aborted handle would
330    /// not have been either. Aborting drops the supervisor's `pump` local,
331    /// and `ScreencastPump::drop` can only send `Page.stopScreencast` from a
332    /// spawned, detached task — which is scheduled independently of the
333    /// replacement supervisor's own CDP round trips, so the stop could land
334    /// AFTER the new pump's `startScreencast`. Chrome then stops emitting
335    /// while the new pump's `running` is `Some` with a live task, so
336    /// `reusable_start` reports the capture as healthy and nothing notices:
337    /// on `browser_record_start { quality }` — the one-pump-serves-both case
338    /// this design exists for — the drawer freezes AND the recording captures
339    /// nothing, with no error anywhere.
340    ///
341    /// So the supervisor is asked to stop instead: it runs `pump.stop().await`
342    /// itself, which leaves `running` empty so its `Drop` is inert, and only
343    /// then does this return. Bounded, because a supervisor wedged in a CDP
344    /// call must not wedge `set_quality` — past the deadline the abort is
345    /// still strictly better than nothing.
346    async fn stop_supervisor(&self) {
347        // Serialized against another teardown, and — critically — the handle
348        // stays IN the slot for the whole wait.
349        //
350        // Taking it out first vacated the slot immediately, so a concurrent
351        // `subscribe` → `ensure_supervisor` read "no supervisor" and spawned a
352        // second one, which sent `Page.startScreencast` on the same page while
353        // the old supervisor was still on its way to `pump.stop()` —
354        // `Page.stopScreencast`. Chrome then stops emitting with the new
355        // supervisor's slot Some and its task alive, so `reusable_start`
356        // reports the capture healthy and nothing notices. That is the exact
357        // failure the cancel signal was added to close, reached through the
358        // subscribe door instead of the abort door: the cancel arm's "provably
359        // done before the caller spawns a replacement" is true for the caller
360        // (`set_quality`) and was never true for anyone else.
361        //
362        // A separate mutex because `publish_exit` wants the supervisor lock,
363        // so holding that one across the join would deadlock the very task
364        // being waited on.
365        let _teardown = self.teardown.lock().await;
366        if self
367            .supervisor
368            .lock()
369            .await
370            .as_ref()
371            .is_none_or(|t| t.is_finished())
372        {
373            return;
374        }
375        // The identity of the supervisor this teardown is FOR, read before the
376        // cancel goes out. Everything below refuses to act on any other one.
377        let generation = self.supervisor_generation.load(Ordering::Acquire);
378        self.cancel.send_modify(|n| *n = n.wrapping_add(1));
379        let deadline = Instant::now() + SUPERVISOR_STOP_GRACE;
380        loop {
381            {
382                let mut slot = self.supervisor.lock().await;
383                if self.supervisor_generation.load(Ordering::Acquire) != generation {
384                    // A subscribe spawned a replacement after our cancel
385                    // landed. Ours is therefore gone (the spawn happens under
386                    // this same lock, and only after the slot read empty or
387                    // finished), and the live task is not ours to wait on —
388                    // still less to abort.
389                    return;
390                }
391                match slot.as_ref() {
392                    // Gone on its own — the slot is not ours to clear.
393                    None => return,
394                    Some(task) if task.is_finished() => {
395                        *slot = None;
396                        return;
397                    }
398                    Some(_) => {}
399                }
400            }
401            if Instant::now() >= deadline {
402                tracing::debug!(
403                    "browser stream: supervisor did not stop within the grace period; aborting"
404                );
405                let mut slot = self.supervisor.lock().await;
406                // Re-checked under the lock: the replacement could have landed
407                // between the loop's check and this deadline branch, and
408                // aborting it here is the whole defect.
409                if self.supervisor_generation.load(Ordering::Acquire) != generation {
410                    return;
411                }
412                if let Some(task) = slot.take() {
413                    drop(slot);
414                    task.abort();
415                    let _ = task.await;
416                }
417                return;
418            }
419            tokio::time::sleep(Duration::from_millis(10)).await;
420        }
421    }
422}
423
424/// Publish this supervisor's exit — or refuse to exit, because a consumer
425/// arrived while it was tearing down. `true` means "exit now".
426///
427/// Every one of `supervise`'s exit paths observes "no consumers" and then
428/// AWAITS before returning (`pump.stop()` is a CDP round trip), and
429/// `ensure_supervisor` cannot see that coming: a `JoinHandle` that has not
430/// finished YET looks alive, so a consumer subscribing inside the teardown
431/// window was left registered with no supervisor and no capture, and nothing
432/// polls for that condition.
433///
434/// The fix is to make the exit decision and the liveness signal the same
435/// event. A subscriber always pushes onto `consumers` BEFORE it calls
436/// `ensure_supervisor`, and both that call and this one take the supervisor
437/// mutex — so under the lock, "consumers is empty" means the subscriber has
438/// not pushed yet, and clearing the slot here means its `ensure_supervisor`
439/// will spawn a fresh supervisor. Neither order loses.
440async fn publish_exit(
441    slot: &Mutex<Option<tokio::task::JoinHandle<()>>>,
442    consumers: &Mutex<Vec<Consumer>>,
443) -> bool {
444    let mut slot = slot.lock().await;
445    if !consumers.lock().await.is_empty() {
446        return false;
447    }
448    *slot = None;
449    true
450}
451
452/// Park until it is worth trying to attach again — the tab registry changed
453/// (a page appeared) — or until the last consumer left, which is the one
454/// answer that means "stop". `false` ⇒ the supervisor should exit.
455///
456/// Without the `idle` arm, a supervisor parked here on a browser with no page
457/// would never learn that everybody unsubscribed, and would hold the browser
458/// and its consumer list until the whole fan-out was dropped.
459/// Why [`wait_for_retry`] stopped waiting.
460///
461/// Cancellation and idleness are NOT the same answer, and collapsing them into
462/// one `bool` was the bug: the caller then asked `publish_exit`, which is a
463/// question about consumer EMPTINESS. On an explicit teardown with consumers
464/// still subscribed that answers "do not exit", so the supervisor looped and
465/// ignored the request — `set_quality` burned its whole grace period waiting
466/// for a task that was never going to leave, and then aborted a pump that was
467/// by then live again, which is the very abort the cancel signal was added to
468/// avoid.
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470enum RetryWake {
471    /// A page may exist now — try attaching again.
472    Retry,
473    /// The consumer list emptied.
474    Idle,
475    /// An explicit teardown request. The caller already owns the slot.
476    Cancelled,
477}
478
479async fn wait_for_retry(
480    tabs: &mut watch::Receiver<car_browser::TabsSnapshot>,
481    idle: &mut watch::Receiver<u64>,
482    cancel: &mut watch::Receiver<u64>,
483) -> RetryWake {
484    tokio::select! {
485        changed = tabs.changed() => {
486            if changed.is_ok() { RetryWake::Retry } else { RetryWake::Idle }
487        }
488        // Only bumped when the consumer list has actually emptied.
489        _ = idle.changed() => RetryWake::Idle,
490        // An explicit teardown. No pump is attached on this path, so there is
491        // nothing to stop — just leave, without consulting a predicate about
492        // consumers.
493        _ = cancel.changed() => RetryWake::Cancelled,
494    }
495}
496
497/// Own one pump generation at a time: attach to whatever tab is active,
498/// forward its frames to the fan-out list, and re-attach when the active tab
499/// changes. Exits — stopping capture — as soon as no consumers remain.
500///
501/// CDP-bound, so it is exercised by `cargo check` and by live use rather than
502/// by a unit test; the parts that carry policy ([`publish`], and the
503/// active-tab comparison) are pulled out so they can be tested without Chrome.
504async fn supervise(
505    backend: Arc<ChromiumBackend>,
506    consumers: Arc<Mutex<Vec<Consumer>>>,
507    blackout: Arc<AtomicBool>,
508    mut idle: watch::Receiver<u64>,
509    mut cancel: watch::Receiver<u64>,
510    quality: i64,
511    width: u32,
512    height: u32,
513    slot: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
514) {
515    let mut tabs = backend.subscribe_tabs();
516    loop {
517        if publish_exit(&slot, &consumers).await {
518            return;
519        }
520        let Ok(page) = backend.page_handle().await else {
521            // No page yet (or the browser is shutting down). Wait for the
522            // tab registry to say otherwise rather than spinning.
523            match wait_for_retry(&mut tabs, &mut idle, &mut cancel).await {
524                RetryWake::Retry => continue,
525                RetryWake::Cancelled => return,
526                RetryWake::Idle => {
527                    if publish_exit(&slot, &consumers).await {
528                        return;
529                    }
530                    continue;
531                }
532            }
533        };
534        let attached_to = backend.active_tab_id();
535        let Ok(pump) = ScreencastPump::attach(&page, quality, 1, width, height).await else {
536            match wait_for_retry(&mut tabs, &mut idle, &mut cancel).await {
537                RetryWake::Retry => continue,
538                RetryWake::Cancelled => return,
539                RetryWake::Idle => {
540                    if publish_exit(&slot, &consumers).await {
541                        return;
542                    }
543                    continue;
544                }
545            }
546        };
547        let Ok((mut incoming, _started)) = pump.subscribe().await else {
548            match wait_for_retry(&mut tabs, &mut idle, &mut cancel).await {
549                RetryWake::Retry => continue,
550                RetryWake::Cancelled => return,
551                RetryWake::Idle => {
552                    if publish_exit(&slot, &consumers).await {
553                        return;
554                    }
555                    continue;
556                }
557            }
558        };
559        // Attaching costs several CDP round trips, and the last consumer can
560        // leave inside them. Re-checked here rather than trusted from the
561        // outer loop, so a drawer opened and closed inside that window does
562        // not leave an armed screencast on a still page.
563        if consumers.lock().await.is_empty() {
564            pump.stop().await;
565            if publish_exit(&slot, &consumers).await {
566                return;
567            }
568            continue;
569        }
570
571        // Pump this generation until the active tab moves, the stream ends
572        // (navigation, tab close, Chrome exit), or everyone unsubscribes.
573        loop {
574            tokio::select! {
575                frame = incoming.recv() => {
576                    let Some(frame) = frame else { break };
577                    let blacked = blackout.load(Ordering::SeqCst);
578                    let mut list = consumers.lock().await;
579                    publish(&mut list, &frame, blacked);
580                    if list.is_empty() {
581                        drop(list);
582                        pump.stop().await;
583                        if publish_exit(&slot, &consumers).await {
584                            return;
585                        }
586                        break;
587                    }
588                }
589                changed = tabs.changed() => {
590                    if changed.is_err() {
591                        break;
592                    }
593                    if backend.active_tab_id() != attached_to {
594                        break;
595                    }
596                }
597                _ = idle.changed() => {
598                    if consumers.lock().await.is_empty() {
599                        pump.stop().await;
600                        if publish_exit(&slot, &consumers).await {
601                            return;
602                        }
603                        break;
604                    }
605                }
606                // An explicit teardown request (`set_quality`). Stop the pump
607                // HERE, awaited, so `Page.stopScreencast` is provably done
608                // before the caller spawns a replacement supervisor that
609                // sends `startScreencast` on the same page — and so the
610                // pump's own `Drop` has nothing left to do on a detached
611                // task. The slot is left for the caller, which already took
612                // it.
613                _ = cancel.changed() => {
614                    pump.stop().await;
615                    return;
616                }
617            }
618        }
619        pump.stop().await;
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use car_browser::models::Viewport;
627
628    fn frame(byte: u8) -> ScreencastFrame {
629        ScreencastFrame {
630            jpeg: vec![byte].into(),
631            viewport: Viewport {
632                width: 4,
633                height: 3,
634                device_pixel_ratio: 1.0,
635            },
636            captured_at: 0.0,
637        }
638    }
639
640    fn consumer(audience: FrameAudience) -> (Consumer, mpsc::Receiver<ScreencastFrame>) {
641        let (tx, rx) = mpsc::channel(FRAME_CHANNEL_CAP);
642        (
643            Consumer {
644                tx,
645                audience,
646                epoch: Instant::now(),
647            },
648            rx,
649        )
650    }
651
652    /// The lost-wakeup this signal was a `Notify` for. The supervisor checks
653    /// emptiness in its outer loop, then spends several CDP round trips
654    /// attaching before it first polls the idle signal — and
655    /// `Notify::notify_waiters()` stores no permit, so a `prune()` landing in
656    /// that window woke nobody and was gone. The supervisor then parked on a
657    /// still page holding an armed screencast with zero consumers.
658    ///
659    /// Stated the way the supervisor sees it: the receiver exists (it is
660    /// subscribed at spawn time), the prune lands, and only afterwards does
661    /// anything await it.
662    #[tokio::test]
663    async fn a_prune_that_lands_while_the_supervisor_is_attaching_is_not_lost() {
664        let fanout = FrameFanout::new(4, 3, 80);
665        let mut idle = fanout.idle_watch_for_test();
666
667        // A drawer opens and closes again inside the attach window.
668        let (rx, _epoch) = fanout.subscribe(FrameAudience::Viewer).await;
669        drop(rx);
670        fanout.prune().await;
671
672        // The supervisor reaches its `idle` arm only now.
673        tokio::time::timeout(std::time::Duration::from_millis(100), idle.changed())
674            .await
675            .expect("the idle signal must still be there when the supervisor finally parks on it")
676            .expect("the fan-out is still alive");
677        assert_eq!(fanout.consumer_count_for_test().await, 0);
678    }
679
680    /// The teardown race `JoinHandle::is_finished()` cannot see. Every exit
681    /// path awaits (`pump.stop()` is a CDP round trip) after observing "no
682    /// consumers", so a consumer that subscribes inside that window found a
683    /// not-yet-finished handle, returned without spawning, and was left
684    /// registered with no supervisor and no capture.
685    ///
686    /// Stated as the two orders that matter, against the real helper.
687    #[tokio::test]
688    async fn a_supervisor_tearing_down_does_not_exit_once_a_consumer_has_arrived() {
689        let slot: Mutex<Option<tokio::task::JoinHandle<()>>> =
690            Mutex::new(Some(tokio::spawn(async {
691                std::future::pending::<()>().await
692            })));
693        let consumers: Mutex<Vec<Consumer>> = Mutex::new(Vec::new());
694
695        // The consumer pushes itself BEFORE calling `ensure_supervisor`, so
696        // under the supervisor lock a non-empty list means it is already
697        // registered — the supervisor must keep running for it.
698        let (c, _rx) = consumer(FrameAudience::Viewer);
699        consumers.lock().await.push(c);
700        assert!(
701            !publish_exit(&slot, &consumers).await,
702            "a registered consumer must keep the supervisor alive"
703        );
704        assert!(
705            slot.lock().await.is_some(),
706            "and the slot must still read as live, or nothing would restart it"
707        );
708
709        // The other order: nobody has arrived, so the exit is published and
710        // the slot reads as vacant — the next `ensure_supervisor` spawns.
711        consumers.lock().await.clear();
712        assert!(publish_exit(&slot, &consumers).await);
713        assert!(
714            slot.lock().await.is_none(),
715            "the exiting task must publish its own absence, not leave a stale handle"
716        );
717    }
718
719    #[test]
720    fn every_consumer_receives_a_frame_when_no_blackout_is_active() {
721        let (viewer, mut viewer_rx) = consumer(FrameAudience::Viewer);
722        let (model, mut model_rx) = consumer(FrameAudience::Model);
723        let mut list = vec![viewer, model];
724
725        assert_eq!(publish(&mut list, &frame(7), false), 2);
726        assert_eq!(viewer_rx.try_recv().unwrap().jpeg.as_ref(), &[7]);
727        assert_eq!(model_rx.try_recv().unwrap().jpeg.as_ref(), &[7]);
728    }
729
730    /// Controller ruling R3, at the exact line that enforces it: while the
731    /// blackout is active the drawer keeps streaming and the recording (the
732    /// disk artifact) receives nothing.
733    #[test]
734    fn blackout_suppresses_the_recording_consumer_and_only_that_one() {
735        let (viewer, mut viewer_rx) = consumer(FrameAudience::Viewer);
736        let (model, mut model_rx) = consumer(FrameAudience::Model);
737        let mut list = vec![viewer, model];
738
739        assert_eq!(publish(&mut list, &frame(1), true), 1);
740        assert_eq!(
741            viewer_rx.try_recv().unwrap().jpeg.as_ref(),
742            &[1],
743            "the human driving must keep seeing the page"
744        );
745        assert!(
746            model_rx.try_recv().is_err(),
747            "no frame captured during a blackout may reach disk"
748        );
749    }
750
751    /// The gated consumer is kept registered through the blackout, so the
752    /// recording resumes by itself instead of silently ending at the first
753    /// blacked-out frame.
754    #[test]
755    fn a_gated_consumer_resumes_once_the_blackout_lifts() {
756        let (viewer, _viewer_rx) = consumer(FrameAudience::Viewer);
757        let (model, mut model_rx) = consumer(FrameAudience::Model);
758        let mut list = vec![viewer, model];
759
760        publish(&mut list, &frame(1), true);
761        publish(&mut list, &frame(2), true);
762        assert_eq!(list.len(), 2, "the gated consumer stays registered");
763        publish(&mut list, &frame(3), false);
764
765        assert_eq!(model_rx.try_recv().unwrap().jpeg.as_ref(), &[3]);
766        assert!(
767            model_rx.try_recv().is_err(),
768            "only the post-blackout frame lands"
769        );
770    }
771
772    #[test]
773    fn a_dropped_consumer_is_pruned_and_the_survivor_keeps_streaming() {
774        let (viewer, viewer_rx) = consumer(FrameAudience::Viewer);
775        let (other, mut other_rx) = consumer(FrameAudience::Viewer);
776        let mut list = vec![viewer, other];
777
778        drop(viewer_rx);
779        assert_eq!(publish(&mut list, &frame(5), false), 1);
780        assert_eq!(list.len(), 1);
781        assert_eq!(other_rx.try_recv().unwrap().jpeg.as_ref(), &[5]);
782    }
783
784    /// Each consumer's `captured_at` is measured from ITS OWN subscription,
785    /// not the pump's — a recording that starts long after the drawer opened
786    /// must still produce a manifest whose first frame is at ~0s.
787    ///
788    /// Asserted as the **difference** between the two stamps, never as an
789    /// absolute bound on the late one. Both are stamped inside the same
790    /// `publish` call, so `early_at - late_at` is exactly the wall gap between
791    /// the two subscriptions — the sleep — and any scheduling delay between
792    /// `list.push` and `publish` lands in both stamps and cancels. An earlier
793    /// version asserted `late_at < 0.030` instead, which is the same 30 ms the
794    /// test itself sleeps: a loaded runner that took longer than that to reach
795    /// `publish` failed a test whose subject had not moved. It went red on a
796    /// mobile-only PR that touches no Rust (car#1115) and blocked the merge.
797    #[tokio::test]
798    async fn captured_at_is_stamped_per_consumer_epoch() {
799        const GAP: std::time::Duration = std::time::Duration::from_millis(30);
800
801        let (early, mut early_rx) = consumer(FrameAudience::Viewer);
802        let mut list = vec![early];
803        tokio::time::sleep(GAP).await;
804        let (late, mut late_rx) = consumer(FrameAudience::Viewer);
805        list.push(late);
806
807        publish(&mut list, &frame(1), false);
808        let early_at = early_rx.try_recv().unwrap().captured_at;
809        let late_at = late_rx.try_recv().unwrap().captured_at;
810        assert!(
811            early_at > late_at,
812            "the earlier subscriber sees a larger elapsed time ({early_at} vs {late_at})"
813        );
814        // The tolerance is on the sleep's *lower* bound only: `sleep` may
815        // overshoot without bound, but it never returns early, so the gap can
816        // only be larger than 30 ms. A single shared epoch would put this
817        // difference at ~0 and fail — which is the property under test.
818        let gap = early_at - late_at;
819        assert!(
820            gap >= GAP.as_secs_f64() * 0.9,
821            "the two stamps must differ by the subscription gap, so the late \
822             subscriber is measuring its own epoch and not a shared one \
823             (early {early_at}, late {late_at}, difference {gap})"
824        );
825    }
826
827    #[tokio::test]
828    async fn subscribing_without_a_browser_yields_no_frames_and_no_supervisor() {
829        let fanout = FrameFanout::new(1920, 1080, 80);
830        let (mut rx, _epoch) = fanout.subscribe(FrameAudience::Viewer).await;
831        assert!(
832            fanout.supervisor.lock().await.is_none(),
833            "no browser bound yet, so nothing to capture"
834        );
835        assert!(rx.try_recv().is_err());
836    }
837
838    #[tokio::test]
839    async fn the_blackout_flag_round_trips() {
840        let fanout = FrameFanout::new(1920, 1080, 80);
841        assert!(!fanout.blackout_active());
842        fanout.set_blackout(true);
843        assert!(fanout.blackout_active());
844        fanout.set_blackout(false);
845        assert!(!fanout.blackout_active());
846    }
847}