Skip to content

Commit dd409bf

Browse files
committed
fix(daemon): make idle timeout configurable
Lets browser automation keep a single DevTools connection alive longer so Chrome approval prompts stop recurring after short idle gaps.
1 parent 33d5bb5 commit dd409bf

8 files changed

Lines changed: 312 additions & 51 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "chrome-devtools-cli"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
edition = "2021"
55
description = "Chrome DevTools Protocol CLI — auto-connects to existing Chrome"
66
authors = ["Aero <aero.windwalker@gmail.com>"]

README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ chrome-devtools navigate https://example.com
4444
4545
├─ If no daemon → spawn one (background process)
4646
│ └─ Daemon connects to Chrome WebSocket (one-time approval)
47-
│ └─ Listens on Unix socket, 5-min idle timeout
47+
│ └─ Listens on Unix socket, configurable idle timeout (default 5m)
4848
4949
└─ Fallback → direct WebSocket connection (no daemon)
5050
```
@@ -149,16 +149,34 @@ You can also use `--page <index>` for quick one-offs, or pass the raw hex target
149149
| `--ws-endpoint <url>` | Explicit WebSocket URL |
150150
| `--user-data-dir <path>` | Custom Chrome profile directory |
151151
| `--channel <ch>` | Chrome channel (stable/beta/canary/dev) |
152+
| `--daemon-idle-timeout <value>` | Daemon idle timeout (`30m`, `1h`, `never`, or `Ns/Nm/Nh`) |
153+
154+
You can also set `CHROME_DEVTOOLS_DAEMON_IDLE_TIMEOUT` as an environment fallback.
155+
156+
Examples:
157+
158+
```bash
159+
# Keep daemon alive for 30 minutes of inactivity
160+
chrome-devtools --daemon-idle-timeout 30m list-pages
161+
162+
# Keep daemon alive for 1 hour (env fallback)
163+
CHROME_DEVTOOLS_DAEMON_IDLE_TIMEOUT=1h chrome-devtools navigate https://example.com
164+
165+
# Disable idle shutdown
166+
chrome-devtools --daemon-idle-timeout never snapshot
167+
```
152168

153169
## Daemon details
154170

155171
- **Socket**: `/tmp/chrome-devtools-daemon.sock`
156172
- **PID file**: `/tmp/chrome-devtools-daemon.pid`
157-
- **Idle timeout**: 5 minutes (auto-exits, cleans up socket)
173+
- **Idle timeout**: 5 minutes by default (configurable via `--daemon-idle-timeout` or `CHROME_DEVTOOLS_DAEMON_IDLE_TIMEOUT`)
158174
- **Protocol**: Length-prefixed JSON over Unix socket
159175
- **Spawned by**: First CLI invocation (transparent to user)
160176
- **Kill manually**: `pkill -f __daemon__` or delete the socket
161177

178+
If a daemon is already running, passing a new `--daemon-idle-timeout` updates it for future idle periods (no manual restart required).
179+
162180
## Source layout
163181

164182
```

skill/chrome-devtools/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ chrome-devtools --target <name> resize 1280 720
8282
| `--ws-endpoint <url>` | Explicit WebSocket endpoint (overrides auto-connect) |
8383
| `--user-data-dir <path>` | Custom Chrome profile directory |
8484
| `--channel <ch>` | Chrome channel: stable / beta / canary / dev |
85+
| `--daemon-idle-timeout <value>` | Daemon idle timeout (`30m`, `1h`, `never`, or `Ns/Nm/Nh`) |
86+
87+
Environment fallback: `CHROME_DEVTOOLS_DAEMON_IDLE_TIMEOUT`.
8588

8689
## Typical task pattern
8790

@@ -98,4 +101,6 @@ Use `snapshot` before `screenshot` when trying to understand page structure —
98101

99102
The binary automatically manages a background daemon that holds a persistent WebSocket connection to Chrome. Chrome prompts for DevTools access once; all subsequent commands reuse the connection silently. No manual daemon management is needed.
100103

104+
Daemon idle timeout defaults to 5 minutes. Override it per command with `--daemon-idle-timeout` (for example: `30m`, `1h`, `never`) or via `CHROME_DEVTOOLS_DAEMON_IDLE_TIMEOUT`. If a daemon is already running, a new timeout value is adopted for future idle periods without a manual restart.
105+
101106
To stop it manually: `pkill -f __daemon__`

src/client.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,21 @@ pub async fn send_to_daemon(request: &DaemonRequest) -> Result<DaemonResponse> {
1818
}
1919

