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}