Skip to content

Commit ce3ba82

Browse files
committed
feat: polish inactivity timeout support
1 parent a4c8414 commit ce3ba82

7 files changed

Lines changed: 77 additions & 6 deletions

File tree

skills/steel-browser/references/steel-browser-lifecycle.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ Main flags:
5151
- `--credentials` — enable credential injection for the session
5252
- `--profile <name>` — load a named browser profile into the session
5353
- `--update-profile` — save session state back to the profile on end
54+
- `--inactivity-timeout <ms>` — release the session after this much idle time (no CDP command or remote input). Defaults to 120000 (2 min); pass `0` to disable.
55+
56+
Note: sessions created via the CLI auto-release after ~2 minutes of inactivity by default. If you expect a session to sit idle between manual steps, pass `--inactivity-timeout 0` (disable) or a larger value at `start`, and check `inactivity_timeout` in the start output.
5457

5558
Parse these output fields:
5659

@@ -59,6 +62,7 @@ Parse these output fields:
5962
- `name`: session alias if provided
6063
- `live_url`: live-view URL when available
6164
- `connect_url`: display-safe URL with sensitive values redacted
65+
- `inactivity_timeout`: idle-release limit when set (human output); `inactivityTimeoutMs` in JSON
6266

6367
Use `id` for stable machine parsing. Treat `connect_url` as display metadata, not a raw credential.
6468