2020
/// Spawn the daemon process in the background.
21-
pub fn spawn_daemon(ws_url: &str) -> Result<()> {
21+
pub fn spawn_daemon(ws_url: &str, idle_timeout: Option<DaemonIdleTimeout>) -> Result<()> {
2222
let exe = std::env::current_exe()?;
23-
std::process::Command::new(&exe)
24-
.args(["__daemon__", ws_url])
23+
let mut command = std::process::Command::new(&exe);
24+
command
25+
.arg("__daemon__")
26+
.arg(ws_url)
2527
.stdin(std::process::Stdio::null())
2628
.stdout(std::process::Stdio::null())
27-
.stderr(std::process::Stdio::null())
28-
.spawn()?;
29+
.stderr(std::process::Stdio::null());
30+
31+
if let Some(timeout) = idle_timeout {
32+
command.arg(timeout.to_string());
33+
}
34+
35+
command.spawn()?;
2936
Ok(())
3037
}
3138

src/daemon.rs

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
use anyhow::{anyhow, Result};
2-
use std::time::Duration;
32
use tokio::net::UnixListener;
43

54
use crate::cdp::CdpClient;
65
use crate::commands;
76
use crate::friendly;
87
use crate::protocol::*;
98

10-
const IDLE_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes
11-
12-
pub async fn run_daemon(ws_url: &str) -> Result<()> {
9+
pub async fn run_daemon(
10+
ws_url: &str,
11+
initial_idle_timeout: Option<DaemonIdleTimeout>,
12+
) -> Result<()> {
1313
let mut client = CdpClient::connect(ws_url).await?;
14+
let mut idle_timeout = initial_idle_timeout.unwrap_or(DaemonIdleTimeout::DEFAULT);
1415

1516
// Clean up stale socket
1617
let sock = socket_path();
@@ -23,10 +24,8 @@ pub async fn run_daemon(ws_url: &str) -> Result<()> {
2324

2425
// Signal readiness by socket existence (it's already bound)
2526
loop {
26-
let accept = tokio::time::timeout(IDLE_TIMEOUT, listener.accept()).await;
27-
28-
match accept {
29-
Ok(Ok((mut stream, _))) => {
27+
match accept_with_idle_timeout(&listener, idle_timeout).await {
28+
Ok(Some((mut stream, _))) => {
3029
let req_bytes = match read_msg(&mut stream).await {
3130
Ok(b) => b,
3231
Err(e) => {
@@ -48,19 +47,23 @@ pub async fn run_daemon(ws_url: &str) -> Result<()> {
4847
}
4948
};
5049

50+
if let Some(next_idle_timeout) = request.daemon_idle_timeout {
51+
idle_timeout = next_idle_timeout;
52+
}
53+
5154
let response = handle_request(&mut client, &request).await;
5255

5356
if let Ok(resp_bytes) = serde_json::to_vec(&response) {
5457
let _ = write_msg(&mut stream, &resp_bytes).await;
5558
}
5659
}
57-
Ok(Err(e)) => {
58-
eprintln!("daemon: accept error: {e}");
59-
}
60-
Err(_) => {
60+
Ok(None) => {
6161
// Idle timeout — exit
6262
break;
6363
}
64+
Err(e) => {
65+
eprintln!("daemon: accept error: {e}");
66+
}
6467
}
6568
}
6669

@@ -69,6 +72,23 @@ pub async fn run_daemon(ws_url: &str) -> Result<()> {
6972
Ok(())
7073
}
7174

75+
async fn accept_with_idle_timeout(
76+
listener: &UnixListener,
77+
idle_timeout: DaemonIdleTimeout,
78+
) -> std::result::Result<
79+
Option<(tokio::net::UnixStream, tokio::net::unix::SocketAddr)>,
80+
std::io::Error,
81+
> {
82+
if let Some(duration) = idle_timeout.as_duration() {
83+
return match tokio::time::timeout(duration, listener.accept()).await {
84+
Ok(accept_result) => accept_result.map(Some),
85+
Err(_) => Ok(None),
86+
};
87+
}
88+
89+
listener.accept().await.map(Some)
90+
}
91+
7292
async fn handle_request(client: &mut CdpClient, req: &DaemonRequest) -> DaemonResponse {
7393
match execute_command(client, req).await {
7494
Ok(output) => DaemonResponse {
@@ -85,7 +105,10 @@ async fn handle_request(client: &mut CdpClient, req: &DaemonRequest) -> DaemonRe
85105
}
86106

87107
fn is_browser_level(cmd: &str) -> bool {
88-
matches!(cmd, "list-pages" | "new-page" | "close-page" | "select-page")
108+
matches!(
109+
cmd,
110+
"list-pages" | "new-page" | "close-page" | "select-page"
111+
)
89112
}
90113

91114
async fn execute_command(client: &mut CdpClient, req: &DaemonRequest) -> Result<String> {
@@ -145,11 +168,15 @@ async fn execute_command(client: &mut CdpClient, req: &DaemonRequest) -> Result<
145168
commands::evaluate::evaluate(client, &session_id, expr, req.json_output).await
146169
}
147170
"click" => {
148-
let sel = args["selector"].as_str().ok_or(anyhow!("selector required"))?;
171+
let sel = args["selector"]
172+
.as_str()
173+
.ok_or(anyhow!("selector required"))?;
149174
commands::input::click(client, &session_id, sel).await
150175
}
151176
"fill" => {
152-
let sel = args["selector"].as_str().ok_or(anyhow!("selector required"))?;
177+
let sel = args["selector"]
178+
.as_str()
179+
.ok_or(anyhow!("selector required"))?;
153180
let val = args["value"].as_str().ok_or(anyhow!("value required"))?;
154181
commands::input::fill(client, &session_id, sel, val).await
155182
}
@@ -162,12 +189,12 @@ async fn execute_command(client: &mut CdpClient, req: &DaemonRequest) -> Result<
162189
commands::input::press_key(client, &session_id, key).await
163190
}
164191
"hover" => {
165-
let sel = args["selector"].as_str().ok_or(anyhow!("selector required"))?;
192+
let sel = args["selector"]
193+
.as_str()
194+
.ok_or(anyhow!("selector required"))?;
166195
commands::input::hover(client, &session_id, sel).await
167196
}
168-
"snapshot" => {
169-
commands::snapshot::take_snapshot(client, &session_id, req.json_output).await
170-
}
197+
"snapshot" => commands::snapshot::take_snapshot(client, &session_id, req.json_output).await,
171198
"resize" => {
172199
let w = args["width"].as_u64().ok_or(anyhow!("width required"))? as u32;
173200
let h = args["height"].as_u64().ok_or(anyhow!("height required"))? as u32;

0 commit comments

Comments
 (0)