Skip to main content

car_browser/
screencast.rs

1//! Broadcast-capable CDP screencast pump.
2//!
3//! A single `Page.startScreencast` connection, fanned out to any number of
4//! consumers — disk recording ([`crate::recorder`]) is one; a future
5//! `car-server-core` live-preview stream is another. Two properties carry
6//! over unchanged from the single-consumer pump this replaces:
7//!
8//! - **Every frame is ACKed exactly once**, regardless of how many
9//!   consumers are subscribed. Chrome keeps at most a small number of
10//!   un-acked frames in flight; miss the ack and the stream simply stops,
11//!   silently. The ack happens once per incoming CDP event, in [`run`],
12//!   before subscriber count is even consulted — consumers never see or
13//!   influence it.
14//! - **Frames arrive only when the page CHANGES.** A screencast is not a
15//!   fixed-rate capture, so `captured_at` on each [`ScreencastFrame`] is a
16//!   real wall-clock timestamp, not an assumed frame interval.
17//!
18//! A third property is new here: **zero consumers means zero frames.** CDP
19//! capture does not start until the first [`ScreencastPump::subscribe`]
20//! call, so a pump nobody has subscribed to generates no CDP traffic at
21//! all — attaching alone (which only pins the viewport) is free.
22//!
23//! Fan-out is centralized in one loop ([`run`]), which is also the single
24//! point a future privacy-blackout gate would suppress delivery to every
25//! consumer at once — no consumer implements its own policy.
26//!
27//! A pump is re-attachable in the sense that matters for this crate: each
28//! [`ScreencastPump::attach`] call creates an independent pump tied to
29//! whichever page is passed, so stopping one pump on page A and attaching a
30//! fresh one to page B is the normal way to move a recording (or a live
31//! view) between pages.
32
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37use async_trait::async_trait;
38use base64::engine::general_purpose::STANDARD as BASE64;
39use base64::Engine as _;
40use chromiumoxide::cdp::browser_protocol::emulation::{
41    ClearDeviceMetricsOverrideParams, SetDeviceMetricsOverrideParams,
42};
43use chromiumoxide::cdp::browser_protocol::page::{
44    EventScreencastFrame, ScreencastFrameAckParams, StartScreencastFormat, StartScreencastParams,
45    StopScreencastParams,
46};
47use chromiumoxide::Page;
48use futures::{Stream, StreamExt};
49use tokio::sync::{mpsc, Mutex};
50
51use crate::backend::BrowserError;
52use crate::models::Viewport;
53
54/// How long a teardown CDP round trip may take before the pump stops waiting
55/// on Chrome and falls through to aborting its loop.
56///
57/// Matches the 2s budget `chromium.rs` already gives `page.close()` and
58/// `fetch_nav_state`: a target that has not answered a `stopScreencast` or a
59/// `clearDeviceMetricsOverride` in two seconds is wedged, and this path holds
60/// the lock every other pump operation needs.
61const CDP_TEARDOWN_TIMEOUT: Duration = Duration::from_secs(2);
62
63/// One captured screencast frame: decoded JPEG bytes plus the viewport it
64/// was captured at, so a consumer can interpret pixel coordinates without
65/// re-querying the page.
66#[derive(Debug, Clone)]
67pub struct ScreencastFrame {
68    /// Decoded JPEG bytes. The raw CDP payload is base64 text; this is
69    /// already decoded (`page.screenshot()` decodes for you, the raw
70    /// screencast event does not). Shared so cloning a frame for N fan-out
71    /// subscribers copies only this pointer, not the full viewport JPEG.
72    pub jpeg: Arc<[u8]>,
73    /// Viewport pinned for the lifetime of this pump attachment.
74    pub viewport: Viewport,
75    /// Wall-clock seconds since capture started — real arrival time, since
76    /// the screencast is change-driven, not fixed-rate.
77    pub captured_at: f64,
78}
79
80/// A live subscription — yields frames published after
81/// [`ScreencastPump::subscribe`] was called, up to [`FRAME_CHANNEL_CAP`] of
82/// them buffered.
83pub type FrameReceiver = mpsc::Receiver<ScreencastFrame>;
84
85/// Per-consumer frame buffer, in whole full-viewport JPEGs.
86///
87/// **Bounded on purpose, and the reason is a behaviour change this pump
88/// introduced.** Before the broadcast pump existed, the recorder acked a CDP
89/// frame and wrote it to disk in the SAME loop iteration, so the next ack
90/// could not be issued until the previous frame had landed — and because
91/// Chrome caps un-acked screencast frames in flight, the writer's speed
92/// throttled Chrome's emission rate. That flow control was accidental but
93/// real, and decoupling the ack from the fan-out removed it: an unbounded
94/// channel in front of a consumer slower than Chrome (a blocking disk write,
95/// a stalled socket, a contended worker) grows without limit, and each slot
96/// is a full-viewport JPEG.
97///
98/// The policy here is BOUND AND DROP rather than bound-and-await, for both
99/// audiences. Awaiting would restore Chrome-level throttling, but it also
100/// lets the slowest consumer stall every other one — a recording on a network
101/// mount would freeze the live drawer. Dropping costs nothing the outcome
102/// notices: a dropped preview frame is invisible, and a dropped RECORDED
103/// frame simply extends the previous frame's duration, because the manifest
104/// measures durations from real arrival timestamps rather than assuming a
105/// fixed rate. A slow disk therefore yields a lower-frame-rate recording —
106/// exactly what Chrome throttling produced — with bounded memory either way.
107pub const FRAME_CHANNEL_CAP: usize = 16;
108
109/// Acknowledges one CDP screencast frame. Abstracted so the pump's ACK
110/// discipline — exactly one ack per incoming frame, independent of how many
111/// consumers are subscribed — can be unit tested without a live CDP
112/// connection. [`Page`] is the only production implementation.
113#[async_trait]
114trait FrameAck: Send + Sync {
115    async fn ack(&self, session_id: i64);
116}
117
118#[async_trait]
119impl FrameAck for Page {
120    async fn ack(&self, session_id: i64) {
121        // ACK FIRST — Chrome stalls the stream on an un-acked frame, and it
122        // does so silently. Best-effort: a failed ack surfaces as the
123        // stream stalling, which is the same failure mode as today.
124        let _ = self
125            .execute(ScreencastFrameAckParams::new(session_id))
126            .await;
127    }
128}
129
130/// One raw incoming screencast event: the session id (for the ack) plus the
131/// base64 JPEG payload. Decoupled from chromiumoxide's CDP event type so
132/// [`run`] can be driven by synthetic input in tests.
133struct RawFrame {
134    session_id: i64,
135    data_base64: String,
136}
137
138/// A CDP screencast attached to one page, fanned out to any number of
139/// consumers.
140///
141/// Attaching pins the viewport but does not start capture — capture begins
142/// on the first [`Self::subscribe`] and ends only when [`Self::stop`] is
143/// called, which stops delivery to every consumer at once. There is
144/// deliberately no per-consumer unsubscribe: consumers are meant to be
145/// centrally gated (a future privacy blackout stops the whole pump, not
146/// each consumer individually).
147pub struct ScreencastPump {
148    page: Page,
149    viewport: Viewport,
150    quality: i64,
151    every_nth_frame: i64,
152    subscribers: Arc<Mutex<Vec<mpsc::Sender<ScreencastFrame>>>>,
153    running: Mutex<Option<RunningCapture>>,
154    /// Whether `attach` applied `Emulation.setDeviceMetricsOverride` to the
155    /// page, and it has not been handed back yet.
156    ///
157    /// Tracked separately from `running` because attaching MUTATES the page
158    /// before anything subscribes: a pump attached and then dropped without a
159    /// subscriber still owes the page its metrics back, and keying that
160    /// cleanup off "was there a capture" left the live page pinned.
161    metrics_overridden: AtomicBool,
162}
163
164struct RunningCapture {
165    stop: Arc<AtomicBool>,
166    task: tokio::task::JoinHandle<()>,
167    started: Instant,
168}
169
170/// Decide whether an existing capture can be reused as-is. Returns the
171/// instant it started if so; `None` if there is no capture yet, OR the
172/// previous one's task has already finished on its own — the CDP event
173/// stream ended (page navigation, tab close, Chrome crash) without
174/// `ScreencastPump::stop()` being called, so `running.is_some()` alone is
175/// not enough to tell "still capturing" from "capture died silently".
176/// `JoinHandle::is_finished()` is what tells the two apart, and it's a pure
177/// check — no CDP connection needed — which is what makes this function
178/// unit-testable without a live `Page` (see `tests::reusable_start_*`
179/// below).
180fn reusable_start(running: &Option<RunningCapture>) -> Option<Instant> {
181    running
182        .as_ref()
183        .filter(|r| !r.task.is_finished())
184        .map(|r| r.started)
185}
186
187impl ScreencastPump {
188    /// Attach to `page` and pin its viewport. Does not start capturing —
189    /// call [`Self::subscribe`] to begin.
190    ///
191    /// `every_nth_frame` throttles Chrome's emission at the source —
192    /// cheaper than capturing everything and dropping frames later.
193    pub async fn attach(
194        page: &Page,
195        quality: i64,
196        every_nth_frame: i64,
197        max_width: u32,
198        max_height: u32,
199    ) -> Result<Self, BrowserError> {
200        // Force the PAGE VIEWPORT to the target size before capturing. A
201        // headed Chromium's window size is not its viewport size (browser
202        // chrome, OS decorations, DPI scaling all eat into it), so
203        // launching at 1920x1080 still rendered the page into a fraction
204        // of the frame with dead margins around it. Overriding device
205        // metrics is what makes the capture full-bleed and deterministic
206        // across machines — the same thing Playwright does for its
207        // `viewport` option.
208        page.execute(
209            SetDeviceMetricsOverrideParams::builder()
210                .width(max_width as i64)
211                .height(max_height as i64)
212                .device_scale_factor(1.0)
213                .mobile(false)
214                .build()
215                .map_err(|e| {
216                    BrowserError::ScreenshotFailed(format!("device metrics params: {e}"))
217                })?,
218        )
219        .await
220        .map_err(|e| BrowserError::ScreenshotFailed(format!("setDeviceMetricsOverride: {e}")))?;
221
222        Ok(Self {
223            page: page.clone(),
224            viewport: Viewport {
225                width: max_width,
226                height: max_height,
227                device_pixel_ratio: 1.0,
228            },
229            quality: quality.clamp(1, 100),
230            every_nth_frame: every_nth_frame.max(1),
231            subscribers: Arc::new(Mutex::new(Vec::new())),
232            running: Mutex::new(None),
233            // `attach` has just applied it, above.
234            metrics_overridden: AtomicBool::new(true),
235        })
236    }
237
238    /// Register a consumer. The first subscription starts CDP capture; a
239    /// pump already capturing just gains another fan-out target — the
240    /// running capture is untouched. Returns the receiver plus the instant
241    /// capture started, so a consumer can compute frame durations on the
242    /// same clock the frames themselves are timestamped against.
243    pub async fn subscribe(&self) -> Result<(FrameReceiver, Instant), BrowserError> {
244        let (tx, rx) = mpsc::channel(FRAME_CHANNEL_CAP);
245        // Register the sender BEFORE (possibly) starting capture, so a
246        // frame can never be published while this subscriber is still
247        // missing from the fan-out list.
248        self.subscribers.lock().await.push(tx);
249        let started = match self.ensure_running().await {
250            Ok(started) => started,
251            Err(e) => {
252                // The registration above outlives a FAILED start otherwise,
253                // and `ensure_running` fails for real reasons — the event
254                // listener and `Page.startScreencast` both error against a
255                // target that is navigating or has just closed. `run`'s
256                // per-frame prune is the only other collector, and a pump that
257                // never started emits no frame to trigger it, so the dead
258                // sender sat in the fan-out permanently: `subscribers` never
259                // empties, and every "is anybody watching" question above
260                // answers yes for a consumer that does not exist.
261                //
262                // Dropping the receiver FIRST is what makes the sender look
263                // closed to the retain — the caller never gets it back.
264                drop(rx);
265                self.subscribers.lock().await.retain(|s| !s.is_closed());
266                return Err(e);
267            }
268        };
269        Ok((rx, started))
270    }
271
272    /// Start CDP capture if it isn't already running. Returns the instant
273    /// capture started (existing or newly begun).
274    async fn ensure_running(&self) -> Result<Instant, BrowserError> {
275        let mut running = self.running.lock().await;
276        if let Some(started) = reusable_start(&running) {
277            return Ok(started);
278        }
279        // Either nothing has ever subscribed, or the previous capture's
280        // task already exited on its own — the CDP event stream ended
281        // (page navigation, tab close, Chrome crash) without
282        // `ScreencastPump::stop()` being called. Either way, fall through
283        // and (re)start rather than handing back a receiver that will
284        // never see another frame.
285
286        let events = self
287            .page
288            .event_listener::<EventScreencastFrame>()
289            .await
290            .map_err(|e| BrowserError::ScreenshotFailed(format!("screencast listener: {e}")))?
291            .map(|frame| RawFrame {
292                session_id: frame.session_id,
293                data_base64: AsRef::<str>::as_ref(&frame.data).to_string(),
294            })
295            .boxed();
296
297        // Pin the frame size to the viewport. Omitting max_width/max_height
298        // lets Chrome choose, and it letterboxes the page into a
299        // differently-shaped frame — a 1920x1080 viewport came back as
300        // 1600x1200 with dead margins to the right and below, which is
301        // unusable as product footage.
302        self.page
303            .execute(
304                StartScreencastParams::builder()
305                    .format(StartScreencastFormat::Jpeg)
306                    .quality(self.quality)
307                    .every_nth_frame(self.every_nth_frame)
308                    .max_width(self.viewport.width as i64)
309                    .max_height(self.viewport.height as i64)
310                    .build(),
311            )
312            .await
313            .map_err(|e| BrowserError::ScreenshotFailed(format!("startScreencast: {e}")))?;
314
315        let stop = Arc::new(AtomicBool::new(false));
316        let started = Instant::now();
317        let task = {
318            let stop = Arc::clone(&stop);
319            let subscribers = Arc::clone(&self.subscribers);
320            let page = self.page.clone();
321            let viewport = self.viewport;
322            tokio::spawn(async move {
323                run(events, &page, &stop, started, viewport, &subscribers).await;
324            })
325        };
326
327        *running = Some(RunningCapture {
328            stop,
329            task,
330            started,
331        });
332        Ok(started)
333    }
334
335    /// Stop CDP capture for every consumer at once.
336    ///
337    /// Safe to call even if nobody ever subscribed — but NOT a no-op then:
338    /// `attach` already pinned the page's device metrics, so the override has
339    /// to come back regardless of whether a capture ever ran.
340    pub async fn stop(&self) {
341        // Unconditional, and first. `attach` mutates the page BEFORE anything
342        // subscribes, so "nobody ever subscribed" — the supervisor bailing
343        // between attach and `subscribe`, a teardown in that window — is
344        // exactly the case the early return below used to skip, leaving the
345        // live page pinned at 1920x1080 / dsf 1.0 for the process's life.
346        // `supervise` re-attaches on every active-tab change, so that is once
347        // per tab visited.
348        self.clear_metrics_override().await;
349        // The guard is held across the whole teardown, INCLUDING the
350        // `stopScreencast` round trip.
351        //
352        // Taking the capture out in a `let ... else` released the lock at the
353        // end of that statement, so the two operations that actually stop
354        // Chrome ran unlocked — and both `stop(&self)` and `subscribe(&self)`
355        // are `&self` on a pump the module header describes as fanned out to N
356        // consumers. Interleaved: A takes the capture and releases; B
357        // subscribes, sees `None`, sends `startScreencast`, spawns a fresh
358        // task and stores it; A then sends `stopScreencast`. Chrome stops
359        // emitting while the slot holds a live task, so `reusable_start`
360        // reports the capture healthy forever and every later `subscribe`
361        // short-circuits onto a receiver that never yields a frame — with no
362        // error raised anywhere. Holding the guard makes `ensure_running`
363        // block until the stop has actually reached Chrome.
364        let mut running_guard = self.running.lock().await;
365        let Some(running) = running_guard.take() else {
366            return;
367        };
368        running.stop.store(true, Ordering::SeqCst);
369        // Bounded, like every other CDP call this crate makes from a teardown
370        // path (`chromium.rs` gives `page.close()` 2s and nav state
371        // `NAV_STATE_TIMEOUT`). Holding the guard across the round trip is
372        // deliberate — it is what stops a concurrent `subscribe` starting a
373        // fresh screencast into a page this one is still stopping — but an
374        // unbounded round trip under it means a wedged renderer parks every
375        // later `subscribe`, `set_quality` and `stop` on this pump forever.
376        // The abort below is the fallback: past the deadline, killing the loop
377        // is strictly better than holding the lock waiting for Chrome.
378        let _ = tokio::time::timeout(
379            CDP_TEARDOWN_TIMEOUT,
380            self.page.execute(StopScreencastParams::default()),
381        )
382        .await;
383        // The pump loop only rechecks the stop flag between frames, and a
384        // still page may never send one — so don't await the task, just
385        // kill it.
386        running.task.abort();
387    }
388
389    /// Hand the page's device metrics back, exactly once.
390    async fn clear_metrics_override(&self) {
391        if !self.metrics_overridden.swap(false, Ordering::SeqCst) {
392            return;
393        }
394        // Bounded for the same reason as the stop below, and it matters more
395        // here: this one runs BEFORE the lock is taken, so an unbounded wait
396        // delays the teardown that follows it as well.
397        let _ = tokio::time::timeout(
398            CDP_TEARDOWN_TIMEOUT,
399            self.page
400                .execute(ClearDeviceMetricsOverrideParams::default()),
401        )
402        .await;
403    }
404}
405
406impl Drop for ScreencastPump {
407    /// Dropping a pump must end its capture, not orphan it.
408    ///
409    /// Dropping a tokio `JoinHandle` **detaches** its task rather than
410    /// aborting it, so without this the pump loop survived its own pump:
411    /// it kept acking and fanning out frames, kept its consumers'
412    /// senders alive (so "zero consumers" never tripped anywhere above),
413    /// and `Page.stopScreencast` was never sent — leaving Chrome
414    /// capturing for the life of the process. Two live paths reach this:
415    /// a `RecordingHandle` dropped without `stop()`, and
416    /// `FrameFanout::set_quality`, which `abort()`s the supervisor task
417    /// that owns the pump as a task local.
418    fn drop(&mut self) {
419        let overridden = self.metrics_overridden.swap(false, Ordering::SeqCst);
420        let running = self.running.get_mut().take();
421        if running.is_none() && !overridden {
422            return;
423        }
424        if let Some(running) = &running {
425            running.stop.store(true, Ordering::SeqCst);
426            running.task.abort();
427        }
428        // Telling Chrome to stop is async and `drop` is not, so it goes
429        // out best-effort on a spawned task — and only when there is a
430        // runtime to spawn onto. During a runtime shutdown there is not,
431        // and the whole CDP connection is going away regardless.
432        let page = self.page.clone();
433        let had_capture = running.is_some();
434        if let Ok(handle) = tokio::runtime::Handle::try_current() {
435            handle.spawn(async move {
436                if had_capture {
437                    let _ = page.execute(StopScreencastParams::default()).await;
438                }
439                if overridden {
440                    let _ = page
441                        .execute(ClearDeviceMetricsOverrideParams::default())
442                        .await;
443                }
444            });
445        }
446    }
447}
448
449/// Read raw screencast events, ack + fan each one out to every subscriber.
450/// The single ack call per iteration — before subscriber count is even
451/// consulted — is what keeps the ACK discipline independent of how many
452/// consumers are listening. This loop is also the single point a future
453/// privacy-blackout gate would suppress delivery to every consumer at once.
454async fn run<A: FrameAck>(
455    mut events: impl Stream<Item = RawFrame> + Unpin,
456    acker: &A,
457    stop: &AtomicBool,
458    started: Instant,
459    viewport: Viewport,
460    subscribers: &Mutex<Vec<mpsc::Sender<ScreencastFrame>>>,
461) {
462    while let Some(raw) = events.next().await {
463        if stop.load(Ordering::SeqCst) {
464            break;
465        }
466        acker.ack(raw.session_id).await;
467
468        // `Binary` wraps the BASE64 TEXT — its `AsRef<[u8]>` hands back the
469        // bytes of that text, not the image, so decode explicitly.
470        let Ok(bytes) = BASE64.decode(&raw.data_base64) else {
471            continue;
472        };
473        publish(
474            subscribers,
475            ScreencastFrame {
476                jpeg: bytes.into(),
477                viewport,
478                captured_at: started.elapsed().as_secs_f64(),
479            },
480        )
481        .await;
482    }
483}
484
485/// Fan `frame` out to every live subscriber, dropping senders whose
486/// receiver has gone away. With zero subscribers this is a no-op — nothing
487/// is buffered for a future subscriber that hasn't arrived yet.
488async fn publish(subscribers: &Mutex<Vec<mpsc::Sender<ScreencastFrame>>>, frame: ScreencastFrame) {
489    let mut subs = subscribers.lock().await;
490    // `try_send`, never `send().await`: a consumer that has fallen behind
491    // loses the frame instead of stalling the pump loop — which would delay
492    // the ACK for every OTHER consumer too, and the ack discipline above is
493    // what keeps Chrome emitting at all. Only a closed channel deregisters a
494    // subscriber; a full one is alive and just behind. See
495    // [`FRAME_CHANNEL_CAP`] for why dropping is the right policy for both
496    // audiences.
497    subs.retain(|tx| match tx.try_send(frame.clone()) {
498        Ok(()) => true,
499        Err(mpsc::error::TrySendError::Full(_)) => true,
500        Err(mpsc::error::TrySendError::Closed(_)) => false,
501    });
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use futures::stream;
508    use std::sync::atomic::AtomicUsize;
509
510    /// Fake [`FrameAck`] that counts invocations instead of talking to a
511    /// live CDP connection — lets the "ack exactly once per frame,
512    /// independent of subscriber count" discipline be tested in isolation.
513    struct CountingAck(AtomicUsize);
514
515    #[async_trait]
516    impl FrameAck for CountingAck {
517        async fn ack(&self, _session_id: i64) {
518            self.0.fetch_add(1, Ordering::SeqCst);
519        }
520    }
521
522    fn raw_frames(n: usize) -> Vec<RawFrame> {
523        (0..n)
524            .map(|i| RawFrame {
525                session_id: i as i64,
526                data_base64: BASE64.encode(format!("frame-{i}")),
527            })
528            .collect()
529    }
530
531    fn test_viewport() -> Viewport {
532        Viewport {
533            width: 1920,
534            height: 1080,
535            device_pixel_ratio: 1.0,
536        }
537    }
538
539    async fn drain(mut rx: FrameReceiver) -> Vec<ScreencastFrame> {
540        let mut out = Vec::new();
541        while let Ok(frame) = rx.try_recv() {
542            out.push(frame);
543        }
544        out
545    }
546
547    /// The pump used to hand every frame to an UNBOUNDED channel, which
548    /// removed the only flow control the recorder ever had: before the
549    /// broadcast pump, the ack and the disk write shared a loop iteration, so
550    /// a slow write throttled Chrome's emission through the un-acked-frame
551    /// cap. Decoupled and unbounded, a consumer slower than Chrome grew a
552    /// full-viewport JPEG per frame without limit.
553    ///
554    /// The policy is bound-and-drop: memory is capped, the pump never stalls
555    /// (which would delay the ACK for every OTHER consumer), and a consumer
556    /// that is merely behind keeps its subscription.
557    #[tokio::test]
558    async fn a_consumer_that_falls_behind_loses_frames_but_keeps_its_subscription() {
559        let subscribers: Mutex<Vec<mpsc::Sender<ScreencastFrame>>> = Mutex::new(Vec::new());
560        let (tx, mut rx) = mpsc::channel(FRAME_CHANNEL_CAP);
561        subscribers.lock().await.push(tx);
562
563        // Twice the buffer, published while nothing drains.
564        for i in 0..(FRAME_CHANNEL_CAP * 2) {
565            publish(
566                &subscribers,
567                ScreencastFrame {
568                    jpeg: vec![i as u8].into(),
569                    viewport: test_viewport(),
570                    captured_at: i as f64,
571                },
572            )
573            .await;
574        }
575
576        assert_eq!(
577            subscribers.lock().await.len(),
578            1,
579            "a consumer that is behind is still a consumer — only a CLOSED channel deregisters"
580        );
581        let mut buffered = 0usize;
582        while rx.try_recv().is_ok() {
583            buffered += 1;
584        }
585        assert_eq!(
586            buffered, FRAME_CHANNEL_CAP,
587            "memory is bounded by the cap, not by how long the consumer stays slow"
588        );
589    }
590
591    /// Every subscriber gets every frame — the point of fan-out.
592    #[tokio::test]
593    async fn every_subscriber_receives_every_frame() {
594        let acker = CountingAck(AtomicUsize::new(0));
595        let stop = AtomicBool::new(false);
596        let subscribers = Mutex::new(Vec::new());
597
598        let (tx_a, rx_a) = mpsc::channel(FRAME_CHANNEL_CAP);
599        let (tx_b, rx_b) = mpsc::channel(FRAME_CHANNEL_CAP);
600        subscribers.lock().await.push(tx_a);
601        subscribers.lock().await.push(tx_b);
602
603        let frames = raw_frames(3);
604        run(
605            stream::iter(frames),
606            &acker,
607            &stop,
608            Instant::now(),
609            test_viewport(),
610            &subscribers,
611        )
612        .await;
613
614        let a = drain(rx_a).await;
615        let b = drain(rx_b).await;
616        assert_eq!(a.len(), 3, "subscriber A should see all 3 frames");
617        assert_eq!(b.len(), 3, "subscriber B should see all 3 frames");
618        for (got, i) in a.iter().zip(0..) {
619            assert_eq!(
620                got.jpeg,
621                Arc::<[u8]>::from(format!("frame-{i}").into_bytes())
622            );
623            assert_eq!(got.viewport.width, 1920);
624        }
625        assert_eq!(
626            a.iter().map(|f| &f.jpeg).collect::<Vec<_>>(),
627            b.iter().map(|f| &f.jpeg).collect::<Vec<_>>(),
628            "both subscribers must see identical frame content"
629        );
630        for (left, right) in a.iter().zip(&b) {
631            assert!(
632                Arc::ptr_eq(&left.jpeg, &right.jpeg),
633                "fan-out must share the JPEG allocation instead of deep-cloning it"
634            );
635        }
636    }
637
638    /// The ack count must equal the number of incoming frames — exactly
639    /// once each — no matter how many subscribers are attached. A bug that
640    /// moved the ack call inside the per-subscriber fan-out loop would
641    /// multiply this count; a bug that skipped acking on an empty
642    /// subscriber list would zero it.
643    #[tokio::test]
644    async fn ack_count_is_independent_of_subscriber_count() {
645        for subscriber_count in [0usize, 1, 3] {
646            let acker = CountingAck(AtomicUsize::new(0));
647            let stop = AtomicBool::new(false);
648            let subscribers = Mutex::new(Vec::new());
649            let mut receivers = Vec::new();
650            for _ in 0..subscriber_count {
651                let (tx, rx) = mpsc::channel(FRAME_CHANNEL_CAP);
652                subscribers.lock().await.push(tx);
653                receivers.push(rx);
654            }
655
656            run(
657                stream::iter(raw_frames(5)),
658                &acker,
659                &stop,
660                Instant::now(),
661                test_viewport(),
662                &subscribers,
663            )
664            .await;
665
666            assert_eq!(
667                acker.0.load(Ordering::SeqCst),
668                5,
669                "5 incoming frames must ack exactly 5 times with {subscriber_count} subscribers"
670            );
671            for rx in receivers {
672                assert_eq!(drain(rx).await.len(), 5);
673            }
674        }
675    }
676
677    /// Zero subscribers ⇒ zero frames delivered anywhere, and nothing is
678    /// buffered waiting for a future subscriber. Frames are still acked
679    /// (that discipline is independent of delivery — see the test above);
680    /// what must be zero is what reaches consumers.
681    #[tokio::test]
682    async fn zero_subscribers_means_zero_frames_delivered() {
683        let acker = CountingAck(AtomicUsize::new(0));
684        let stop = AtomicBool::new(false);
685        let subscribers: Mutex<Vec<mpsc::Sender<ScreencastFrame>>> = Mutex::new(Vec::new());
686
687        run(
688            stream::iter(raw_frames(4)),
689            &acker,
690            &stop,
691            Instant::now(),
692            test_viewport(),
693            &subscribers,
694        )
695        .await;
696
697        assert!(
698            subscribers.lock().await.is_empty(),
699            "no subscriber ever registered, so none should exist after the run"
700        );
701        // No panic, no leaked state: publish() is a safe no-op with no
702        // subscribers, which is the whole of what "zero consumers ⇒ zero
703        // frames pumped" means at the fan-out layer.
704    }
705
706    /// A subscriber that drops its receiver mid-stream is pruned rather
707    /// than causing future publishes to fail or panic.
708    #[tokio::test]
709    async fn dropped_subscriber_is_pruned_not_fatal() {
710        let acker = CountingAck(AtomicUsize::new(0));
711        let stop = AtomicBool::new(false);
712        let subscribers = Mutex::new(Vec::new());
713
714        let (tx_survivor, rx_survivor) = mpsc::channel(FRAME_CHANNEL_CAP);
715        let (tx_dropped, rx_dropped) = mpsc::channel(FRAME_CHANNEL_CAP);
716        subscribers.lock().await.push(tx_survivor);
717        subscribers.lock().await.push(tx_dropped);
718        drop(rx_dropped);
719
720        run(
721            stream::iter(raw_frames(2)),
722            &acker,
723            &stop,
724            Instant::now(),
725            test_viewport(),
726            &subscribers,
727        )
728        .await;
729
730        assert_eq!(drain(rx_survivor).await.len(), 2);
731        assert_eq!(
732            subscribers.lock().await.len(),
733            1,
734            "the dropped receiver's sender should have been pruned"
735        );
736    }
737
738    /// The stop flag halts the loop before processing (and acking) any
739    /// further frames — checked at the top of each iteration, matching the
740    /// original single-consumer pump's behavior.
741    #[tokio::test]
742    async fn stop_flag_halts_processing() {
743        let acker = CountingAck(AtomicUsize::new(0));
744        let stop = AtomicBool::new(true);
745        let subscribers = Mutex::new(Vec::new());
746
747        run(
748            stream::iter(raw_frames(3)),
749            &acker,
750            &stop,
751            Instant::now(),
752            test_viewport(),
753            &subscribers,
754        )
755        .await;
756
757        assert_eq!(acker.0.load(Ordering::SeqCst), 0);
758    }
759
760    /// A `RunningCapture` whose task has already finished — no CDP
761    /// connection needed, just a dummy tokio task run to completion. This
762    /// is the exact stale state `ensure_running` must detect: a capture
763    /// task that exited on its own (the CDP event stream ended — page
764    /// navigation, tab close, Chrome crash) without `ScreencastPump::stop`
765    /// being called.
766    async fn dead_running_capture() -> RunningCapture {
767        let task = tokio::spawn(async {});
768        // The task body does nothing, so it completes on its very first
769        // poll — yield until the executor has actually run it, rather than
770        // racing on a sleep.
771        for _ in 0..1000 {
772            if task.is_finished() {
773                break;
774            }
775            tokio::task::yield_now().await;
776        }
777        assert!(task.is_finished(), "dummy task never finished");
778        RunningCapture {
779            stop: Arc::new(AtomicBool::new(false)),
780            task,
781            started: Instant::now(),
782        }
783    }
784
785    /// A `RunningCapture` whose task is still alive (never completes) — the
786    /// normal in-progress-capture state.
787    fn alive_running_capture() -> RunningCapture {
788        RunningCapture {
789            stop: Arc::new(AtomicBool::new(false)),
790            task: tokio::spawn(std::future::pending::<()>()),
791            started: Instant::now(),
792        }
793    }
794
795    #[tokio::test]
796    async fn reusable_start_is_none_with_no_capture_yet() {
797        assert!(reusable_start(&None).is_none());
798    }
799
800    #[tokio::test]
801    async fn reusable_start_reuses_a_live_capture() {
802        let capture = alive_running_capture();
803        let started = capture.started;
804        let running = Some(capture);
805
806        assert_eq!(reusable_start(&running), Some(started));
807
808        // The dummy task never completes on its own; abort it rather than
809        // leaking it past this test.
810        if let Some(r) = running {
811            r.task.abort();
812        }
813    }
814
815    /// The bug this fix closes: `ensure_running` used to treat any
816    /// `Some(RunningCapture)` as still running, even after its task had
817    /// already exited on its own. A later `subscribe()` then returned a
818    /// receiver that would never see a frame, with no error surfaced
819    /// anywhere. `reusable_start` is the decision `ensure_running` now
820    /// makes before reusing anything — it must discard a dead capture
821    /// rather than hand back its stale `started` instant.
822    #[tokio::test]
823    async fn reusable_start_discards_a_dead_capture() {
824        let running = Some(dead_running_capture().await);
825        assert_eq!(
826            reusable_start(&running),
827            None,
828            "a finished capture task must not be treated as still running"
829        );
830    }
831}