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}