feat: add --idle-timeout CLI flag for daemon auto-shutdown (#856)

* feat: add --idle-timeout CLI flag for daemon auto-shutdown

Add user-friendly --idle-timeout flag that converts time strings
to milliseconds. Supports formats like '10s', '3m', '1h', or raw ms.

This addresses a common need for ephemeral/CI environments where
daemon processes can be orphaned if not explicitly closed, leading
to resource consumption from zombie chrome-headless-shell processes.

Co-authored-by: Hermes (via claude-sonnet-4-20250520) <agent@hermes.ai>

* fix: address idle-timeout review feedback

* fix: normalize idle-timeout parsing

---------

Co-authored-by: Merlin <merlin@rbeckner.com>
Co-authored-by: Hermes (via claude-sonnet-4-20250520) <agent@hermes.ai>
This commit is contained in:
Merlin
2026-03-16 15:20:16 -05:00
committed by GitHub
co-authored by Hermes Merlin
parent 0883813cd3
commit 42c4c56c9a
4 changed files with 136 additions and 1 deletions
+1
View File
@@ -2181,6 +2181,7 @@ mod tests {
screenshot_dir: None,
screenshot_quality: None,
screenshot_format: None,
idle_timeout: None,
}
}
+4
View File
@@ -234,6 +234,7 @@ pub struct DaemonOptions<'a> {
pub action_policy: Option<&'a str>,
pub confirm_actions: Option<&'a str>,
pub engine: Option<&'a str>,
pub idle_timeout: Option<&'a str>,
}
fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
@@ -300,6 +301,9 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
if let Some(engine) = opts.engine {
cmd.env("AGENT_BROWSER_ENGINE", engine);
}
if let Some(idle) = opts.idle_timeout {
cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", idle);
}
}
pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult, String> {
+130 -1
View File
@@ -8,6 +8,48 @@ const CONFIG_DIR: &str = ".agent-browser";
const CONFIG_FILENAME: &str = "config.json";
const PROJECT_CONFIG_FILENAME: &str = "agent-browser.json";
/// Parse idle timeout from user-friendly format.
/// Supports: "10s" (seconds), "3m" (minutes), "1h" (hours), or raw milliseconds.
fn parse_idle_timeout(s: &str) -> Result<String, String> {
let s = s.trim();
if s.is_empty() {
return Err("Empty idle timeout".to_string());
}
// If the value ends with a unit suffix, convert it to milliseconds.
if s.chars().last().is_some_and(|c| c.is_ascii_alphabetic()) {
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str.parse().map_err(|_| "Invalid number")?;
let ms = match unit {
"s" => num * 1000,
"m" => num * 60 * 1000,
"h" => num * 60 * 60 * 1000,
_ => return Err("Invalid idle timeout unit (use s, m, h, or raw ms)".to_string()),
};
return Ok(ms.to_string());
}
// Pure numbers are already expressed in milliseconds.
s.parse::<u64>().map_err(|_| "Invalid idle timeout")?;
Ok(s.to_string())
}
fn parse_idle_timeout_value(value: Option<String>, source: &str) -> Option<String> {
value.and_then(|raw| match parse_idle_timeout(&raw) {
Ok(ms) => Some(ms),
Err(e) => {
eprintln!(
"{} invalid idle timeout from {}: {}",
color::warning_indicator(),
source,
e
);
None
}
})
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Config {
@@ -45,6 +87,7 @@ pub struct Config {
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
pub screenshot_format: Option<String>,
pub idle_timeout: Option<String>,
}
impl Config {
@@ -90,6 +133,7 @@ impl Config {
screenshot_dir: other.screenshot_dir.or(self.screenshot_dir),
screenshot_quality: other.screenshot_quality.or(self.screenshot_quality),
screenshot_format: other.screenshot_format.or(self.screenshot_format),
idle_timeout: other.idle_timeout.or(self.idle_timeout),
}
}
}
@@ -97,7 +141,13 @@ impl Config {
fn read_config_file(path: &Path) -> Option<Config> {
let content = fs::read_to_string(path).ok()?;
match serde_json::from_str::<Config>(&content) {
Ok(config) => Some(config),
Ok(mut config) => {
config.idle_timeout = parse_idle_timeout_value(
config.idle_timeout.take(),
&format!("config file {}", path.display()),
);
Some(config)
}
Err(e) => {
eprintln!(
"{} invalid config file {}: {}",
@@ -168,6 +218,7 @@ fn extract_config_path(args: &[String]) -> Option<Option<String>> {
"--screenshot-dir",
"--screenshot-quality",
"--screenshot-format",
"--idle-timeout",
];
let mut i = 0;
while i < args.len() {
@@ -249,6 +300,7 @@ pub struct Flags {
pub screenshot_dir: Option<String>,
pub screenshot_quality: Option<u32>,
pub screenshot_format: Option<String>,
pub idle_timeout: Option<String>, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS
// Track which launch-time options were explicitly passed via CLI
// (as opposed to being set only via environment variables)
@@ -366,6 +418,11 @@ pub fn parse_flags(args: &[String]) -> Flags {
.ok()
.or(config.screenshot_format)
.filter(|s| s == "png" || s == "jpeg"),
idle_timeout: parse_idle_timeout_value(
env::var("AGENT_BROWSER_IDLE_TIMEOUT_MS").ok(),
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
)
.or(config.idle_timeout),
cli_executable_path: false,
cli_extensions: false,
cli_profile: false,
@@ -418,6 +475,19 @@ pub fn parse_flags(args: &[String]) -> Flags {
i += 1;
}
}
"--idle-timeout" => {
if let Some(s) = args.get(i + 1) {
match parse_idle_timeout(s) {
Ok(ms) => flags.idle_timeout = Some(ms),
Err(e) => eprintln!(
"{} Invalid --idle-timeout: {}",
color::warning_indicator(),
e
),
}
i += 1;
}
}
"--headers" => {
if let Some(h) = args.get(i + 1) {
flags.headers = Some(h.clone());
@@ -690,6 +760,7 @@ pub fn clean_args(args: &[String]) -> Vec<String> {
"--screenshot-dir",
"--screenshot-quality",
"--screenshot-format",
"--idle-timeout",
];
let mut i = 0;
@@ -734,6 +805,36 @@ mod tests {
assert_eq!(flags.headers, Some(r#"{"Auth":"token"}"#.to_string()));
}
#[test]
fn test_parse_idle_timeout_raw_ms() {
assert_eq!(parse_idle_timeout("10").unwrap(), "10");
}
#[test]
fn test_parse_idle_timeout_seconds() {
assert_eq!(parse_idle_timeout("10s").unwrap(), "10000");
}
#[test]
fn test_parse_idle_timeout_minutes() {
assert_eq!(parse_idle_timeout("3m").unwrap(), "180000");
}
#[test]
fn test_parse_idle_timeout_hours() {
assert_eq!(parse_idle_timeout("1h").unwrap(), "3600000");
}
#[test]
fn test_parse_idle_timeout_rejects_capital_m() {
assert!(parse_idle_timeout("10M").is_err());
}
#[test]
fn test_parse_idle_timeout_rejects_unknown_unit() {
assert!(parse_idle_timeout("10x").is_err());
}
#[test]
fn test_parse_headers_flag_with_spaces() {
// Headers JSON is passed as a single quoted argument in shell
@@ -829,6 +930,18 @@ mod tests {
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_clean_args_removes_idle_timeout_before_command() {
let cleaned = clean_args(&args("--idle-timeout 10s open example.com"));
assert_eq!(cleaned, vec!["open", "example.com"]);
}
#[test]
fn test_parse_idle_timeout_flag_converts_to_ms() {
let flags = parse_flags(&args("--idle-timeout 10s open example.com"));
assert_eq!(flags.idle_timeout.as_deref(), Some("10000"));
}
#[test]
fn test_parse_flags_with_session_and_executable_path() {
let flags = parse_flags(&args(
@@ -1034,6 +1147,22 @@ mod tests {
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_load_config_from_file_parses_idle_timeout() {
use std::io::Write;
let dir = std::env::temp_dir().join("ab-test-idle-timeout-config");
let _ = fs::create_dir_all(&dir);
let config_path = dir.join("test-config.json");
let mut f = fs::File::create(&config_path).unwrap();
writeln!(f, r#"{{"idleTimeout": "10s"}}"#).unwrap();
let config = read_config_file(&config_path).unwrap();
assert_eq!(config.idle_timeout.as_deref(), Some("10000"));
let _ = fs::remove_file(&config_path);
let _ = fs::remove_dir(&dir);
}
#[test]
fn test_load_config_missing_file_returns_none() {
let result = read_config_file(&PathBuf::from("/nonexistent/agent-browser.json"));
+1
View File
@@ -316,6 +316,7 @@ fn main() {
action_policy: flags.action_policy.as_deref(),
confirm_actions: flags.confirm_actions.as_deref(),
engine: flags.engine.as_deref(),
idle_timeout: flags.idle_timeout.as_deref(),
};
let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) {
Ok(result) => result,