src/browser/daemon/protocol.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ pub struct SessionInfo {
6060
/// Session timeout in milliseconds (from create params). `None` = no timeout.
6161
#[serde(skip_serializing_if = "Option::is_none")]
6262
pub timeout_ms: Option<u64>,
63+
#[serde(skip_serializing_if = "Option::is_none")]
64+
pub inactivity_timeout_ms: Option<u64>,
6365
/// Epoch milliseconds when the session was created. Used with `timeout_ms`
6466
/// to compute remaining time on the client side.
6567
#[serde(skip_serializing_if = "Option::is_none")]

src/browser/daemon/server.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use tokio::net::UnixListener;
88
use crate::api::client::SteelClient;
99
use crate::browser::engine::BrowserEngine;
1010
use crate::browser::lifecycle::{
11-
get_session_created_at_ms, get_session_timeout, to_session_summary,
11+
get_session_created_at_ms, get_session_inactivity_timeout, get_session_timeout,
12+
to_session_summary,
1213
};
1314
use crate::config::auth::{Auth, AuthSource};
1415

@@ -64,6 +65,8 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()>
6465

6566
// Prefer API-reported timeout over what we requested — the server may apply defaults
6667
let effective_timeout = get_session_timeout(&session).or(params.timeout_ms);
68+
let effective_inactivity_timeout =
69+
get_session_inactivity_timeout(&session).or(params.inactivity_timeout_ms);
6770
// Prefer API-reported createdAt; fall back to local clock
6871
let created_at_ms = get_session_created_at_ms(&session).or_else(|| {
6972
std::time::SystemTime::now()
@@ -81,6 +84,7 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()>
8184
viewer_url: summary.viewer_url,
8285
profile_id: summary.profile_id,
8386
timeout_ms: effective_timeout,
87+
inactivity_timeout_ms: effective_inactivity_timeout,
8488
created_at_ms,
8589
};
8690

src/browser/lifecycle.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,27 @@ pub fn get_session_timeout(session: &Value) -> Option<u64> {
111111
None
112112
}
113113

114+
pub fn get_session_inactivity_timeout(session: &Value) -> Option<u64> {
115+
let keys = [
116+
"inactivityTimeout",
117+
"inactivity_timeout",
118+
"inactivityTimeoutMs",
119+
];
120+
for key in &keys {
121+
if let Some(v) = session.get(key) {
122+
if let Some(n) = v.as_u64() {
123+
return Some(n);
124+
}
125+
if let Some(s) = v.as_str()
126+
&& let Ok(n) = s.trim().parse::<u64>()
127+
{
128+
return Some(n);
129+
}
130+
}
131+
}
132+
None
133+
}
134+
114135
/// Extract session creation timestamp as epoch milliseconds from API response.
115136
/// Looks for `createdAt` or `created_at` as ISO 8601 / RFC 3339 string.
116137
pub fn get_session_created_at_ms(session: &Value) -> Option<u64> {
@@ -517,6 +538,19 @@ mod tests {
517538
assert_eq!(get_session_timeout(&json!({"id": "s1"})), None);
518539
}
519540

541+
#[test]
542+
fn inactivity_timeout_from_response() {
543+
assert_eq!(
544+
get_session_inactivity_timeout(&json!({"inactivityTimeout": 120000})),
545+
Some(120000)
546+
);
547+
assert_eq!(
548+
get_session_inactivity_timeout(&json!({"inactivityTimeout": "120000"})),
549+
Some(120000)
550+
);
551+
assert_eq!(get_session_inactivity_timeout(&json!({"id": "s1"})), None);
552+
}
553+
520554
#[test]
521555
fn session_created_at_iso() {
522556
let s = json!({"createdAt": "2025-01-15T10:30:00Z"});

src/commands/browser/start.rs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> {
8282
let proxy_enabled = args.proxy.is_some();
8383
let namespace_set = args.namespace.is_some();
8484
let inactivity_timeout_ms = resolve_inactivity_timeout(args.inactivity_timeout);
85+
if let (Some(timeout), Some(inactivity)) = (args.session_timeout, inactivity_timeout_ms)
86+
&& inactivity >= timeout
87+
{
88+
eprintln!(
89+
"warning: --inactivity-timeout ({inactivity}ms) >= --session-timeout ({timeout}ms); inactivity timeout has no effect because the session timeout elapses first"
90+
);
91+
}
8592

8693
// If a daemon is already running for this session name, stop it first.
8794
// `start` always creates a fresh session — use `steel browser sessions`
@@ -175,6 +182,9 @@ fn display_session_info(info: &SessionInfo) {
175182
if let Some(ref rem) = remaining {
176183
data["remainingMs"] = json!(rem.0);
177184
}
185+
if let Some(ms) = info.inactivity_timeout_ms {
186+
data["inactivityTimeoutMs"] = json!(ms);
187+
}
178188
output::success_data(data);
179189
} else {
180190
println!("id: {}", info.session_id);
@@ -193,6 +203,9 @@ fn display_session_info(info: &SessionInfo) {
193203
println!("expires_in: {label}");
194204
}
195205
}
206+
if let Some(ms) = info.inactivity_timeout_ms {
207+
println!("inactivity_timeout: {}", humanize_secs(ms / 1000));
208+
}
196209
}
197210
}
198211

@@ -209,18 +222,20 @@ fn remaining_time_str(info: &SessionInfo) -> Option<(u64, String)> {
209222
return Some((0, "expired".to_string()));
210223
}
211224
let remaining = expires_at - now;
212-
let secs = remaining / 1000;
213-
let label = if secs >= 3600 {
225+
Some((remaining, humanize_secs(remaining / 1000)))
226+
}
227+
228+
fn humanize_secs(secs: u64) -> String {
229+
if secs >= 3600 {
214230
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
215231
} else if secs >= 60 {
216232
format!("{}m {}s", secs / 60, secs % 60)
217233
} else {
218234
format!("{secs}s")
219-
};
220-
Some((remaining, label))
235+
}
221236
}
222237

223-
fn resolve_inactivity_timeout(explicit: Option<u64>) -> Option<u64> {
238+
const fn resolve_inactivity_timeout(explicit: Option<u64>) -> Option<u64> {
224239
match explicit {
225240
None => Some(DEFAULT_INACTIVITY_TIMEOUT_MS),
226241
Some(0) => None,
@@ -249,4 +264,11 @@ mod tests {
249264
fn inactivity_zero_disables() {
250265
assert_eq!(resolve_inactivity_timeout(Some(0)), None);
251266
}
267+
268+
#[test]
269+
fn humanize_secs_formats() {
270+
assert_eq!(humanize_secs(45), "45s");
271+
assert_eq!(humanize_secs(120), "2m 0s");
272+
assert_eq!(humanize_secs(3661), "1h 1m");
273+
}
252274
}

tests/cli_compat.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ fn browser_start_flags() {
294294
flag("stealth"),
295295
flag_val_short("proxy", 'p'),
296296
flag_val("session-timeout"),
297+
flag_val("inactivity-timeout"),
297298
flag("session-solve-captcha"),
298299
flag_val("profile"),
299300
flag("update-profile"),

tests/lifecycle_contract.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ fn session_info_json_roundtrip() {
385385
viewer_url: Some("https://app.steel.dev/sessions/sess-123".to_string()),
386386
profile_id: Some("prof-1".to_string()),
387387
timeout_ms: Some(300_000),
388+
inactivity_timeout_ms: Some(120_000),
388389
created_at_ms: Some(1_700_000_000_000),
389390
};
390391

@@ -399,6 +400,7 @@ fn session_info_json_roundtrip() {
399400
assert!(back.viewer_url.is_some());
400401
assert_eq!(back.profile_id.as_deref(), Some("prof-1"));
401402
assert_eq!(back.timeout_ms, Some(300_000));
403+
assert_eq!(back.inactivity_timeout_ms, Some(120_000));
402404
assert_eq!(back.created_at_ms, Some(1_700_000_000_000));
403405
}
404406

@@ -413,6 +415,7 @@ fn session_info_minimal_roundtrip() {
413415
viewer_url: None,
414416
profile_id: None,
415417
timeout_ms: None,
418+
inactivity_timeout_ms: None,
416419
created_at_ms: None,
417420
};
418421

@@ -428,6 +431,7 @@ fn session_info_minimal_roundtrip() {
428431

429432
// Optional fields should be omitted from JSON when None
430433
assert!(!json_str.contains("timeout_ms"));
434+
assert!(!json_str.contains("inactivity_timeout_ms"));
431435
assert!(!json_str.contains("created_at_ms"));
432436
}
433437

0 commit comments

Comments
 (0)