Skip to main content

browser_control/session/
foreground.rs

1//! Foreground emulation for background tabs (ADR-004).
2//!
3//! Chromium treats a tab in a minimized window, or any tab while the
4//! display is locked, as hidden: `requestAnimationFrame` never fires,
5//! timers are throttled, `document.visibilityState` is `hidden` and
6//! `document.hasFocus()` is false. `Emulation.setFocusEmulationEnabled`
7//! flips all of that, but only for as long as the CDP session that enabled
8//! it stays attached.
9//!
10//! So both the CLI (`browser-control tab foreground <browser>/<tab> on`) and
11//! the MCP tool (`browser_tab_foreground`) use the same thing: a small
12//! detached **holder process** (`browser-control tab foreground-hold`, a
13//! hidden subcommand) that attaches one session, enables the emulation, and
14//! blocks until it is stopped, the tab goes away, or the browser exits. Its
15//! PID is recorded in the registry so `off` can stop it and tab listings can
16//! show the flag from either surface. The holder is push-only: it waits on
17//! CDP events and a termination signal; nothing polls or wakes on a timer.
18//! This is the documented exception to the daemonless rule in ADR-002.
19
20use std::process::Stdio;
21use std::time::Duration;
22
23use anyhow::{anyhow, bail, Context, Result};
24use serde_json::json;
25use tokio::sync::broadcast;
26
27use crate::cdp::{CdpClient, CdpEvent};
28use crate::detect::Engine;
29use crate::registry::{ForegroundRow, Registry};
30use crate::session::backend::{open_backend, TabBackend};
31
32/// How long `spawn_holder` waits for the child to register itself.
33const SPAWN_WAIT: Duration = Duration::from_secs(8);
34const SPAWN_POLL: Duration = Duration::from_millis(50);
35
36/// Make Chromium treat the tab as focused and visible (or stop doing so).
37/// `setFocusEmulationEnabled` is what flips `document.visibilityState`,
38/// `document.hasFocus()`, `requestAnimationFrame`, and timer throttling for
39/// a minimized window or a locked display; `setIdleOverride` additionally
40/// answers the Idle Detection API with "active, unlocked".
41pub async fn apply(client: &CdpClient, session_id: &str, enabled: bool) -> Result<()> {
42    client
43        .send_with_session(
44            "Emulation.setFocusEmulationEnabled",
45            json!({ "enabled": enabled }),
46            Some(session_id),
47        )
48        .await?;
49    let idle = if enabled {
50        client
51            .send_with_session(
52                "Emulation.setIdleOverride",
53                json!({ "isUserActive": true, "isScreenUnlocked": true }),
54                Some(session_id),
55            )
56            .await
57    } else {
58        client
59            .send_with_session("Emulation.clearIdleOverride", json!({}), Some(session_id))
60            .await
61    };
62    if let Err(e) = idle {
63        tracing::debug!(error = %e, "idle override unavailable");
64    }
65    Ok(())
66}
67
68/// Whether a live holder exists for the tab.
69pub fn status(
70    registry: &Registry,
71    browser_name: &str,
72    target_id: &str,
73) -> Result<Option<ForegroundRow>> {
74    registry.foreground_get(browser_name, target_id)
75}
76
77/// Target ids under foreground emulation for a browser.
78pub fn active_targets(registry: &Registry, browser_name: &str) -> Result<Vec<String>> {
79    Ok(registry
80        .foreground_list(browser_name)?
81        .into_iter()
82        .map(|r| r.target_id)
83        .collect())
84}
85
86/// Default lifetime of a holder. Agents forget to turn things off; an hour
87/// covers a long debugging session without keeping a game at 60 fps in the
88/// background forever.
89pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60 * 60);
90
91/// Start a detached holder for the tab that expires after `timeout`, or
92/// return the existing one. Returns `(pid, created)`.
93pub fn spawn_holder(
94    registry: &Registry,
95    browser_name: &str,
96    target_id: &str,
97    timeout: Duration,
98) -> Result<(u32, bool)> {
99    if let Some(existing) = registry.foreground_get(browser_name, target_id)? {
100        return Ok((existing.pid, false));
101    }
102    let exe = std::env::var_os("BROWSER_CONTROL_BIN")
103        .map(std::path::PathBuf::from)
104        .map(Ok)
105        .unwrap_or_else(std::env::current_exe)
106        .context("locating the browser-control executable")?;
107    let mut cmd = std::process::Command::new(exe);
108    cmd.args([
109        "tab",
110        "foreground-hold",
111        browser_name,
112        target_id,
113        "--timeout-s",
114        &timeout.as_secs().to_string(),
115    ])
116    .stdin(Stdio::null())
117    .stdout(Stdio::null())
118    .stderr(Stdio::null());
119    #[cfg(unix)]
120    {
121        use std::os::unix::process::CommandExt;
122        // Own process group: survives the parent's shell job control and
123        // Ctrl-C in the terminal that started it.
124        cmd.process_group(0);
125    }
126    #[cfg(windows)]
127    {
128        use std::os::windows::process::CommandExt;
129        const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
130        const DETACHED_PROCESS: u32 = 0x0000_0008;
131        cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
132    }
133    let mut child = cmd.spawn().context("spawning the foreground holder")?;
134    let pid = child.id();
135    let deadline = std::time::Instant::now() + SPAWN_WAIT;
136    loop {
137        if let Some(row) = registry.foreground_get(browser_name, target_id)? {
138            if row.pid == pid {
139                // Reap the child whenever it exits so it never lingers as a
140                // zombie (which `pid_alive` would otherwise keep reporting
141                // as a live holder in a long-lived MCP server).
142                std::thread::Builder::new()
143                    .name("foreground-holder-reaper".into())
144                    .spawn(move || {
145                        let _ = child.wait();
146                    })
147                    .ok();
148                return Ok((pid, true));
149            }
150        }
151        if let Some(status) = child.try_wait()? {
152            bail!(
153                "foreground holder exited before attaching ({status}); is the tab still open and the browser a Chromium?"
154            );
155        }
156        if std::time::Instant::now() >= deadline {
157            let _ = child.kill();
158            bail!("foreground holder did not attach within {SPAWN_WAIT:?}");
159        }
160        std::thread::sleep(SPAWN_POLL);
161    }
162}
163
164/// Stop the holder for the tab, if any. Returns whether one was running.
165pub fn stop_holder(registry: &Registry, browser_name: &str, target_id: &str) -> Result<bool> {
166    let Some(row) = registry.foreground_get(browser_name, target_id)? else {
167        return Ok(false);
168    };
169    terminate(row.pid);
170    // The holder deletes its own row on a clean exit; make sure it is gone
171    // even if it was killed hard.
172    let deadline = std::time::Instant::now() + Duration::from_secs(3);
173    while crate::registry::pid_alive(row.pid) && std::time::Instant::now() < deadline {
174        std::thread::sleep(SPAWN_POLL);
175    }
176    registry.foreground_delete(browser_name, target_id)?;
177    Ok(true)
178}
179
180/// Stop every holder on a browser. Returns how many were running.
181pub fn stop_all(registry: &Registry, browser_name: &str) -> Result<usize> {
182    let rows = registry.foreground_list(browser_name)?;
183    let mut n = 0;
184    for r in rows {
185        if stop_holder(registry, browser_name, &r.target_id)? {
186            n += 1;
187        }
188    }
189    Ok(n)
190}
191
192/// Ask a holder to exit. SIGTERM on Unix so it can disable the emulation
193/// and detach cleanly; `TerminateProcess` on Windows (the session drop
194/// reverts the emulation anyway).
195fn terminate(pid: u32) {
196    #[cfg(unix)]
197    {
198        // SAFETY: plain syscall with a pid we recorded ourselves.
199        unsafe {
200            libc::kill(pid as i32, libc::SIGTERM);
201        }
202    }
203    #[cfg(not(unix))]
204    {
205        let mut sys = sysinfo::System::new();
206        let p = sysinfo::Pid::from_u32(pid);
207        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[p]), true);
208        if let Some(proc_) = sys.process(p) {
209            proc_.kill();
210        }
211    }
212}
213
214/// Body of the hidden `tab foreground-hold` subcommand: attach, emulate,
215/// record, and block until told to stop or the tab/browser goes away.
216pub async fn hold(browser_name: &str, target_id: &str, timeout: Duration) -> Result<()> {
217    let registry = Registry::open()?;
218    let row = registry
219        .get_by_name(browser_name)?
220        .ok_or_else(|| anyhow!("no registered browser named {browser_name}"))?;
221    if row.engine != Engine::Cdp {
222        bail!("foreground emulation is Chromium-only (Firefox has no BiDi equivalent)");
223    }
224    let TabBackend::Cdp(client) = open_backend(&row.endpoint, Engine::Cdp).await? else {
225        bail!("expected a CDP backend");
226    };
227    let mut events = client.subscribe();
228    // Browser-level target events tell us when our tab is closed.
229    let _ = client
230        .send("Target.setDiscoverTargets", json!({ "discover": true }))
231        .await;
232    let session_id = client.attach_to_target(target_id).await?;
233    let _ = client
234        .send_with_session("Inspector.enable", json!({}), Some(&session_id))
235        .await;
236    apply(&client, &session_id, true).await?;
237    let expires_at = crate::registry::now_epoch_s() + timeout.as_secs() as i64;
238    registry.foreground_upsert(browser_name, target_id, std::process::id(), expires_at)?;
239    tracing::info!(
240        browser = browser_name,
241        target = target_id,
242        ?timeout,
243        "foreground emulation held"
244    );
245
246    let reason = wait_for_exit(&mut events, target_id, &session_id, timeout).await;
247    tracing::info!(browser = browser_name, target = target_id, %reason, "foreground holder exiting");
248    let _ = tokio::time::timeout(Duration::from_secs(2), async {
249        let _ = apply(&client, &session_id, false).await;
250        let _ = client
251            .send(
252                "Target.detachFromTarget",
253                json!({ "sessionId": session_id }),
254            )
255            .await;
256    })
257    .await;
258    registry.foreground_delete(browser_name, target_id)?;
259    Ok(())
260}
261
262/// Block until a stop signal or a CDP event says the tab or browser is gone.
263async fn wait_for_exit(
264    events: &mut broadcast::Receiver<CdpEvent>,
265    target_id: &str,
266    session_id: &str,
267    timeout: Duration,
268) -> &'static str {
269    let stop = stop_signal();
270    tokio::pin!(stop);
271    // One deadline, requested by the user: the holder's own expiry.
272    let expiry = tokio::time::sleep(timeout);
273    tokio::pin!(expiry);
274    loop {
275        tokio::select! {
276            _ = &mut stop => return "stop requested",
277            _ = &mut expiry => return "timeout reached",
278            ev = events.recv() => match ev {
279                Ok(ev) => {
280                    let ours_session = ev.session_id.as_deref() == Some(session_id);
281                    match ev.method.as_str() {
282                        "Target.targetDestroyed" | "Target.targetCrashed"
283                            if ev.params["targetId"].as_str() == Some(target_id) =>
284                        {
285                            return "tab closed";
286                        }
287                        "Target.detachedFromTarget"
288                            if ev.params["sessionId"].as_str() == Some(session_id) =>
289                        {
290                            return "session detached";
291                        }
292                        "Inspector.detached" | "Inspector.targetCrashed" if ours_session => {
293                            return "tab detached";
294                        }
295                        _ => {}
296                    }
297                }
298                Err(broadcast::error::RecvError::Lagged(_)) => {}
299                Err(broadcast::error::RecvError::Closed) => return "browser connection closed",
300            },
301        }
302    }
303}
304
305#[cfg(unix)]
306async fn stop_signal() {
307    use tokio::signal::unix::{signal, SignalKind};
308    let mut term = match signal(SignalKind::terminate()) {
309        Ok(s) => s,
310        Err(_) => {
311            let _ = tokio::signal::ctrl_c().await;
312            return;
313        }
314    };
315    tokio::select! {
316        _ = term.recv() => {}
317        _ = tokio::signal::ctrl_c() => {}
318    }
319}
320
321#[cfg(not(unix))]
322async fn stop_signal() {
323    let _ = tokio::signal::ctrl_c().await;
324}