From 86daf7bfb2467404e61d34ecb012b8e75c04d6ca Mon Sep 17 00:00:00 2001 From: junhsss Date: Wed, 18 Mar 2026 17:08:45 +0900 Subject: [PATCH 1/2] feat: daemon-owned session lifecycle --- src/browser/daemon/client.rs | 4 +- src/browser/daemon/process.rs | 71 +- src/browser/daemon/protocol.rs | 54 ++ src/browser/daemon/server.rs | 98 ++- src/browser/lifecycle.rs | 343 +--------- src/commands/browser/action.rs | 206 +----- src/commands/browser/captcha.rs | 42 +- src/commands/browser/live.rs | 38 +- src/commands/browser/sessions.rs | 50 +- src/commands/browser/start.rs | 87 +-- src/commands/browser/stop.rs | 60 +- src/commands/mod.rs | 13 +- src/config/mod.rs | 1 - src/config/session_state.rs | 483 ------------- tests/lifecycle_contract.rs | 1094 +++++++----------------------- 15 files changed, 614 insertions(+), 2030 deletions(-) delete mode 100644 src/config/session_state.rs diff --git a/src/browser/daemon/client.rs b/src/browser/daemon/client.rs index aa8f82f..40b47ce 100644 --- a/src/browser/daemon/client.rs +++ b/src/browser/daemon/client.rs @@ -14,8 +14,8 @@ pub struct DaemonClient { } impl DaemonClient { - pub async fn connect(session_id: &str) -> Result { - let sock = process::socket_path(session_id); + pub async fn connect(session_name: &str) -> Result { + let sock = process::socket_path(session_name); let stream = UnixStream::connect(&sock).await.map_err(|_| { anyhow::anyhow!("Cannot connect to browser daemon. Is a session running?") })?; diff --git a/src/browser/daemon/process.rs b/src/browser/daemon/process.rs index 5822557..d8d5a9f 100644 --- a/src/browser/daemon/process.rs +++ b/src/browser/daemon/process.rs @@ -3,38 +3,63 @@ use std::time::Duration; use anyhow::{Result, bail}; -pub fn socket_path(session_id: &str) -> PathBuf { - crate::config::config_dir().join(format!("daemon-{session_id}.sock")) +use super::protocol::DaemonCreateParams; + +/// Scan the config directory for `daemon-*.sock` files and return the session names. +pub fn list_daemon_names() -> Vec { + let dir = crate::config::config_dir(); + let Ok(entries) = std::fs::read_dir(&dir) else { + return vec![]; + }; + let mut names = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if let Some(rest) = name.strip_prefix("daemon-") { + if let Some(session_name) = rest.strip_suffix(".sock") { + if !session_name.is_empty() { + names.push(session_name.to_string()); + } + } + } + } + names } -pub fn pid_path(session_id: &str) -> PathBuf { - crate::config::config_dir().join(format!("daemon-{session_id}.pid")) +pub fn socket_path(session_name: &str) -> PathBuf { + crate::config::config_dir().join(format!("daemon-{session_name}.sock")) } -fn log_path(session_id: &str) -> PathBuf { - crate::config::config_dir().join(format!("daemon-{session_id}.log")) +pub fn pid_path(session_name: &str) -> PathBuf { + crate::config::config_dir().join(format!("daemon-{session_name}.pid")) } -pub fn cleanup_stale(session_id: &str) { - let _ = std::fs::remove_file(socket_path(session_id)); - let _ = std::fs::remove_file(pid_path(session_id)); +fn log_path(session_name: &str) -> PathBuf { + crate::config::config_dir().join(format!("daemon-{session_name}.log")) } -/// Spawn a daemon process for the given session. The CDP URL is passed via +pub fn cleanup_stale(session_name: &str) { + let _ = std::fs::remove_file(socket_path(session_name)); + let _ = std::fs::remove_file(pid_path(session_name)); +} + +/// Spawn a daemon process for the given session. The create params are passed via /// environment variable to avoid leaking API keys in the process list. -pub fn spawn_daemon(session_id: &str, cdp_url: &str) -> Result<()> { +pub fn spawn_daemon(session_name: &str, params: &DaemonCreateParams) -> Result<()> { let exe = std::env::current_exe()?; - cleanup_stale(session_id); + cleanup_stale(session_name); let config_dir = crate::config::config_dir(); std::fs::create_dir_all(&config_dir)?; - let log = std::fs::File::create(log_path(session_id))?; + let log = std::fs::File::create(log_path(session_name))?; + + let params_json = serde_json::to_string(params)?; std::process::Command::new(exe) - .args(["__daemon", "--session-id", session_id]) - .env("STEEL_DAEMON_CDP_URL", cdp_url) + .args(["__daemon", "--session-name", session_name]) + .env("STEEL_DAEMON_PARAMS", params_json) .stdin(std::process::Stdio::null()) .stdout(log.try_clone()?) .stderr(log) @@ -44,8 +69,8 @@ pub fn spawn_daemon(session_id: &str, cdp_url: &str) -> Result<()> { } /// Wait until the daemon socket is connectable. -pub async fn wait_for_daemon(session_id: &str, timeout: Duration) -> Result<()> { - let sock = socket_path(session_id); +pub async fn wait_for_daemon(session_name: &str, timeout: Duration) -> Result<()> { + let sock = socket_path(session_name); let start = std::time::Instant::now(); while start.elapsed() < timeout { @@ -62,23 +87,23 @@ pub async fn wait_for_daemon(session_id: &str, timeout: Duration) -> Result<()> } /// Send a shutdown command to a running daemon and clean up files. -pub async fn stop_daemon(session_id: &str) -> Result<()> { +pub async fn stop_daemon(session_name: &str) -> Result<()> { use super::client::DaemonClient; use super::protocol::DaemonCommand; - if let Ok(mut client) = DaemonClient::connect(session_id).await { + if let Ok(mut client) = DaemonClient::connect(session_name).await { let _ = client.send(DaemonCommand::Shutdown).await; } tokio::time::sleep(Duration::from_millis(200)).await; - cleanup_stale(session_id); + cleanup_stale(session_name); Ok(()) } /// Kill a daemon process by reading its PID file, then clean up. -pub fn kill_daemon(session_id: &str) -> Result<()> { - let pid_file = pid_path(session_id); +pub fn kill_daemon(session_name: &str) -> Result<()> { + let pid_file = pid_path(session_name); if let Ok(contents) = std::fs::read_to_string(&pid_file) && let Ok(pid) = contents.trim().parse::() { @@ -89,6 +114,6 @@ pub fn kill_daemon(session_id: &str) -> Result<()> { .stderr(std::process::Stdio::null()) .status(); } - cleanup_stale(session_id); + cleanup_stale(session_name); Ok(()) } diff --git a/src/browser/daemon/protocol.rs b/src/browser/daemon/protocol.rs index 1132ee8..b1a6a64 100644 --- a/src/browser/daemon/protocol.rs +++ b/src/browser/daemon/protocol.rs @@ -2,6 +2,59 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use crate::api::session::CreateSessionOptions; +use crate::config::settings::ApiMode; + +/// Parameters passed to the daemon subprocess via env var so it can create +/// the cloud session itself. Serialized as JSON into `STEEL_DAEMON_PARAMS`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonCreateParams { + pub api_key: Option, + pub base_url: String, + pub mode: ApiMode, + pub session_name: String, + // Flattened CreateSessionOptions fields: + pub stealth: bool, + pub proxy_url: Option, + pub timeout_ms: Option, + pub headless: Option, + pub region: Option, + pub solve_captcha: bool, + pub profile_id: Option, + pub persist_profile: bool, + pub namespace: Option, + pub credentials: bool, +} + +impl DaemonCreateParams { + pub fn to_create_options(&self) -> CreateSessionOptions { + CreateSessionOptions { + stealth: self.stealth, + proxy_url: self.proxy_url.clone(), + timeout_ms: self.timeout_ms, + headless: self.headless, + region: self.region.clone(), + solve_captcha: self.solve_captcha, + profile_id: self.profile_id.clone(), + persist_profile: self.persist_profile, + namespace: self.namespace.clone(), + credentials: self.credentials, + } + } +} + +/// Information about the daemon-managed session, returned by `GetSessionInfo`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionInfo { + pub session_id: String, + pub session_name: String, + pub mode: ApiMode, + pub status: Option, + pub connect_url: Option, + pub viewer_url: Option, + pub profile_id: Option, +} + #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct DaemonRequest { pub id: u64, @@ -158,6 +211,7 @@ pub enum DaemonCommand { Close, Ping, Shutdown, + GetSessionInfo, } #[derive(Debug, PartialEq, Serialize, Deserialize)] diff --git a/src/browser/daemon/server.rs b/src/browser/daemon/server.rs index 535691f..6056f02 100644 --- a/src/browser/daemon/server.rs +++ b/src/browser/daemon/server.rs @@ -5,7 +5,10 @@ use serde_json::{Value, json}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixListener; +use crate::api::client::SteelClient; use crate::browser::engine::BrowserEngine; +use crate::browser::lifecycle::to_session_summary; +use crate::config::auth::{Auth, AuthSource}; use super::process; use super::protocol::*; @@ -13,20 +16,70 @@ use super::protocol::*; const IDLE_TIMEOUT: Duration = Duration::from_secs(300); const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(30); -pub async fn run(session_id: String, cdp_url: String) -> Result<()> { - let pid_path = process::pid_path(&session_id); +pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()> { + let pid_path = process::pid_path(&session_name); std::fs::create_dir_all(pid_path.parent().unwrap())?; std::fs::write(&pid_path, std::process::id().to_string())?; + // Build API client + auth from params + let api_client = SteelClient::new()?; + let auth = Auth { + api_key: params.api_key.clone(), + source: AuthSource::Env, + }; + let options = params.to_create_options(); + + // Create the cloud session + let session = match api_client + .create_session(¶ms.base_url, params.mode, &options, &auth) + .await + { + Ok(s) => s, + Err(e) => { + cleanup(&session_name); + return Err(anyhow::anyhow!("{e}")); + } + }; + + let summary = match to_session_summary(&session, params.mode, Some(&session_name), &auth) { + Ok(s) => s, + Err(e) => { + cleanup(&session_name); + return Err(e); + } + }; + + let cdp_url = match summary.connect_url { + Some(ref url) => url.clone(), + None => { + cleanup(&session_name); + return Err(anyhow::anyhow!("Session has no CDP connect URL")); + } + }; + + let session_info = SessionInfo { + session_id: summary.id.clone(), + session_name: session_name.clone(), + mode: params.mode, + status: summary.status, + connect_url: Some(cdp_url.clone()), + viewer_url: summary.viewer_url, + profile_id: summary.profile_id, + }; + let mut engine = match BrowserEngine::connect(&cdp_url).await { Ok(e) => e, Err(e) => { - cleanup(&session_id); + // Best-effort release + let _ = api_client + .release_session(¶ms.base_url, params.mode, &session_info.session_id, &auth) + .await; + cleanup(&session_name); return Err(e); } }; - let socket_path = process::socket_path(&session_id); + let socket_path = process::socket_path(&session_name); let _ = std::fs::remove_file(&socket_path); let listener = UnixListener::bind(&socket_path)?; @@ -45,7 +98,7 @@ pub async fn run(session_id: String, cdp_url: String) -> Result<()> { Err(_) => break, Ok(Err(_)) => continue, Ok(Ok((stream, _))) => { - let result = handle_connection(&mut engine, stream).await; + let result = handle_connection(&mut engine, &session_info, stream).await; match result { ConnectionResult::Continue => {} ConnectionResult::Shutdown => break, @@ -56,7 +109,13 @@ pub async fn run(session_id: String, cdp_url: String) -> Result<()> { } engine.close().await.ok(); - cleanup(&session_id); + + // Best-effort release the API session + let _ = api_client + .release_session(¶ms.base_url, params.mode, &session_info.session_id, &auth) + .await; + + cleanup(&session_name); Ok(()) } @@ -68,6 +127,7 @@ enum ConnectionResult { async fn handle_connection( engine: &mut BrowserEngine, + session_info: &SessionInfo, stream: tokio::net::UnixStream, ) -> ConnectionResult { let (read_half, mut write_half) = tokio::io::split(stream); @@ -98,7 +158,7 @@ async fn handle_connection( request.command, DaemonCommand::Shutdown | DaemonCommand::Close ); - let result = dispatch(engine, request.command).await; + let result = dispatch(engine, session_info, request.command).await; // Check if CDP connection died during dispatch let cdp_dead = @@ -123,8 +183,12 @@ async fn handle_connection( } } -async fn dispatch(engine: &mut BrowserEngine, cmd: DaemonCommand) -> DaemonResult { - match dispatch_inner(engine, cmd).await { +async fn dispatch( + engine: &mut BrowserEngine, + session_info: &SessionInfo, + cmd: DaemonCommand, +) -> DaemonResult { + match dispatch_inner(engine, session_info, cmd).await { Ok(data) => DaemonResult::Ok { data }, Err(e) => DaemonResult::Error { message: e.to_string(), @@ -171,7 +235,11 @@ fn build_screenshot_options( } } -async fn dispatch_inner(engine: &mut BrowserEngine, cmd: DaemonCommand) -> Result { +async fn dispatch_inner( + engine: &mut BrowserEngine, + session_info: &SessionInfo, + cmd: DaemonCommand, +) -> Result { match cmd { DaemonCommand::Navigate { url, @@ -412,12 +480,16 @@ async fn dispatch_inner(engine: &mut BrowserEngine, cmd: DaemonCommand) -> Resul Ok(Value::Null) } DaemonCommand::Ping => Ok(json!("pong")), + DaemonCommand::GetSessionInfo => { + let info_json = serde_json::to_value(session_info)?; + Ok(info_json) + } } } -fn cleanup(session_id: &str) { - let _ = std::fs::remove_file(process::socket_path(session_id)); - let _ = std::fs::remove_file(process::pid_path(session_id)); +fn cleanup(session_name: &str) { + let _ = std::fs::remove_file(process::socket_path(session_name)); + let _ = std::fs::remove_file(process::pid_path(session_name)); } #[cfg(test)] diff --git a/src/browser/lifecycle.rs b/src/browser/lifecycle.rs index a08be06..2653370 100644 --- a/src/browser/lifecycle.rs +++ b/src/browser/lifecycle.rs @@ -1,13 +1,10 @@ -//! Browser session lifecycle orchestration. -//! Combines API client + session state to implement high-level browser operations. -//! Ported from: cli/source/utils/browser/lifecycle.ts + session-policy.ts +//! Browser session lifecycle utilities. +//! Provides helpers for parsing API responses, CAPTCHA operations, and URL handling. use serde_json::Value; -use crate::api::client::{ApiError, SteelClient}; -use crate::api::session::CreateSessionOptions; +use crate::api::client::SteelClient; use crate::config::auth::Auth; -use crate::config::session_state::{SessionState, SessionStatePaths, read_state, with_lock}; use crate::config::settings::ApiMode; /// Summary of a browser session, matching TS `BrowserSessionSummary`. @@ -23,13 +20,6 @@ pub struct SessionSummary { pub profile_id: Option, } -/// Result of stopping browser sessions. -pub struct StopResult { - pub mode: ApiMode, - pub all: bool, - pub stopped_session_ids: Vec, -} - /// Result of solving a CAPTCHA. pub struct CaptchaSolveResult { pub mode: ApiMode, @@ -101,7 +91,7 @@ fn get_session_status(session: &Value) -> Option { .map(|s| s.trim().to_string()) } -fn get_connect_url(session: &Value) -> Option { +pub fn get_connect_url(session: &Value) -> Option { let keys = [ "websocketUrl", "wsUrl", @@ -123,7 +113,7 @@ fn get_connect_url(session: &Value) -> Option { None } -fn get_viewer_url(session: &Value, mode: ApiMode, session_id: &str) -> Option { +pub fn get_viewer_url(session: &Value, mode: ApiMode, session_id: &str) -> Option { let keys = ["sessionViewerUrl", "viewerUrl", "liveViewUrl"]; for key in &keys { if let Some(v) = session.get(key) @@ -191,317 +181,19 @@ pub fn to_session_summary( }) } -/// Try to get a live session, returning None if not found or not live. -async fn try_get_live_session( - client: &SteelClient, - base_url: &str, - mode: ApiMode, - session_id: &str, - auth: &Auth, -) -> Result, ApiError> { - match client.get_session(base_url, mode, session_id, auth).await { - Ok(session) => { - if is_session_live(&session) { - Ok(Some(session)) - } else { - Ok(None) - } - } - Err(e) if e.is_not_found() => Ok(None), - Err(e) => Err(e), - } -} - -/// Resolve target session from state (for stop/live/captcha commands). -fn resolve_target_session( - state: &SessionState, - mode: ApiMode, - session_name: Option<&str>, -) -> (Option, Option) { - if let Some(name) = session_name { - let name = name.trim(); - if !name.is_empty() { - let session_id = state.named_sessions.get(mode).get(name).cloned(); - return (session_id, Some(name.to_string())); - } - } - - if state.active_api_mode == Some(mode) - && let Some(ref id) = state.active_session_id - { - return (Some(id.clone()), state.active_session_name.clone()); - } - - (None, None) -} - -/// Resolve session ID for captcha commands (supports explicit --session-id). -fn resolve_captcha_session_id( - state: &SessionState, - mode: ApiMode, - session_id: Option<&str>, - session_name: Option<&str>, -) -> Option { - if let Some(id) = session_id { - let trimmed = id.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - - let (id, _) = resolve_target_session(state, mode, session_name); - id -} - -/// Start a browser session. Matches TS `startBrowserSession()`. -pub async fn start_session( - client: &SteelClient, - base_url: &str, - mode: ApiMode, - auth: &Auth, - paths: &SessionStatePaths, - session_name: Option<&str>, - options: &CreateSessionOptions, -) -> anyhow::Result { - let session_name = session_name.map(|s| s.trim()).filter(|s| !s.is_empty()); - - loop { - // Check for existing candidate - let candidate_id = with_lock(paths, false, |state| { - state - .resolve_candidate(mode, session_name) - .map(|s| s.to_string()) - })?; - - if let Some(ref candidate_id) = candidate_id { - // Try to attach to existing session - let existing = try_get_live_session(client, base_url, mode, candidate_id, auth) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - if let Some(ref session) = existing { - // Claim the session under lock - let claimed = with_lock(paths, true, |state| { - let latest = state - .resolve_candidate(mode, session_name) - .map(|s| s.to_string()); - if latest.as_deref() != Some(candidate_id) { - return false; - } - state.set_active(mode, candidate_id, session_name); - true - })?; - - if claimed { - return to_session_summary(session, mode, session_name, auth); - } - continue; - } - - // Dead session — clear and retry - with_lock(paths, true, |state| { - let latest = state - .resolve_candidate(mode, session_name) - .map(|s| s.to_string()); - if latest.as_deref() == Some(candidate_id) { - state.clear_active(mode, candidate_id); - } - })?; - continue; - } - - // No candidate — create new session - let created = client - .create_session(base_url, mode, options, auth) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let created_id = get_session_id(&created) - .ok_or_else(|| anyhow::anyhow!("API did not return a session id."))?; - - // Claim the created session under lock - let claimed = with_lock(paths, true, |state| { - let latest = state - .resolve_candidate(mode, session_name) - .map(|s| s.to_string()); - if latest.is_some() { - return false; - } - - if let Some(name) = session_name { - state - .named_sessions - .get_mut(mode) - .insert(name.to_string(), created_id.clone()); - } - state.set_active(mode, &created_id, session_name); - true - })?; - - if claimed { - return to_session_summary(&created, mode, session_name, auth); - } - - // Race: another session appeared — release ours and retry - let _ = client - .release_session(base_url, mode, &created_id, auth) - .await; - } -} - -/// Stop browser sessions. Matches TS `stopBrowserSession()`. -pub async fn stop_session( - client: &SteelClient, - base_url: &str, - mode: ApiMode, - auth: &Auth, - paths: &SessionStatePaths, - session_name: Option<&str>, - all: bool, -) -> anyhow::Result { - if all { - let sessions = client - .list_sessions(base_url, mode, auth) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let live_ids: Vec = sessions - .iter() - .filter(|s| is_session_live(s)) - .filter_map(get_session_id) - .collect(); - - for id in &live_ids { - let _ = client.release_session(base_url, mode, id, auth).await; - } - - with_lock(paths, true, |state| { - for id in &live_ids { - state.clear_active(mode, id); - } - })?; - - return Ok(StopResult { - mode, - all: true, - stopped_session_ids: live_ids, - }); - } - - let target_id = with_lock(paths, false, |state| { - let (id, _) = resolve_target_session(state, mode, session_name); - id - })?; - - let Some(target_id) = target_id else { - return Ok(StopResult { - mode, - all: false, - stopped_session_ids: vec![], - }); - }; - - client - .release_session(base_url, mode, &target_id, auth) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - with_lock(paths, true, |state| { - state.clear_active(mode, &target_id); - })?; - - Ok(StopResult { - mode, - all: false, - stopped_session_ids: vec![target_id], - }) -} - -/// List browser sessions with names resolved from state. -pub async fn list_sessions( - client: &SteelClient, - base_url: &str, - mode: ApiMode, - auth: &Auth, - paths: &SessionStatePaths, -) -> anyhow::Result> { - let sessions = client - .list_sessions(base_url, mode, auth) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let state = read_state(&paths.state_path); - - let mut summaries = Vec::new(); - for session in &sessions { - let id = get_session_id(session); - let name = id - .as_deref() - .and_then(|id| state.resolve_name(mode, id)) - .map(|s| s.to_string()); - let summary = to_session_summary(session, mode, name.as_deref(), auth)?; - summaries.push(summary); - } - - Ok(summaries) -} - -/// Get the live URL for the active session. -pub async fn get_live_url( - client: &SteelClient, - base_url: &str, - mode: ApiMode, - auth: &Auth, - paths: &SessionStatePaths, - session_name: Option<&str>, -) -> anyhow::Result> { - let target_id = with_lock(paths, false, |state| { - let (id, _) = resolve_target_session(state, mode, session_name); - id - })?; - - let Some(target_id) = target_id else { - return Ok(None); - }; - - let session = try_get_live_session(client, base_url, mode, &target_id, auth) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let Some(session) = session else { - return Ok(None); - }; - - let summary = to_session_summary(&session, mode, None, auth)?; - Ok(summary.viewer_url) -} - /// Solve a CAPTCHA for a browser session. pub async fn solve_captcha( client: &SteelClient, base_url: &str, mode: ApiMode, auth: &Auth, - paths: &SessionStatePaths, - explicit_session_id: Option<&str>, - session_name: Option<&str>, + session_id: &str, page_id: Option<&str>, url: Option<&str>, task_id: Option<&str>, ) -> anyhow::Result { - let session_id = with_lock(paths, false, |state| { - resolve_captcha_session_id(state, mode, explicit_session_id, session_name) - })?; - - let session_id = session_id.ok_or_else(|| { - anyhow::anyhow!( - "No target browser session found for CAPTCHA solving. \ - Pass `--session-id`, pass `--session `, or start a session first." - ) - })?; - let raw = client - .solve_captcha(base_url, mode, &session_id, page_id, url, task_id, auth) + .solve_captcha(base_url, mode, session_id, page_id, url, task_id, auth) .await .map_err(|e| anyhow::anyhow!("{e}"))?; @@ -516,7 +208,7 @@ pub async fn solve_captcha( Ok(CaptchaSolveResult { mode, - session_id, + session_id: session_id.to_string(), success, message, raw, @@ -529,9 +221,7 @@ pub async fn captcha_status( base_url: &str, mode: ApiMode, auth: &Auth, - paths: &SessionStatePaths, - explicit_session_id: Option<&str>, - session_name: Option<&str>, + session_id: &str, page_id: Option<&str>, wait: bool, timeout_ms: Option, @@ -540,22 +230,11 @@ pub async fn captcha_status( let timeout = timeout_ms.unwrap_or(60_000); let interval = interval_ms.unwrap_or(1_000); - let session_id = with_lock(paths, false, |state| { - resolve_captcha_session_id(state, mode, explicit_session_id, session_name) - })?; - - let session_id = session_id.ok_or_else(|| { - anyhow::anyhow!( - "No target browser session found for CAPTCHA status. \ - Pass `--session-id`, pass `--session `, or start a session first." - ) - })?; - let start = std::time::Instant::now(); loop { let pages = client - .captcha_status(base_url, mode, &session_id, page_id, auth) + .captcha_status(base_url, mode, session_id, page_id, auth) .await .map_err(|e| anyhow::anyhow!("{e}"))?; @@ -564,7 +243,7 @@ pub async fn captcha_status( if !wait || is_terminal_captcha_status(&status) { return Ok(CaptchaStatusResult { mode, - session_id, + session_id: session_id.to_string(), status, types, raw: serde_json::json!({"pages": pages}), diff --git a/src/commands/browser/action.rs b/src/commands/browser/action.rs index fdb7401..7faf625 100644 --- a/src/commands/browser/action.rs +++ b/src/commands/browser/action.rs @@ -2,18 +2,13 @@ //! that holds the BrowserEngine (CDP connection + RefMap) alive between calls. use std::collections::HashMap; -use std::time::Duration; use anyhow::Result; use clap::{Parser, Subcommand}; -use crate::api::client::SteelClient; use crate::browser::daemon::client::DaemonClient; -use crate::browser::daemon::process; use crate::browser::daemon::protocol::DaemonCommand; -use crate::browser::lifecycle::to_session_summary; -use crate::config::session_state::{SessionStatePaths, read_state}; -use crate::util::{api, output}; +use crate::util::output; // ── Shared arg types ──────────────────────────────────────────────── @@ -816,148 +811,29 @@ async fn dispatch_action(client: &mut DaemonClient, action: ActionCommand) -> Re Ok(()) } -/// Ensure a daemon is running for the target session and return a connected client. -/// If no daemon exists, resolves the CDP URL via the API and spawns one. -/// -/// When `session_name` is Some, resolves the named session from state. -/// Otherwise falls back to the active session. async fn ensure_daemon(session_name: Option<&str>) -> Result { - let paths = SessionStatePaths::default_paths(); - let state = read_state(&paths.state_path); - - let session_id = if let Some(name) = session_name { - state - .resolve_candidate(api::mode(), Some(name)) - .ok_or_else(|| { - anyhow::anyhow!( - "No session found for \"{name}\". Start one with `steel browser start --session {name}`." - ) - })? - } else { - state.active_session_id.as_deref().ok_or_else(|| { - anyhow::anyhow!("No active browser session. Run `steel browser start` first.") - })? - }; - - // Fast path: daemon already running - if let Ok(client) = DaemonClient::connect(session_id).await { - return Ok(client); - } - - // Slow path: resolve CDP URL and spawn daemon - let (mode, base_url, auth_info) = api::resolve_with_auth(); - - let api_client = SteelClient::new()?; - let session = api_client - .get_session(&base_url, mode, session_id, &auth_info) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let summary = to_session_summary(&session, mode, None, &auth_info)?; - let cdp_url = summary - .connect_url - .ok_or_else(|| anyhow::anyhow!("Session {} has no CDP connect URL.", session_id))?; - - process::spawn_daemon(session_id, &cdp_url)?; - process::wait_for_daemon(session_id, Duration::from_secs(10)).await?; - - DaemonClient::connect(session_id).await + let name = session_name.unwrap_or("default"); + DaemonClient::connect(name).await.map_err(|_| { + anyhow::anyhow!( + "No running session \"{name}\". Start one with: steel browser start --session {name}" + ) + }) } -/// On action failure, check whether the remote session is still alive. -/// If the session is dead/expired, clear stale state and return a user-friendly error. -/// If it's alive but close to expiry, warn on stderr. -/// Returns None if the session is fine (original error should be used). +/// On action failure, check if daemon is still reachable. If not, suggest restarting. async fn check_session_health( session_name: Option<&str>, _original_err: &anyhow::Error, ) -> Option { - let paths = SessionStatePaths::default_paths(); - let state = read_state(&paths.state_path); - let mode = api::mode(); - - let session_id = if let Some(name) = session_name { - state.resolve_candidate(mode, Some(name))? - } else { - state.active_session_id.as_deref()? - }; - - let (_, base_url, auth) = api::resolve_with_auth(); - let client = SteelClient::new().ok()?; - let session = match client.get_session(&base_url, mode, session_id, &auth).await { - Ok(s) => s, - Err(e) if e.is_not_found() => { - // Session doesn't exist anymore — clean up state - let _ = crate::config::session_state::with_lock(&paths, true, |state| { - state.clear_active(mode, session_id); - }); - let _ = process::kill_daemon(session_id); - return Some(anyhow::anyhow!( - "Session expired or not found. Run `steel browser start` to create a new one." - )); + let name = session_name.unwrap_or("default"); + if let Ok(mut client) = DaemonClient::connect(name).await { + if client.send(DaemonCommand::Ping).await.is_ok() { + return None; // Daemon is fine, original error stands } - Err(_) => return None, // API unreachable, don't mask the original error - }; - - use crate::browser::lifecycle::is_session_live; - if !is_session_live(&session) { - // Session exists but is dead — clean up state - let _ = crate::config::session_state::with_lock(&paths, true, |state| { - state.clear_active(mode, session_id); - }); - let _ = process::kill_daemon(session_id); - let status = session - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - return Some(anyhow::anyhow!( - "Session is no longer active (status: {status}). Run `steel browser start` to create a new one." - )); - } - - // Session is alive — check if close to expiry and warn - if let Some(timeout) = session.get("timeout").and_then(|v| v.as_u64()) - && let Some(created) = session.get("createdAt").and_then(|v| v.as_str()) - && let Some(remaining_ms) = estimate_remaining_ms(created, timeout) - && remaining_ms < 5 * 60 * 1000 - { - let remaining_secs = remaining_ms / 1000; - let mins = remaining_secs / 60; - let secs = remaining_secs % 60; - eprintln!( - "Warning: Session expires in {mins}m{secs}s. \ - Run `steel browser start` to create a new one." - ); } - - None -} - -/// Estimate remaining session time from a createdAt timestamp (RFC3339) and timeout (ms). -/// Returns None if parsing fails or the session has already expired. -fn estimate_remaining_ms(created_at: &str, timeout_ms: u64) -> Option { - use std::time::{SystemTime, UNIX_EPOCH}; - - let created = parse_rfc3339_to_epoch_ms(created_at)?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok()? - .as_millis() as u64; - let expires_at = created.checked_add(timeout_ms)?; - if now >= expires_at { - return None; // Already expired - } - Some(expires_at - now) -} - -/// Parse an RFC3339 timestamp to epoch milliseconds. -fn parse_rfc3339_to_epoch_ms(s: &str) -> Option { - let ts: jiff::Timestamp = s.trim().parse().ok()?; - let ms = ts.as_millisecond(); - if ms < 0 { - return None; - } - Some(ms as u64) + Some(anyhow::anyhow!( + "Session \"{name}\" is no longer reachable. Run `steel browser start` to create a new one." + )) } #[cfg(test)] @@ -1105,56 +981,4 @@ mod tests { assert_eq!(v.unwrap(), vec!["color"]); } - // ── parse_rfc3339_to_epoch_ms ───────────────────────────────── - - #[test] - fn rfc3339_utc_basic() { - // 2025-01-01T00:00:00Z = 1735689600000 ms - let ms = parse_rfc3339_to_epoch_ms("2025-01-01T00:00:00Z").unwrap(); - assert_eq!(ms, 1735689600000); - } - - #[test] - fn rfc3339_with_fractional_seconds() { - let ms = parse_rfc3339_to_epoch_ms("2025-01-01T00:00:00.500Z").unwrap(); - assert_eq!(ms, 1735689600500); - } - - #[test] - fn rfc3339_with_positive_offset() { - // 2025-01-01T09:00:00+09:00 = 2025-01-01T00:00:00Z - let ms = parse_rfc3339_to_epoch_ms("2025-01-01T09:00:00+09:00").unwrap(); - assert_eq!(ms, 1735689600000); - } - - #[test] - fn rfc3339_with_negative_offset() { - // 2024-12-31T19:00:00-05:00 = 2025-01-01T00:00:00Z - let ms = parse_rfc3339_to_epoch_ms("2024-12-31T19:00:00-05:00").unwrap(); - assert_eq!(ms, 1735689600000); - } - - #[test] - fn rfc3339_invalid_returns_none() { - assert!(parse_rfc3339_to_epoch_ms("not a date").is_none()); - assert!(parse_rfc3339_to_epoch_ms("").is_none()); - } - - // ── estimate_remaining_ms ───────────────────────────────────── - - #[test] - fn estimate_remaining_far_future() { - // Created in far future with 10 min timeout → should have remaining time - let remaining = estimate_remaining_ms("2099-01-01T00:00:00Z", 600_000); - assert!(remaining.is_some()); - assert!(remaining.unwrap() > 0); - } - - #[test] - fn estimate_remaining_already_expired() { - // Created in the past with 1ms timeout → expired - let remaining = estimate_remaining_ms("2020-01-01T00:00:00Z", 1); - assert!(remaining.is_none()); - } - } diff --git a/src/commands/browser/captcha.rs b/src/commands/browser/captcha.rs index a273215..21150a7 100644 --- a/src/commands/browser/captcha.rs +++ b/src/commands/browser/captcha.rs @@ -2,8 +2,9 @@ use clap::{Parser, Subcommand}; use serde_json::json; use crate::api::client::SteelClient; +use crate::browser::daemon::client::DaemonClient; +use crate::browser::daemon::protocol::{DaemonCommand, SessionInfo}; use crate::browser::lifecycle; -use crate::config::session_state::SessionStatePaths; use crate::util::{api, output}; #[derive(Subcommand)] @@ -64,19 +65,43 @@ pub async fn run(command: Command, session: Option<&str>) -> anyhow::Result<()> } } +/// Resolve session_id: explicit --session-id flag, or query daemon. +async fn resolve_session_id( + explicit_session_id: Option<&str>, + session: Option<&str>, +) -> anyhow::Result { + if let Some(id) = explicit_session_id { + let trimmed = id.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); + } + } + + let session_name = session.unwrap_or("default"); + let mut client = DaemonClient::connect(session_name).await.map_err(|_| { + anyhow::anyhow!( + "No running session \"{session_name}\". \ + Pass `--session-id`, or start a session with `steel browser start`." + ) + })?; + + let data = client.send(DaemonCommand::GetSessionInfo).await?; + let info: SessionInfo = serde_json::from_value(data)?; + Ok(info.session_id) +} + async fn run_solve(args: SolveArgs, session: Option<&str>) -> anyhow::Result<()> { let (mode, base_url, auth) = api::resolve_with_auth(); let client = SteelClient::new()?; - let paths = SessionStatePaths::default_paths(); + + let session_id = resolve_session_id(args.session_id.as_deref(), session).await?; let result = lifecycle::solve_captcha( &client, &base_url, mode, &auth, - &paths, - args.session_id.as_deref(), - session, + &session_id, args.page_id.as_deref(), args.url.as_deref(), args.task_id.as_deref(), @@ -112,16 +137,15 @@ async fn run_solve(args: SolveArgs, session: Option<&str>) -> anyhow::Result<()> async fn run_status(args: StatusArgs, session: Option<&str>) -> anyhow::Result<()> { let (mode, base_url, auth) = api::resolve_with_auth(); let client = SteelClient::new()?; - let paths = SessionStatePaths::default_paths(); + + let session_id = resolve_session_id(args.session_id.as_deref(), session).await?; let result = lifecycle::captcha_status( &client, &base_url, mode, &auth, - &paths, - args.session_id.as_deref(), - session, + &session_id, args.page_id.as_deref(), args.wait, args.timeout, diff --git a/src/commands/browser/live.rs b/src/commands/browser/live.rs index 96c2202..38de0b1 100644 --- a/src/commands/browser/live.rs +++ b/src/commands/browser/live.rs @@ -1,36 +1,38 @@ use clap::Parser; use serde_json::json; -use crate::api::client::SteelClient; -use crate::browser::lifecycle::get_live_url; -use crate::config::session_state::SessionStatePaths; -use crate::util::{api, output}; +use crate::browser::daemon::client::DaemonClient; +use crate::browser::daemon::protocol::{DaemonCommand, SessionInfo}; +use crate::util::output; #[derive(Parser)] pub struct Args {} pub async fn run(_args: Args, session: Option<&str>) -> anyhow::Result<()> { - let (mode, base_url, auth) = api::resolve_with_auth(); + let session_name = session.unwrap_or("default"); - let client = SteelClient::new()?; - let paths = SessionStatePaths::default_paths(); + let mut client = DaemonClient::connect(session_name).await.map_err(|_| { + let msg = match session { + Some(name) => format!( + "No running session \"{name}\". \ + Start one with `steel browser start --session {name}`." + ), + None => "No active browser session. Start one with `steel browser start`.".to_string(), + }; + anyhow::anyhow!("{msg}") + })?; - let live_url = get_live_url(&client, &base_url, mode, &auth, &paths, session).await?; + let data = client.send(DaemonCommand::GetSessionInfo).await?; + let info: SessionInfo = serde_json::from_value(data)?; - match live_url { + match info.viewer_url { Some(url) => { output::success(json!(url), &format!("{url}\n")); } None => { - let msg = match session { - Some(name) => format!( - "No live session found for \"{name}\". \ - Start one with `steel browser start --session {name}`." - ), - None => "No active live session found. Start one with `steel browser start`." - .to_string(), - }; - anyhow::bail!("{msg}"); + anyhow::bail!( + "Session \"{session_name}\" has no live viewer URL." + ); } } diff --git a/src/commands/browser/sessions.rs b/src/commands/browser/sessions.rs index b555c89..58a42ce 100644 --- a/src/commands/browser/sessions.rs +++ b/src/commands/browser/sessions.rs @@ -1,32 +1,41 @@ use clap::Parser; -use crate::api::client::SteelClient; -use crate::browser::lifecycle::list_sessions; -use crate::config::session_state::SessionStatePaths; -use crate::util::{api, output}; +use crate::browser::daemon::client::DaemonClient; +use crate::browser::daemon::process; +use crate::browser::daemon::protocol::{DaemonCommand, SessionInfo}; +use crate::util::output; #[derive(Parser)] pub struct Args {} pub async fn run(_args: Args) -> anyhow::Result<()> { - let (mode, base_url, auth) = api::resolve_with_auth(); + let names = process::list_daemon_names(); - let client = SteelClient::new()?; - let paths = SessionStatePaths::default_paths(); - - let sessions = list_sessions(&client, &base_url, mode, &auth, &paths).await?; + let mut sessions: Vec = Vec::new(); + for name in &names { + match DaemonClient::connect(name).await { + Ok(mut client) => { + if let Ok(data) = client.send(DaemonCommand::GetSessionInfo).await { + if let Ok(info) = serde_json::from_value::(data) { + sessions.push(info); + } + } + } + Err(_) => { + // Stale socket — clean up + process::cleanup_stale(name); + } + } + } let data: Vec = sessions .iter() .map(|s| { let mut obj = serde_json::json!({ - "id": s.id, + "id": s.session_id, + "name": s.session_name, "mode": s.mode.to_string(), - "live": s.live, }); - if let Some(ref name) = s.name { - obj["name"] = serde_json::json!(name); - } if let Some(ref status) = s.status { obj["status"] = serde_json::json!(status); } @@ -45,14 +54,13 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { // Human-readable table let max_id = sessions .iter() - .map(|s| s.id.len()) + .map(|s| s.session_id.len()) .max() .unwrap_or(2) .max(2); let max_name = sessions .iter() - .filter_map(|s| s.name.as_ref()) - .map(|n| n.len()) + .map(|s| s.session_name.len()) .max() .unwrap_or(4) .max(4); @@ -61,14 +69,10 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { "ID", "NAME", "MODE" ); for s in &sessions { - let name = s.name.as_deref().unwrap_or("-"); - let status = s - .status - .as_deref() - .unwrap_or(if s.live { "live" } else { "-" }); + let status = s.status.as_deref().unwrap_or("live"); println!( "{:) -> anyhow::Result<()> { let (mode, base_url, auth) = api::resolve_with_auth(); - let client = SteelClient::new()?; - let paths = SessionStatePaths::default_paths(); + let session_name = session.unwrap_or("default").to_string(); // Resolve profile let mut resolved_profile_id = None; @@ -75,7 +73,19 @@ pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> { let persist_profile = args.profile.is_some() && args.update_profile; - let options = CreateSessionOptions { + // Check if daemon already running → reattach + if let Ok(mut client) = DaemonClient::connect(&session_name).await { + let info = get_session_info(&mut client).await?; + display_session_info(&info); + return Ok(()); + } + + // Build params and spawn daemon + let params = DaemonCreateParams { + api_key: auth.api_key, + base_url, + mode, + session_name: session_name.clone(), stealth: args.stealth, proxy_url: args.proxy, timeout_ms: args.session_timeout, @@ -88,11 +98,16 @@ pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> { credentials: args.credentials, }; - let session = start_session(&client, &base_url, mode, &auth, &paths, session, &options).await?; + process::spawn_daemon(&session_name, ¶ms)?; + process::wait_for_daemon(&session_name, std::time::Duration::from_secs(30)).await?; + + // Connect and get session info + let mut client = DaemonClient::connect(&session_name).await?; + let info = get_session_info(&mut client).await?; - // Write profile mapping back if a profile was specified + // Profile write-back using session_info.profile_id if let Some(ref profile_name) = args.profile - && let Some(ref returned_profile_id) = session.profile_id + && let Some(ref returned_profile_id) = info.profile_id { profile_store::write_profile( profile_name, @@ -102,48 +117,40 @@ pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> { )?; } - // Eagerly spawn daemon so subsequent commands are fast - if let Some(ref url) = session.connect_url { - use crate::browser::daemon::process; - if !process::socket_path(&session.id).exists() { - if let Err(e) = process::spawn_daemon(&session.id, url) { - eprintln!("Warning: failed to start browser daemon: {e}"); - } else if let Err(e) = - process::wait_for_daemon(&session.id, std::time::Duration::from_secs(10)).await - { - eprintln!("Warning: browser daemon did not become ready: {e}"); - } - } - } + display_session_info(&info); + Ok(()) +} + +async fn get_session_info(client: &mut DaemonClient) -> anyhow::Result { + let data = client.send(DaemonCommand::GetSessionInfo).await?; + let info: SessionInfo = serde_json::from_value(data)?; + Ok(info) +} + +fn display_session_info(info: &SessionInfo) { if output::is_json() { let mut data = json!({ - "id": session.id, - "mode": session.mode.to_string(), + "id": info.session_id, + "mode": info.mode.to_string(), }); - if let Some(ref name) = session.name { - data["name"] = json!(name); - } - if let Some(ref url) = session.viewer_url { + data["name"] = json!(&info.session_name); + if let Some(ref url) = info.viewer_url { data["liveUrl"] = json!(url); } - if let Some(ref url) = session.connect_url { + if let Some(ref url) = info.connect_url { data["connectUrl"] = json!(sanitize_connect_url(url)); } output::success_data(data); } else { - println!("id: {}", session.id); - println!("mode: {}", session.mode); - if let Some(ref name) = session.name { - println!("name: {name}"); - } - if let Some(ref url) = session.viewer_url { + println!("id: {}", info.session_id); + println!("mode: {}", info.mode); + println!("name: {}", info.session_name); + if let Some(ref url) = info.viewer_url { println!("live_url: {url}"); } - if let Some(ref url) = session.connect_url { + if let Some(ref url) = info.connect_url { println!("connect_url: {}", sanitize_connect_url(url)); } } - - Ok(()) } diff --git a/src/commands/browser/stop.rs b/src/commands/browser/stop.rs index 4b69dc4..04d058d 100644 --- a/src/commands/browser/stop.rs +++ b/src/commands/browser/stop.rs @@ -1,10 +1,8 @@ use clap::Parser; use serde_json::json; -use crate::api::client::SteelClient; -use crate::browser::lifecycle::stop_session; -use crate::config::session_state::SessionStatePaths; -use crate::util::{api, output}; +use crate::browser::daemon::process; +use crate::util::output; #[derive(Parser)] pub struct Args { @@ -18,33 +16,35 @@ pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> { anyhow::bail!("Cannot combine `--all` with `--session`."); } - let (mode, base_url, auth) = api::resolve_with_auth(); - - let client = SteelClient::new()?; - let paths = SessionStatePaths::default_paths(); - - let result = stop_session(&client, &base_url, mode, &auth, &paths, session, args.all).await?; - - // Stop daemons for released sessions - for id in &result.stopped_session_ids { - let _ = crate::browser::daemon::process::stop_daemon(id).await; - } - - if output::is_json() { - output::success_data(json!({ - "stoppedSessionIds": result.stopped_session_ids, - "mode": result.mode.to_string(), - })); - } else if result.stopped_session_ids.is_empty() { - println!("No active browser sessions to stop."); - } else if result.all { - println!( - "Stopped {} sessions in {} mode.", - result.stopped_session_ids.len(), - result.mode - ); + if args.all { + let names = process::list_daemon_names(); + if names.is_empty() { + if output::is_json() { + output::success_data(json!({ "stoppedSessions": [] })); + } else { + println!("No active browser sessions to stop."); + } + return Ok(()); + } + + for name in &names { + let _ = process::stop_daemon(name).await; + } + + if output::is_json() { + output::success_data(json!({ "stoppedSessions": names })); + } else { + println!("Stopped {} sessions.", names.len()); + } } else { - println!("Stopped session {}.", result.stopped_session_ids[0]); + let session_name = session.unwrap_or("default"); + process::stop_daemon(session_name).await?; + + if output::is_json() { + output::success_data(json!({ "stoppedSessions": [session_name] })); + } else { + println!("Stopped session \"{session_name}\"."); + } } Ok(()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 30c1506..8e74c97 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -234,7 +234,7 @@ pub enum Command { #[command(name = "__daemon", hide = true)] Daemon { #[arg(long)] - session_id: String, + session_name: String, }, /// Scrape webpage content (markdown output by default) @@ -292,10 +292,13 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { crate::util::api::init(cli.local, cli.api_url); match cli.command { - Command::Daemon { session_id } => { - let cdp_url = std::env::var("STEEL_DAEMON_CDP_URL") - .map_err(|_| anyhow::anyhow!("Missing STEEL_DAEMON_CDP_URL"))?; - crate::browser::daemon::server::run(session_id, cdp_url).await + Command::Daemon { session_name } => { + let params_json = std::env::var("STEEL_DAEMON_PARAMS") + .map_err(|_| anyhow::anyhow!("Missing STEEL_DAEMON_PARAMS"))?; + let params: crate::browser::daemon::protocol::DaemonCreateParams = + serde_json::from_str(¶ms_json) + .map_err(|e| anyhow::anyhow!("Invalid STEEL_DAEMON_PARAMS: {e}"))?; + crate::browser::daemon::server::run(session_name, params).await } Command::Scrape(args) => scrape::run(args).await, Command::Screenshot(args) => screenshot::run(args).await, diff --git a/src/config/mod.rs b/src/config/mod.rs index 32d19d5..4e9b4a8 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,5 +1,4 @@ pub mod auth; -pub mod session_state; pub mod settings; use std::path::{Path, PathBuf}; diff --git a/src/config/session_state.rs b/src/config/session_state.rs deleted file mode 100644 index 19937b1..0000000 --- a/src/config/session_state.rs +++ /dev/null @@ -1,483 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime}; - -use anyhow::{Context, Result, bail}; -use serde::{Deserialize, Serialize}; - -use crate::config::settings::ApiMode; - -const LOCK_TIMEOUT: Duration = Duration::from_millis(5000); -const LOCK_RETRY: Duration = Duration::from_millis(50); -const LOCK_STALE: Duration = Duration::from_millis(15_000); - -/// Persistent session state. Matches TS `BrowserSessionState`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[derive(Default)] -pub struct SessionState { - pub active_session_id: Option, - pub active_api_mode: Option, - pub active_session_name: Option, - pub named_sessions: NamedSessions, - pub updated_at: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct NamedSessions { - pub cloud: HashMap, - pub local: HashMap, -} - -impl NamedSessions { - pub fn get(&self, mode: ApiMode) -> &HashMap { - match mode { - ApiMode::Cloud => &self.cloud, - ApiMode::Local => &self.local, - } - } - - pub fn get_mut(&mut self, mode: ApiMode) -> &mut HashMap { - match mode { - ApiMode::Cloud => &mut self.cloud, - ApiMode::Local => &mut self.local, - } - } -} - -impl SessionState { - /// Set the active session. Matches TS `setActiveSessionState()`. - pub fn set_active(&mut self, mode: ApiMode, session_id: &str, session_name: Option<&str>) { - self.active_api_mode = Some(mode); - self.active_session_id = Some(session_id.to_string()); - self.active_session_name = session_name.map(|s| s.to_string()); - } - - /// Clear the active session. Matches TS `clearActiveSessionState()`. - pub fn clear_active(&mut self, mode: ApiMode, session_id: &str) { - if self.active_api_mode == Some(mode) - && self.active_session_id.as_deref() == Some(session_id) - { - self.active_api_mode = None; - self.active_session_id = None; - self.active_session_name = None; - } - - // Remove from named sessions - self.named_sessions - .get_mut(mode) - .retain(|_, v| v != session_id); - } - - /// Find a candidate session ID. Matches TS `resolveCandidateSessionId()`. - pub fn resolve_candidate(&self, mode: ApiMode, session_name: Option<&str>) -> Option<&str> { - if let Some(name) = session_name { - return self.named_sessions.get(mode).get(name).map(|s| s.as_str()); - } - - if self.active_api_mode == Some(mode) { - return self.active_session_id.as_deref(); - } - - None - } - - /// Find the name for a session ID. Matches TS `resolveNameFromState()`. - pub fn resolve_name(&self, mode: ApiMode, session_id: &str) -> Option<&str> { - for (name, id) in self.named_sessions.get(mode) { - if id == session_id { - return Some(name.as_str()); - } - } - - if self.active_api_mode == Some(mode) - && self.active_session_id.as_deref() == Some(session_id) - { - return self.active_session_name.as_deref(); - } - - None - } -} - -/// File paths for session state storage. -pub struct SessionStatePaths { - pub state_path: PathBuf, - pub lock_path: PathBuf, - pub dir: PathBuf, -} - -impl SessionStatePaths { - pub fn new(config_dir: &Path) -> Self { - let state_path = config_dir.join("browser-session-state.json"); - let lock_path = config_dir.join("browser-session-state.json.lock"); - Self { - state_path, - lock_path, - dir: config_dir.to_path_buf(), - } - } - - pub fn default_paths() -> Self { - Self::new(&crate::config::config_dir()) - } -} - -/// Read session state from disk. Returns default if file doesn't exist or is invalid. -pub fn read_state(path: &Path) -> SessionState { - let contents = match std::fs::read_to_string(path) { - Ok(c) => c, - Err(_) => return SessionState::default(), - }; - - serde_json::from_str(&contents).unwrap_or_default() -} - -/// Write session state to disk. -fn write_state(path: &Path, state: &mut SessionState) -> Result<()> { - state.updated_at = Some(now_iso()); - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; - } - let contents = serde_json::to_string_pretty(state)?; - std::fs::write(path, contents)?; - Ok(()) -} - -fn now_iso() -> String { - jiff::Timestamp::now().to_string() -} - -/// Acquire an exclusive file lock. Matches TS `acquireLock()`. -fn acquire_lock(lock_path: &Path) -> Result<()> { - let started = std::time::Instant::now(); - - loop { - // Try exclusive create (O_CREAT | O_EXCL) - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(lock_path) - { - Ok(_) => return Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - // Check if lock is stale - if let Ok(metadata) = std::fs::metadata(lock_path) - && let Ok(modified) = metadata.modified() - && let Ok(age) = SystemTime::now().duration_since(modified) - && age > LOCK_STALE - { - let _ = std::fs::remove_file(lock_path); - continue; - } - - if started.elapsed() >= LOCK_TIMEOUT { - bail!("Timed out waiting for browser session state lock."); - } - - std::thread::sleep(LOCK_RETRY); - } - Err(e) => { - return Err(e).context("Failed to acquire session state lock"); - } - } - } -} - -/// Release the file lock. -fn release_lock(lock_path: &Path) { - let _ = std::fs::remove_file(lock_path); -} - -/// Execute an operation under the session state lock. -/// Matches TS `withSessionStateLock()`. -/// -/// If `write` is true (default), the state is written back after the operation. -pub fn with_lock(paths: &SessionStatePaths, write: bool, operation: F) -> Result -where - F: FnOnce(&mut SessionState) -> T, -{ - std::fs::create_dir_all(&paths.dir)?; - acquire_lock(&paths.lock_path)?; - - let result = (|| { - let mut state = read_state(&paths.state_path); - let result = operation(&mut state); - if write { - write_state(&paths.state_path, &mut state)?; - } - Ok(result) - })(); - - release_lock(&paths.lock_path); - result -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn tmp_paths() -> (TempDir, SessionStatePaths) { - let dir = TempDir::new().unwrap(); - let paths = SessionStatePaths::new(dir.path()); - (dir, paths) - } - - // --- SessionState unit tests --- - - #[test] - fn default_state() { - let state = SessionState::default(); - assert!(state.active_session_id.is_none()); - assert!(state.active_api_mode.is_none()); - assert!(state.named_sessions.cloud.is_empty()); - assert!(state.named_sessions.local.is_empty()); - } - - #[test] - fn set_and_resolve_active() { - let mut state = SessionState::default(); - state.set_active(ApiMode::Cloud, "sess-1", Some("work")); - - assert_eq!(state.active_session_id.as_deref(), Some("sess-1")); - assert_eq!(state.active_api_mode, Some(ApiMode::Cloud)); - assert_eq!(state.active_session_name.as_deref(), Some("work")); - } - - #[test] - fn clear_active_matching() { - let mut state = SessionState::default(); - state.set_active(ApiMode::Cloud, "sess-1", Some("work")); - state - .named_sessions - .cloud - .insert("work".into(), "sess-1".into()); - - state.clear_active(ApiMode::Cloud, "sess-1"); - - assert!(state.active_session_id.is_none()); - assert!(state.named_sessions.cloud.is_empty()); - } - - #[test] - fn clear_active_non_matching_mode_preserved() { - let mut state = SessionState::default(); - state.set_active(ApiMode::Cloud, "sess-1", None); - - state.clear_active(ApiMode::Local, "sess-1"); - - assert_eq!(state.active_session_id.as_deref(), Some("sess-1")); - } - - #[test] - fn resolve_candidate_by_name() { - let mut state = SessionState::default(); - state - .named_sessions - .cloud - .insert("work".into(), "sess-1".into()); - - assert_eq!( - state.resolve_candidate(ApiMode::Cloud, Some("work")), - Some("sess-1") - ); - assert_eq!( - state.resolve_candidate(ApiMode::Cloud, Some("missing")), - None - ); - } - - #[test] - fn resolve_candidate_by_active() { - let mut state = SessionState::default(); - state.set_active(ApiMode::Local, "sess-2", None); - - assert_eq!( - state.resolve_candidate(ApiMode::Local, None), - Some("sess-2") - ); - assert_eq!(state.resolve_candidate(ApiMode::Cloud, None), None); - } - - #[test] - fn resolve_name_from_named_sessions() { - let mut state = SessionState::default(); - state - .named_sessions - .cloud - .insert("work".into(), "sess-1".into()); - - assert_eq!(state.resolve_name(ApiMode::Cloud, "sess-1"), Some("work")); - assert_eq!(state.resolve_name(ApiMode::Cloud, "other"), None); - } - - #[test] - fn resolve_name_falls_back_to_active() { - let mut state = SessionState::default(); - state.set_active(ApiMode::Cloud, "sess-1", Some("dev")); - - assert_eq!(state.resolve_name(ApiMode::Cloud, "sess-1"), Some("dev")); - } - - // --- Serialization --- - - #[test] - fn state_json_roundtrip() { - let mut state = SessionState::default(); - state.set_active(ApiMode::Cloud, "abc-123", Some("main")); - state - .named_sessions - .cloud - .insert("main".into(), "abc-123".into()); - - let json = serde_json::to_string_pretty(&state).unwrap(); - let parsed: SessionState = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.active_session_id.as_deref(), Some("abc-123")); - assert_eq!(parsed.active_api_mode, Some(ApiMode::Cloud)); - assert_eq!( - parsed.named_sessions.cloud.get("main").map(|s| s.as_str()), - Some("abc-123") - ); - } - - #[test] - fn state_reads_ts_camel_case() { - let json = r#"{ - "activeSessionId": "s1", - "activeApiMode": "local", - "activeSessionName": "test", - "namedSessions": { - "cloud": {}, - "local": {"test": "s1"} - }, - "updatedAt": "2025-01-01T00:00:00.000Z" - }"#; - - let state: SessionState = serde_json::from_str(json).unwrap(); - assert_eq!(state.active_session_id.as_deref(), Some("s1")); - assert_eq!(state.active_api_mode, Some(ApiMode::Local)); - assert_eq!( - state.named_sessions.local.get("test").map(|s| s.as_str()), - Some("s1") - ); - } - - #[test] - fn read_state_missing_file() { - let state = read_state(Path::new("/nonexistent/state.json")); - assert!(state.active_session_id.is_none()); - } - - #[test] - fn read_state_invalid_json() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("state.json"); - std::fs::write(&path, "not json").unwrap(); - - let state = read_state(&path); - assert!(state.active_session_id.is_none()); - } - - // --- File lock --- - - #[test] - fn with_lock_read_only() { - let (_dir, paths) = tmp_paths(); - - let result = with_lock(&paths, false, |state| { - state - .resolve_candidate(ApiMode::Cloud, None) - .map(|s| s.to_string()) - }) - .unwrap(); - - assert_eq!(result, None); - assert!(!paths.state_path.exists()); - } - - #[test] - fn with_lock_write() { - let (_dir, paths) = tmp_paths(); - - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Cloud, "sess-1", Some("work")); - state - .named_sessions - .cloud - .insert("work".into(), "sess-1".into()); - }) - .unwrap(); - - let state = read_state(&paths.state_path); - assert_eq!(state.active_session_id.as_deref(), Some("sess-1")); - assert_eq!( - state.named_sessions.cloud.get("work").map(|s| s.as_str()), - Some("sess-1") - ); - } - - #[test] - fn lock_released_after_operation() { - let (_dir, paths) = tmp_paths(); - - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Local, "s1", None); - }) - .unwrap(); - - assert!(!paths.lock_path.exists()); - - // Second lock should succeed - with_lock(&paths, false, |_| {}).unwrap(); - } - - #[test] - fn state_persists_across_calls() { - let (_dir, paths) = tmp_paths(); - - with_lock(&paths, true, |state| { - state - .named_sessions - .local - .insert("dev".into(), "sess-a".into()); - }) - .unwrap(); - - let id = with_lock(&paths, false, |state| { - state - .resolve_candidate(ApiMode::Local, Some("dev")) - .map(|s| s.to_string()) - }) - .unwrap(); - - assert_eq!(id.as_deref(), Some("sess-a")); - } - - #[test] - fn now_iso_produces_valid_timestamp() { - let ts = now_iso(); - assert!(ts.contains('T'), "Should contain T separator, got: {ts}"); - assert!(ts.ends_with('Z'), "Should end with Z, got: {ts}"); - assert!( - ts.parse::().is_ok(), - "Should parse as RFC3339, got: {ts}" - ); - } - - #[test] - fn updated_at_is_iso_after_write() { - let (_dir, paths) = tmp_paths(); - - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Cloud, "s1", None); - }) - .unwrap(); - - let state = read_state(&paths.state_path); - let ts = state.updated_at.unwrap(); - assert!(ts.contains('T'), "updated_at should be ISO 8601, got: {ts}"); - assert!(ts.ends_with('Z'), "updated_at should end with Z, got: {ts}"); - } -} diff --git a/tests/lifecycle_contract.rs b/tests/lifecycle_contract.rs index 061eab4..43af371 100644 --- a/tests/lifecycle_contract.rs +++ b/tests/lifecycle_contract.rs @@ -1,18 +1,18 @@ //! Integration tests for browser session lifecycle contracts. //! -//! These tests mirror the contracts from the Node.js `browser-lifecycle.test.ts`, -//! using wiremock to mock the Steel API and tempfile for isolated session state. +//! Pure-function tests for `to_session_summary`, `sanitize_connect_url`, and +//! connect-URL injection logic. Also covers daemon protocol types +//! (`DaemonCreateParams`, `SessionInfo`, `GetSessionInfo`) and +//! `list_daemon_names()`. use serde_json::json; use tempfile::TempDir; -use wiremock::matchers::{body_json, header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; -use steel_cli::api::client::SteelClient; -use steel_cli::api::session::CreateSessionOptions; +use steel_cli::browser::daemon::protocol::{ + DaemonCommand, DaemonCreateParams, SessionInfo, +}; use steel_cli::browser::lifecycle::*; use steel_cli::config::auth::{Auth, AuthSource}; -use steel_cli::config::session_state::{SessionStatePaths, read_state, with_lock}; use steel_cli::config::settings::ApiMode; // --------------------------------------------------------------------------- @@ -33,454 +33,8 @@ fn local_auth() -> Auth { } } -fn tmp_paths() -> (TempDir, SessionStatePaths) { - let dir = TempDir::new().unwrap(); - let paths = SessionStatePaths::new(dir.path()); - (dir, paths) -} - -fn new_client() -> SteelClient { - SteelClient::new().unwrap() -} - -/// Mount a mock that returns a single live session for POST /sessions. -async fn mock_create_session(server: &MockServer, id: &str) { - Mock::given(method("POST")) - .and(path("/sessions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": id, - "status": "live", - "isLive": true, - }))) - .mount(server) - .await; -} - -/// Mount a mock that returns a single session for GET /sessions/:id. -async fn mock_get_session(server: &MockServer, id: &str, live: bool) { - let status = if live { "live" } else { "released" }; - Mock::given(method("GET")) - .and(path(format!("/sessions/{id}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": id, - "status": status, - "isLive": live, - }))) - .mount(server) - .await; -} - -/// Mount a mock that returns 404 for GET /sessions/:id. -async fn mock_get_session_not_found(server: &MockServer, id: &str) { - Mock::given(method("GET")) - .and(path(format!("/sessions/{id}"))) - .respond_with(ResponseTemplate::new(404).set_body_json(json!({"message": "Not found"}))) - .mount(server) - .await; -} - -/// Mount a mock for POST /sessions/:id/release. -async fn mock_release_session(server: &MockServer, id: &str) { - Mock::given(method("POST")) - .and(path(format!("/sessions/{id}/release"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(server) - .await; -} - -// =========================================================================== -// 1. Session Creation: creates cloud session and persists active state -// =========================================================================== - -#[tokio::test] -async fn creates_cloud_session_and_persists_active_state() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - mock_create_session(&server, "sess-cloud-1").await; - - let summary = start_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - None, - &CreateSessionOptions::default(), - ) - .await - .unwrap(); - - assert_eq!(summary.id, "sess-cloud-1"); - assert_eq!(summary.mode, ApiMode::Cloud); - assert!(summary.live); - - // Verify state was persisted - let state = read_state(&paths.state_path); - assert_eq!(state.active_session_id.as_deref(), Some("sess-cloud-1")); - assert_eq!(state.active_api_mode, Some(ApiMode::Cloud)); -} - -// =========================================================================== -// 2. Session Creation: maps config fields into create payload -// =========================================================================== - -#[tokio::test] -async fn maps_session_config_fields_into_create_payload() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - // Expect the exact mapped payload - Mock::given(method("POST")) - .and(path("/sessions")) - .and(body_json(json!({ - "timeout": 60000, - "headless": true, - "region": "us-east-1", - "solveCaptcha": true, - "stealthConfig": { - "humanizeInteractions": true, - "autoCaptchaSolving": true - } - }))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "sess-mapped", - "status": "live", - "isLive": true, - }))) - .mount(&server) - .await; - - let options = CreateSessionOptions { - timeout_ms: Some(60000), - headless: Some(true), - region: Some("us-east-1".to_string()), - solve_captcha: true, - stealth: true, - ..Default::default() - }; - - let summary = start_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - None, - &options, - ) - .await - .unwrap(); - - assert_eq!(summary.id, "sess-mapped"); -} - -// =========================================================================== -// 3. Session Creation: creates local session when explicit api-url is provided -// =========================================================================== - -#[tokio::test] -async fn creates_local_session_with_explicit_api_url() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - mock_create_session(&server, "sess-local-1").await; - - // When using Local mode (as resolved from an explicit non-steel api-url), - // the session should be created in Local mode. - let summary = start_session( - &client, - &server.uri(), - ApiMode::Local, - &local_auth(), - &paths, - None, - &CreateSessionOptions::default(), - ) - .await - .unwrap(); - - assert_eq!(summary.id, "sess-local-1"); - assert_eq!(summary.mode, ApiMode::Local); - assert!(summary.live); - - let state = read_state(&paths.state_path); - assert_eq!(state.active_session_id.as_deref(), Some("sess-local-1")); - assert_eq!(state.active_api_mode, Some(ApiMode::Local)); -} - -// =========================================================================== -// 4. Session Creation: reattaches a named live session instead of creating new -// =========================================================================== - -#[tokio::test] -async fn reattaches_named_live_session() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - // Pre-seed state with a named session - with_lock(&paths, true, |state| { - state - .named_sessions - .cloud - .insert("work".to_string(), "existing-sess".to_string()); - }) - .unwrap(); - - // Mock: GET /sessions/existing-sess returns a live session - mock_get_session(&server, "existing-sess", true).await; - - // No POST /sessions mock -- if start_session tries to create, it will fail - let summary = start_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - Some("work"), - &CreateSessionOptions::default(), - ) - .await - .unwrap(); - - assert_eq!(summary.id, "existing-sess"); - assert_eq!(summary.mode, ApiMode::Cloud); - assert!(summary.live); - - // Active state should point to the reattached session - let state = read_state(&paths.state_path); - assert_eq!(state.active_session_id.as_deref(), Some("existing-sess")); - assert_eq!(state.active_session_name.as_deref(), Some("work")); -} - -// =========================================================================== -// 5. Session Creation: clears dead sessions from state and creates new one -// =========================================================================== - -#[tokio::test] -async fn clears_dead_session_and_creates_new() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - // Pre-seed state with a named session that is now dead - with_lock(&paths, true, |state| { - state - .named_sessions - .cloud - .insert("work".to_string(), "dead-sess".to_string()); - }) - .unwrap(); - - // Mock: GET /sessions/dead-sess returns 404 (dead) - mock_get_session_not_found(&server, "dead-sess").await; - // Mock: POST /sessions creates a new session - mock_create_session(&server, "new-sess").await; - - let summary = start_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - Some("work"), - &CreateSessionOptions::default(), - ) - .await - .unwrap(); - - assert_eq!(summary.id, "new-sess"); - assert!(summary.live); - - // Verify dead session was cleared and new one is active - let state = read_state(&paths.state_path); - assert_eq!(state.active_session_id.as_deref(), Some("new-sess")); - assert_eq!( - state.named_sessions.cloud.get("work").map(|s| s.as_str()), - Some("new-sess") - ); -} - -// =========================================================================== -// 6. Session Stop: stops active session by releasing via API -// =========================================================================== - -#[tokio::test] -async fn stops_active_session_by_releasing() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - // Pre-seed active session - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Cloud, "sess-to-stop", None); - }) - .unwrap(); - - mock_release_session(&server, "sess-to-stop").await; - - let result = stop_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - None, - false, - ) - .await - .unwrap(); - - assert_eq!(result.stopped_session_ids, vec!["sess-to-stop"]); - assert!(!result.all); - assert_eq!(result.mode, ApiMode::Cloud); -} - -// =========================================================================== -// 7. Session Stop: --all releases all live sessions -// =========================================================================== - -#[tokio::test] -async fn stop_all_releases_all_live_sessions() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - // Mock: GET /sessions returns two live sessions and one dead - Mock::given(method("GET")) - .and(path("/sessions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - {"id": "live-1", "status": "live", "isLive": true}, - {"id": "live-2", "status": "live", "isLive": true}, - {"id": "dead-1", "status": "released", "isLive": false}, - ]))) - .mount(&server) - .await; - - mock_release_session(&server, "live-1").await; - mock_release_session(&server, "live-2").await; - - let result = stop_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - None, - true, - ) - .await - .unwrap(); - - assert!(result.all); - assert_eq!(result.stopped_session_ids.len(), 2); - assert!(result.stopped_session_ids.contains(&"live-1".to_string())); - assert!(result.stopped_session_ids.contains(&"live-2".to_string())); -} - -// =========================================================================== -// 8. Session Stop: clears session from state -// =========================================================================== - -#[tokio::test] -async fn stop_clears_session_from_state() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - // Pre-seed with an active named session - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Cloud, "sess-named", Some("work")); - state - .named_sessions - .cloud - .insert("work".to_string(), "sess-named".to_string()); - }) - .unwrap(); - - mock_release_session(&server, "sess-named").await; - - stop_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - None, - false, - ) - .await - .unwrap(); - - // State should be cleared - let state = read_state(&paths.state_path); - assert!(state.active_session_id.is_none()); - assert!(state.active_api_mode.is_none()); - assert!(state.active_session_name.is_none()); - assert!(state.named_sessions.cloud.get("work").is_none()); -} - // =========================================================================== -// 9. Session List: lists sessions with names resolved from state -// =========================================================================== - -#[tokio::test] -async fn list_sessions_resolves_names_from_state() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - // Pre-seed state with named sessions - with_lock(&paths, true, |state| { - state - .named_sessions - .cloud - .insert("work".to_string(), "sess-1".to_string()); - state - .named_sessions - .cloud - .insert("dev".to_string(), "sess-2".to_string()); - }) - .unwrap(); - - Mock::given(method("GET")) - .and(path("/sessions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - {"id": "sess-1", "status": "live", "isLive": true}, - {"id": "sess-2", "status": "live", "isLive": true}, - {"id": "sess-3", "status": "live", "isLive": true}, - ]))) - .mount(&server) - .await; - - let summaries = list_sessions( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - ) - .await - .unwrap(); - - assert_eq!(summaries.len(), 3); - - let s1 = summaries.iter().find(|s| s.id == "sess-1").unwrap(); - assert_eq!(s1.name.as_deref(), Some("work")); - - let s2 = summaries.iter().find(|s| s.id == "sess-2").unwrap(); - assert_eq!(s2.name.as_deref(), Some("dev")); - - let s3 = summaries.iter().find(|s| s.id == "sess-3").unwrap(); - assert!(s3.name.is_none()); -} - -// =========================================================================== -// 14. Connect URL Contract: injects apiKey into connect URL for cloud mode +// Connect URL Contract: injects apiKey into connect URL for cloud mode // =========================================================================== #[test] @@ -545,7 +99,7 @@ fn does_not_duplicate_api_key_if_already_present() { } // =========================================================================== -// 15. Connect URL Contract: fallback builds wss://connect.steel.dev URL +// Connect URL Contract: fallback builds wss://connect.steel.dev URL // =========================================================================== #[test] @@ -590,7 +144,7 @@ fn no_fallback_connect_url_for_local_mode() { } // =========================================================================== -// 16. Connect URL Contract: sanitize_connect_url masks apiKey +// Connect URL Contract: sanitize_connect_url masks apiKey // =========================================================================== #[test] @@ -638,329 +192,9 @@ fn sanitize_handles_invalid_url() { } // =========================================================================== -// 17. Session State: named sessions are stored and retrieved correctly +// Viewer URL tests // =========================================================================== -#[test] -fn named_sessions_stored_and_retrieved() { - let (_dir, paths) = tmp_paths(); - - with_lock(&paths, true, |state| { - state - .named_sessions - .cloud - .insert("work".to_string(), "sess-1".to_string()); - state - .named_sessions - .cloud - .insert("dev".to_string(), "sess-2".to_string()); - state - .named_sessions - .local - .insert("local-work".to_string(), "sess-3".to_string()); - }) - .unwrap(); - - let state = read_state(&paths.state_path); - - assert_eq!( - state.named_sessions.cloud.get("work").map(|s| s.as_str()), - Some("sess-1") - ); - assert_eq!( - state.named_sessions.cloud.get("dev").map(|s| s.as_str()), - Some("sess-2") - ); - assert_eq!( - state - .named_sessions - .local - .get("local-work") - .map(|s| s.as_str()), - Some("sess-3") - ); -} - -#[test] -fn resolve_candidate_returns_named_session() { - let (_dir, paths) = tmp_paths(); - - with_lock(&paths, true, |state| { - state - .named_sessions - .cloud - .insert("work".to_string(), "sess-1".to_string()); - }) - .unwrap(); - - let id = with_lock(&paths, false, |state| { - state - .resolve_candidate(ApiMode::Cloud, Some("work")) - .map(|s| s.to_string()) - }) - .unwrap(); - - assert_eq!(id.as_deref(), Some("sess-1")); -} - -#[test] -fn resolve_name_from_state() { - let (_dir, paths) = tmp_paths(); - - with_lock(&paths, true, |state| { - state - .named_sessions - .cloud - .insert("work".to_string(), "sess-1".to_string()); - }) - .unwrap(); - - let state = read_state(&paths.state_path); - let name = state.resolve_name(ApiMode::Cloud, "sess-1"); - assert_eq!(name, Some("work")); -} - -// =========================================================================== -// 18. Session State: state persists across calls (file-backed) -// =========================================================================== - -#[test] -fn state_persists_across_calls() { - let (_dir, paths) = tmp_paths(); - - // First call: write state - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Cloud, "sess-persist", Some("project-a")); - state - .named_sessions - .cloud - .insert("project-a".to_string(), "sess-persist".to_string()); - }) - .unwrap(); - - // Second call: read state back - let state = read_state(&paths.state_path); - assert_eq!(state.active_session_id.as_deref(), Some("sess-persist")); - assert_eq!(state.active_session_name.as_deref(), Some("project-a")); - assert_eq!(state.active_api_mode, Some(ApiMode::Cloud)); - assert_eq!( - state - .named_sessions - .cloud - .get("project-a") - .map(|s| s.as_str()), - Some("sess-persist") - ); -} - -#[test] -fn state_survives_multiple_mutations() { - let (_dir, paths) = tmp_paths(); - - // Write first named session - with_lock(&paths, true, |state| { - state - .named_sessions - .cloud - .insert("alpha".to_string(), "sess-a".to_string()); - }) - .unwrap(); - - // Write second named session in separate call - with_lock(&paths, true, |state| { - state - .named_sessions - .cloud - .insert("beta".to_string(), "sess-b".to_string()); - }) - .unwrap(); - - // Both should be present - let state = read_state(&paths.state_path); - assert_eq!( - state.named_sessions.cloud.get("alpha").map(|s| s.as_str()), - Some("sess-a") - ); - assert_eq!( - state.named_sessions.cloud.get("beta").map(|s| s.as_str()), - Some("sess-b") - ); -} - -// =========================================================================== -// 19. Session State: cross-process state (write in one call, read in another) -// =========================================================================== - -#[test] -fn cross_process_state_write_then_read() { - let (_dir, paths) = tmp_paths(); - - // Simulate "process 1": write state - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Local, "cross-sess", Some("shared")); - state - .named_sessions - .local - .insert("shared".to_string(), "cross-sess".to_string()); - }) - .unwrap(); - - // Simulate "process 2": read state from disk (fresh read, no in-memory carry-over) - let fresh_state = read_state(&paths.state_path); - assert_eq!(fresh_state.active_session_id.as_deref(), Some("cross-sess")); - assert_eq!(fresh_state.active_api_mode, Some(ApiMode::Local)); - assert_eq!( - fresh_state - .named_sessions - .local - .get("shared") - .map(|s| s.as_str()), - Some("cross-sess") - ); - - // "Process 2" can also resolve the candidate - let candidate = fresh_state - .resolve_candidate(ApiMode::Local, Some("shared")) - .map(|s| s.to_string()); - assert_eq!(candidate.as_deref(), Some("cross-sess")); -} - -#[test] -fn cross_process_clear_visible_to_other_reader() { - let (_dir, paths) = tmp_paths(); - - // Process 1: create session - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Cloud, "to-clear", Some("temp")); - state - .named_sessions - .cloud - .insert("temp".to_string(), "to-clear".to_string()); - }) - .unwrap(); - - // Process 2: clear the session - with_lock(&paths, true, |state| { - state.clear_active(ApiMode::Cloud, "to-clear"); - }) - .unwrap(); - - // Process 3: reads and sees it cleared - let state = read_state(&paths.state_path); - assert!(state.active_session_id.is_none()); - assert!(state.named_sessions.cloud.get("temp").is_none()); -} - -// =========================================================================== -// Additional edge-case and contract tests -// =========================================================================== - -#[tokio::test] -async fn stop_with_no_active_session_returns_empty() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - // No mocks needed -- nothing should be called - let result = stop_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - None, - false, - ) - .await - .unwrap(); - - assert!(result.stopped_session_ids.is_empty()); -} - -#[tokio::test] -async fn stop_named_session_releases_correct_session() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - // Pre-seed two named sessions and an active one - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Cloud, "active-sess", None); - state - .named_sessions - .cloud - .insert("work".to_string(), "named-sess".to_string()); - }) - .unwrap(); - - mock_release_session(&server, "named-sess").await; - - let result = stop_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - Some("work"), - false, - ) - .await - .unwrap(); - - assert_eq!(result.stopped_session_ids, vec!["named-sess"]); - - // The active session (different from the named one) should remain - let state = read_state(&paths.state_path); - assert_eq!( - state.active_session_id.as_deref(), - Some("active-sess"), - "Active session should remain since we stopped a different named session" - ); -} - -#[tokio::test] -async fn start_session_with_dead_unnamed_active_creates_new() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); - - // Pre-seed with a dead unnamed active session - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Cloud, "dead-active", None); - }) - .unwrap(); - - // GET /sessions/dead-active -> not live - Mock::given(method("GET")) - .and(path("/sessions/dead-active")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "dead-active", - "status": "released", - "isLive": false, - }))) - .mount(&server) - .await; - - mock_create_session(&server, "fresh-sess").await; - - let summary = start_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - None, - &CreateSessionOptions::default(), - ) - .await - .unwrap(); - - assert_eq!(summary.id, "fresh-sess"); - - let state = read_state(&paths.state_path); - assert_eq!(state.active_session_id.as_deref(), Some("fresh-sess")); -} - #[test] fn viewer_url_defaults_to_cloud_url() { let session = json!({ @@ -1007,6 +241,10 @@ fn viewer_url_uses_api_response_when_present() { ); } +// =========================================================================== +// to_session_summary edge cases +// =========================================================================== + #[test] fn to_session_summary_requires_id() { let session = json!({"status": "live"}); @@ -1027,102 +265,238 @@ fn to_session_summary_passes_name_through() { assert_eq!(summary.name.as_deref(), Some("my-session")); } -#[tokio::test] -async fn list_sessions_empty() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); +// =========================================================================== +// DaemonCreateParams JSON roundtrip +// =========================================================================== + +#[test] +fn daemon_create_params_json_roundtrip() { + let params = DaemonCreateParams { + api_key: Some("sk-test-key".to_string()), + base_url: "https://api.steel.dev/v1".to_string(), + mode: ApiMode::Cloud, + session_name: "work".to_string(), + stealth: true, + proxy_url: Some("http://proxy:8080".to_string()), + timeout_ms: Some(60000), + headless: Some(true), + region: Some("us-east-1".to_string()), + solve_captcha: true, + profile_id: Some("prof-1".to_string()), + persist_profile: true, + namespace: Some("ns".to_string()), + credentials: false, + }; + + let json_str = serde_json::to_string(¶ms).unwrap(); + let back: DaemonCreateParams = serde_json::from_str(&json_str).unwrap(); + + assert_eq!(back.api_key.as_deref(), Some("sk-test-key")); + assert_eq!(back.base_url, "https://api.steel.dev/v1"); + assert_eq!(back.mode, ApiMode::Cloud); + assert_eq!(back.session_name, "work"); + assert!(back.stealth); + assert_eq!(back.proxy_url.as_deref(), Some("http://proxy:8080")); + assert_eq!(back.timeout_ms, Some(60000)); + assert_eq!(back.headless, Some(true)); + assert_eq!(back.region.as_deref(), Some("us-east-1")); + assert!(back.solve_captcha); + assert_eq!(back.profile_id.as_deref(), Some("prof-1")); + assert!(back.persist_profile); + assert_eq!(back.namespace.as_deref(), Some("ns")); + assert!(!back.credentials); +} + +#[test] +fn daemon_create_params_minimal_roundtrip() { + let params = DaemonCreateParams { + api_key: None, + base_url: "http://localhost:3000/v1".to_string(), + mode: ApiMode::Local, + session_name: "default".to_string(), + stealth: false, + proxy_url: None, + timeout_ms: None, + headless: None, + region: None, + solve_captcha: false, + profile_id: None, + persist_profile: false, + namespace: None, + credentials: false, + }; + + let json_str = serde_json::to_string(¶ms).unwrap(); + let back: DaemonCreateParams = serde_json::from_str(&json_str).unwrap(); - Mock::given(method("GET")) - .and(path("/sessions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .mount(&server) - .await; + assert!(back.api_key.is_none()); + assert_eq!(back.mode, ApiMode::Local); + assert_eq!(back.session_name, "default"); + assert!(!back.stealth); +} - let summaries = list_sessions( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - ) - .await - .unwrap(); +#[test] +fn daemon_create_params_to_create_options() { + let params = DaemonCreateParams { + api_key: Some("sk-key".to_string()), + base_url: "https://api.steel.dev/v1".to_string(), + mode: ApiMode::Cloud, + session_name: "test".to_string(), + stealth: true, + proxy_url: Some("http://proxy".to_string()), + timeout_ms: Some(30000), + headless: Some(false), + region: Some("eu-west-1".to_string()), + solve_captcha: true, + profile_id: Some("prof".to_string()), + persist_profile: true, + namespace: Some("ns".to_string()), + credentials: true, + }; - assert!(summaries.is_empty()); + let opts = params.to_create_options(); + assert!(opts.stealth); + assert_eq!(opts.proxy_url.as_deref(), Some("http://proxy")); + assert_eq!(opts.timeout_ms, Some(30000)); + assert_eq!(opts.headless, Some(false)); + assert_eq!(opts.region.as_deref(), Some("eu-west-1")); + assert!(opts.solve_captcha); + assert_eq!(opts.profile_id.as_deref(), Some("prof")); + assert!(opts.persist_profile); + assert_eq!(opts.namespace.as_deref(), Some("ns")); + assert!(opts.credentials); } -#[tokio::test] -async fn create_session_sends_api_key_header() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); +// =========================================================================== +// SessionInfo JSON roundtrip +// =========================================================================== - Mock::given(method("POST")) - .and(path("/sessions")) - .and(header("Steel-Api-Key", "sk-test-key-12345678")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "auth-sess", - "status": "live", - "isLive": true, - }))) - .mount(&server) - .await; +#[test] +fn session_info_json_roundtrip() { + let info = SessionInfo { + session_id: "sess-123".to_string(), + session_name: "work".to_string(), + mode: ApiMode::Cloud, + status: Some("live".to_string()), + connect_url: Some("wss://connect.steel.dev?sessionId=sess-123".to_string()), + viewer_url: Some("https://app.steel.dev/sessions/sess-123".to_string()), + profile_id: Some("prof-1".to_string()), + }; - let summary = start_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - None, - &CreateSessionOptions::default(), - ) - .await - .unwrap(); + let json_str = serde_json::to_string(&info).unwrap(); + let back: SessionInfo = serde_json::from_str(&json_str).unwrap(); - assert_eq!(summary.id, "auth-sess"); + assert_eq!(back.session_id, "sess-123"); + assert_eq!(back.session_name, "work"); + assert_eq!(back.mode, ApiMode::Cloud); + assert_eq!(back.status.as_deref(), Some("live")); + assert!(back.connect_url.is_some()); + assert!(back.viewer_url.is_some()); + assert_eq!(back.profile_id.as_deref(), Some("prof-1")); } -#[tokio::test] -async fn stop_all_clears_all_from_state() { - let server = MockServer::start().await; - let client = new_client(); - let (_dir, paths) = tmp_paths(); +#[test] +fn session_info_minimal_roundtrip() { + let info = SessionInfo { + session_id: "sess-min".to_string(), + session_name: "default".to_string(), + mode: ApiMode::Local, + status: None, + connect_url: None, + viewer_url: None, + profile_id: None, + }; - // Pre-seed with an active session - with_lock(&paths, true, |state| { - state.set_active(ApiMode::Cloud, "all-1", Some("work")); - state - .named_sessions - .cloud - .insert("work".to_string(), "all-1".to_string()); - }) - .unwrap(); + let json_str = serde_json::to_string(&info).unwrap(); + let back: SessionInfo = serde_json::from_str(&json_str).unwrap(); - Mock::given(method("GET")) - .and(path("/sessions")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - {"id": "all-1", "status": "live", "isLive": true}, - ]))) - .mount(&server) - .await; + assert_eq!(back.session_id, "sess-min"); + assert_eq!(back.mode, ApiMode::Local); + assert!(back.status.is_none()); + assert!(back.connect_url.is_none()); +} - mock_release_session(&server, "all-1").await; +// =========================================================================== +// GetSessionInfo command serialization +// =========================================================================== + +#[test] +fn get_session_info_serialization() { + let cmd = DaemonCommand::GetSessionInfo; + let v = serde_json::to_value(&cmd).unwrap(); + assert_eq!(v, json!({"action": "get_session_info"})); +} + +#[test] +fn get_session_info_roundtrip() { + let cmd = DaemonCommand::GetSessionInfo; + let json_str = serde_json::to_string(&cmd).unwrap(); + let back: DaemonCommand = serde_json::from_str(&json_str).unwrap(); + assert_eq!(cmd, back); +} + +// =========================================================================== +// list_daemon_names +// =========================================================================== + +#[test] +fn list_daemon_names_finds_sock_files() { + let dir = TempDir::new().unwrap(); + + // Create mock socket files + std::fs::write(dir.path().join("daemon-work.sock"), "").unwrap(); + std::fs::write(dir.path().join("daemon-dev.sock"), "").unwrap(); + std::fs::write(dir.path().join("daemon-default.sock"), "").unwrap(); + + // Also create non-socket files that should be ignored + std::fs::write(dir.path().join("daemon-work.pid"), "").unwrap(); + std::fs::write(dir.path().join("daemon-work.log"), "").unwrap(); + std::fs::write(dir.path().join("config.json"), "").unwrap(); - stop_session( - &client, - &server.uri(), - ApiMode::Cloud, - &cloud_auth(), - &paths, - None, - true, - ) - .await - .unwrap(); + // Use env override to point to temp dir + let names = list_daemon_names_in(dir.path()); - let state = read_state(&paths.state_path); - assert!(state.active_session_id.is_none()); - assert!(state.named_sessions.cloud.get("work").is_none()); + assert_eq!(names.len(), 3); + assert!(names.contains(&"work".to_string())); + assert!(names.contains(&"dev".to_string())); + assert!(names.contains(&"default".to_string())); +} + +#[test] +fn list_daemon_names_empty_dir() { + let dir = TempDir::new().unwrap(); + let names = list_daemon_names_in(dir.path()); + assert!(names.is_empty()); +} + +#[test] +fn list_daemon_names_ignores_non_sock() { + let dir = TempDir::new().unwrap(); + + std::fs::write(dir.path().join("daemon-work.pid"), "").unwrap(); + std::fs::write(dir.path().join("daemon-work.log"), "").unwrap(); + std::fs::write(dir.path().join("daemon-.sock"), "").unwrap(); // empty name + + let names = list_daemon_names_in(dir.path()); + assert!(names.is_empty()); +} + +/// Testable version that takes an explicit directory. +fn list_daemon_names_in(dir: &std::path::Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return vec![]; + }; + let mut names = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if let Some(rest) = name.strip_prefix("daemon-") { + if let Some(session_name) = rest.strip_suffix(".sock") { + if !session_name.is_empty() { + names.push(session_name.to_string()); + } + } + } + } + names } From cf4ad0cc5c447f7e8811b3149b56af27ebe4db39 Mon Sep 17 00:00:00 2001 From: junhsss Date: Wed, 18 Mar 2026 17:22:34 +0900 Subject: [PATCH 2/2] feat: expiry handling --- src/browser/daemon/client.rs | 13 +++-- src/browser/daemon/process.rs | 30 +++++++++++ src/browser/daemon/protocol.rs | 7 +++ src/browser/daemon/server.rs | 59 ++++++++++++++++++++-- src/browser/lifecycle.rs | 85 ++++++++++++++++++++++++++++++++ src/commands/browser/action.rs | 13 +++-- src/commands/browser/sessions.rs | 6 ++- src/commands/browser/start.rs | 49 ++++++++++++++++-- tests/lifecycle_contract.rs | 12 +++++ 9 files changed, 260 insertions(+), 14 deletions(-) diff --git a/src/browser/daemon/client.rs b/src/browser/daemon/client.rs index 40b47ce..84925ec 100644 --- a/src/browser/daemon/client.rs +++ b/src/browser/daemon/client.rs @@ -16,9 +16,16 @@ pub struct DaemonClient { impl DaemonClient { pub async fn connect(session_name: &str) -> Result { let sock = process::socket_path(session_name); - let stream = UnixStream::connect(&sock).await.map_err(|_| { - anyhow::anyhow!("Cannot connect to browser daemon. Is a session running?") - })?; + let stream = match UnixStream::connect(&sock).await { + Ok(s) => s, + Err(_) => { + // Socket file may exist but the daemon process is dead — clean up + process::cleanup_if_dead(session_name); + return Err(anyhow::anyhow!( + "Cannot connect to browser daemon. Is a session running?" + )); + } + }; let (read_half, write_half) = tokio::io::split(stream); Ok(Self { reader: BufReader::new(read_half), diff --git a/src/browser/daemon/process.rs b/src/browser/daemon/process.rs index d8d5a9f..72ef923 100644 --- a/src/browser/daemon/process.rs +++ b/src/browser/daemon/process.rs @@ -43,6 +43,36 @@ pub fn cleanup_stale(session_name: &str) { let _ = std::fs::remove_file(pid_path(session_name)); } +/// Check if the daemon process for this session is still alive using its PID file. +/// Returns `false` if the PID file is missing, unreadable, or the process is not running. +pub fn is_daemon_alive(session_name: &str) -> bool { + let pid_file = pid_path(session_name); + let Ok(contents) = std::fs::read_to_string(&pid_file) else { + return false; + }; + let Ok(pid) = contents.trim().parse::() else { + return false; + }; + // kill -0 checks process existence without sending a signal + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// If a daemon's socket file exists but its process is dead, clean up stale files. +/// Returns `true` if stale files were removed. +pub fn cleanup_if_dead(session_name: &str) -> bool { + if socket_path(session_name).exists() && !is_daemon_alive(session_name) { + cleanup_stale(session_name); + return true; + } + false +} + /// Spawn a daemon process for the given session. The create params are passed via /// environment variable to avoid leaking API keys in the process list. pub fn spawn_daemon(session_name: &str, params: &DaemonCreateParams) -> Result<()> { diff --git a/src/browser/daemon/protocol.rs b/src/browser/daemon/protocol.rs index b1a6a64..6718af6 100644 --- a/src/browser/daemon/protocol.rs +++ b/src/browser/daemon/protocol.rs @@ -53,6 +53,13 @@ pub struct SessionInfo { pub connect_url: Option, pub viewer_url: Option, pub profile_id: Option, + /// Session timeout in milliseconds (from create params). `None` = no timeout. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, + /// Epoch milliseconds when the session was created. Used with `timeout_ms` + /// to compute remaining time on the client side. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at_ms: Option, } #[derive(Debug, PartialEq, Serialize, Deserialize)] diff --git a/src/browser/daemon/server.rs b/src/browser/daemon/server.rs index 6056f02..11ddc7c 100644 --- a/src/browser/daemon/server.rs +++ b/src/browser/daemon/server.rs @@ -7,7 +7,9 @@ use tokio::net::UnixListener; use crate::api::client::SteelClient; use crate::browser::engine::BrowserEngine; -use crate::browser::lifecycle::to_session_summary; +use crate::browser::lifecycle::{ + get_session_created_at_ms, get_session_timeout, to_session_summary, +}; use crate::config::auth::{Auth, AuthSource}; use super::process; @@ -15,6 +17,9 @@ use super::protocol::*; const IDLE_TIMEOUT: Duration = Duration::from_secs(300); const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(30); +/// Shut down proactively this long before the cloud session is expected to expire, +/// giving us time to release cleanly rather than having the server kill the CDP socket. +const EXPIRY_BUFFER: Duration = Duration::from_secs(30); pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()> { let pid_path = process::pid_path(&session_name); @@ -57,6 +62,16 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()> } }; + // Prefer API-reported timeout over what we requested — the server may apply defaults + let effective_timeout = get_session_timeout(&session).or(params.timeout_ms); + // Prefer API-reported createdAt; fall back to local clock + let created_at_ms = get_session_created_at_ms(&session).or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .ok() + }); + let session_info = SessionInfo { session_id: summary.id.clone(), session_name: session_name.clone(), @@ -65,6 +80,29 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()> connect_url: Some(cdp_url.clone()), viewer_url: summary.viewer_url, profile_id: summary.profile_id, + timeout_ms: effective_timeout, + created_at_ms, + }; + + // Compute when the session will expire (if timeout is known). + // Use wall-clock math: `created_at_ms + timeout - buffer` converted to a tokio Instant. + let expires_at = match (effective_timeout, created_at_ms) { + (Some(timeout), Some(created)) => { + let now_epoch = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let expire_epoch = created.saturating_add(timeout); + let buffer = EXPIRY_BUFFER.as_millis() as u64; + if now_epoch >= expire_epoch.saturating_sub(buffer) { + // Already past expiry — will exit on the first loop iteration + Some(tokio::time::Instant::now()) + } else { + let remaining = expire_epoch.saturating_sub(buffer).saturating_sub(now_epoch); + Some(tokio::time::Instant::now() + Duration::from_millis(remaining)) + } + } + _ => None, }; let mut engine = match BrowserEngine::connect(&cdp_url).await { @@ -89,20 +127,35 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()> // Periodic health check: exit if CDP connection is dead if last_health_check.elapsed() >= HEALTH_CHECK_INTERVAL { if !engine.is_alive().await { + eprintln!("[daemon] CDP connection lost, shutting down"); break; } last_health_check = tokio::time::Instant::now(); } + // Proactive expiry check: exit before the server kills the session + if let Some(deadline) = expires_at { + if tokio::time::Instant::now() >= deadline { + eprintln!("[daemon] Session timeout reached, shutting down"); + break; + } + } + match tokio::time::timeout(IDLE_TIMEOUT, listener.accept()).await { - Err(_) => break, + Err(_) => { + eprintln!("[daemon] Idle timeout ({IDLE_TIMEOUT:?}), shutting down"); + break; + } Ok(Err(_)) => continue, Ok(Ok((stream, _))) => { let result = handle_connection(&mut engine, &session_info, stream).await; match result { ConnectionResult::Continue => {} ConnectionResult::Shutdown => break, - ConnectionResult::Disconnected => break, + ConnectionResult::Disconnected => { + eprintln!("[daemon] CDP disconnected during command, shutting down"); + break; + } } } } diff --git a/src/browser/lifecycle.rs b/src/browser/lifecycle.rs index 2653370..16ae15b 100644 --- a/src/browser/lifecycle.rs +++ b/src/browser/lifecycle.rs @@ -91,6 +91,47 @@ fn get_session_status(session: &Value) -> Option { .map(|s| s.trim().to_string()) } +/// Extract session timeout (milliseconds) from API response. +/// The API may return it as `timeout`, `sessionTimeout`, or `timeoutMs`. +pub fn get_session_timeout(session: &Value) -> Option { + let keys = ["timeout", "sessionTimeout", "timeoutMs"]; + for key in &keys { + if let Some(v) = session.get(key) { + if let Some(n) = v.as_u64() { + return Some(n); + } + // Handle string numbers + if let Some(s) = v.as_str() { + if let Ok(n) = s.trim().parse::() { + return Some(n); + } + } + } + } + None +} + +/// Extract session creation timestamp as epoch milliseconds from API response. +/// Looks for `createdAt` or `created_at` as ISO 8601 / RFC 3339 string. +pub fn get_session_created_at_ms(session: &Value) -> Option { + let keys = ["createdAt", "created_at"]; + for key in &keys { + if let Some(v) = session.get(key) + && let Some(s) = v.as_str() + { + let trimmed = s.trim(); + if !trimmed.is_empty() { + if let Ok(ts) = jiff::Timestamp::strptime("%Y-%m-%dT%H:%M:%S%.fZ", trimmed) + .or_else(|_| trimmed.parse::()) + { + return Some(ts.as_millisecond() as u64); + } + } + } + } + None +} + pub fn get_connect_url(session: &Value) -> Option { let keys = [ "websocketUrl", @@ -448,6 +489,50 @@ mod tests { assert!(sanitized.contains("sessionId=s1")); } + #[test] + fn session_timeout_from_response() { + assert_eq!(get_session_timeout(&json!({"timeout": 300000})), Some(300000)); + assert_eq!(get_session_timeout(&json!({"sessionTimeout": 60000})), Some(60000)); + assert_eq!(get_session_timeout(&json!({"timeoutMs": 120000})), Some(120000)); + } + + #[test] + fn session_timeout_string_number() { + assert_eq!(get_session_timeout(&json!({"timeout": "300000"})), Some(300000)); + } + + #[test] + fn session_timeout_missing() { + assert_eq!(get_session_timeout(&json!({"id": "s1"})), None); + } + + #[test] + fn session_created_at_iso() { + let s = json!({"createdAt": "2025-01-15T10:30:00Z"}); + let ms = get_session_created_at_ms(&s).unwrap(); + // 2025-01-15T10:30:00Z in epoch ms + assert!(ms > 1_700_000_000_000); + assert!(ms < 1_800_000_000_000); + } + + #[test] + fn session_created_at_with_fractional() { + let s = json!({"createdAt": "2025-01-15T10:30:00.123Z"}); + let ms = get_session_created_at_ms(&s).unwrap(); + assert!(ms > 1_700_000_000_000); + } + + #[test] + fn session_created_at_missing() { + assert_eq!(get_session_created_at_ms(&json!({"id": "s1"})), None); + } + + #[test] + fn session_created_at_snake_case() { + let s = json!({"created_at": "2025-01-15T10:30:00Z"}); + assert!(get_session_created_at_ms(&s).is_some()); + } + #[test] fn normalize_captcha_none() { let (status, types) = normalize_captcha_status(&[]); diff --git a/src/commands/browser/action.rs b/src/commands/browser/action.rs index 7faf625..b5b425f 100644 --- a/src/commands/browser/action.rs +++ b/src/commands/browser/action.rs @@ -813,10 +813,17 @@ async fn dispatch_action(client: &mut DaemonClient, action: ActionCommand) -> Re async fn ensure_daemon(session_name: Option<&str>) -> Result { let name = session_name.unwrap_or("default"); + // connect() already cleans up stale sockets via cleanup_if_dead() DaemonClient::connect(name).await.map_err(|_| { - anyhow::anyhow!( - "No running session \"{name}\". Start one with: steel browser start --session {name}" - ) + if name == "default" { + anyhow::anyhow!( + "No active browser session. Start one with: steel browser start" + ) + } else { + anyhow::anyhow!( + "No running session \"{name}\". Start one with: steel browser start --session {name}" + ) + } }) } diff --git a/src/commands/browser/sessions.rs b/src/commands/browser/sessions.rs index 58a42ce..57af876 100644 --- a/src/commands/browser/sessions.rs +++ b/src/commands/browser/sessions.rs @@ -13,6 +13,10 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { let mut sessions: Vec = Vec::new(); for name in &names { + // Fast path: skip sockets whose daemon process is already dead + if process::cleanup_if_dead(name) { + continue; + } match DaemonClient::connect(name).await { Ok(mut client) => { if let Ok(data) = client.send(DaemonCommand::GetSessionInfo).await { @@ -22,7 +26,7 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { } } Err(_) => { - // Stale socket — clean up + // Socket exists but connection failed — clean up process::cleanup_stale(name); } } diff --git a/src/commands/browser/start.rs b/src/commands/browser/start.rs index 3d160a6..cbb8450 100644 --- a/src/commands/browser/start.rs +++ b/src/commands/browser/start.rs @@ -73,11 +73,16 @@ pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> { let persist_profile = args.profile.is_some() && args.update_profile; - // Check if daemon already running → reattach + // Check if daemon already running → verify health then reattach if let Ok(mut client) = DaemonClient::connect(&session_name).await { - let info = get_session_info(&mut client).await?; - display_session_info(&info); - return Ok(()); + if client.send(DaemonCommand::Ping).await.is_ok() { + let info = get_session_info(&mut client).await?; + display_session_info(&info); + return Ok(()); + } + // Daemon socket connected but not responding — clean up and start fresh + drop(client); + process::cleanup_stale(&session_name); } // Build params and spawn daemon @@ -129,6 +134,8 @@ async fn get_session_info(client: &mut DaemonClient) -> anyhow::Result Option<(u64, String)> { + let timeout = info.timeout_ms?; + let created = info.created_at_ms?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_millis() as u64; + let expires_at = created.checked_add(timeout)?; + if now >= expires_at { + return Some((0, "expired".to_string())); + } + let remaining = expires_at - now; + let secs = remaining / 1000; + let label = if secs >= 3600 { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } else if secs >= 60 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{secs}s") + }; + Some((remaining, label)) +} diff --git a/tests/lifecycle_contract.rs b/tests/lifecycle_contract.rs index 43af371..23d0e20 100644 --- a/tests/lifecycle_contract.rs +++ b/tests/lifecycle_contract.rs @@ -381,6 +381,8 @@ fn session_info_json_roundtrip() { connect_url: Some("wss://connect.steel.dev?sessionId=sess-123".to_string()), viewer_url: Some("https://app.steel.dev/sessions/sess-123".to_string()), profile_id: Some("prof-1".to_string()), + timeout_ms: Some(300_000), + created_at_ms: Some(1_700_000_000_000), }; let json_str = serde_json::to_string(&info).unwrap(); @@ -393,6 +395,8 @@ fn session_info_json_roundtrip() { assert!(back.connect_url.is_some()); assert!(back.viewer_url.is_some()); assert_eq!(back.profile_id.as_deref(), Some("prof-1")); + assert_eq!(back.timeout_ms, Some(300_000)); + assert_eq!(back.created_at_ms, Some(1_700_000_000_000)); } #[test] @@ -405,6 +409,8 @@ fn session_info_minimal_roundtrip() { connect_url: None, viewer_url: None, profile_id: None, + timeout_ms: None, + created_at_ms: None, }; let json_str = serde_json::to_string(&info).unwrap(); @@ -414,6 +420,12 @@ fn session_info_minimal_roundtrip() { assert_eq!(back.mode, ApiMode::Local); assert!(back.status.is_none()); assert!(back.connect_url.is_none()); + assert!(back.timeout_ms.is_none()); + assert!(back.created_at_ms.is_none()); + + // Optional fields should be omitted from JSON when None + assert!(!json_str.contains("timeout_ms")); + assert!(!json_str.contains("created_at_ms")); } // ===========================================================================