Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/browser/daemon/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,18 @@ pub struct DaemonClient {
}

impl DaemonClient {
pub async fn connect(session_id: &str) -> Result<Self> {
let sock = process::socket_path(session_id);
let stream = UnixStream::connect(&sock).await.map_err(|_| {
anyhow::anyhow!("Cannot connect to browser daemon. Is a session running?")
})?;
pub async fn connect(session_name: &str) -> Result<Self> {
let sock = process::socket_path(session_name);
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),
Expand Down
101 changes: 78 additions & 23 deletions src/browser/daemon/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,93 @@ 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<String> {
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 socket_path(session_name: &str) -> PathBuf {
crate::config::config_dir().join(format!("daemon-{session_name}.sock"))
}

pub fn pid_path(session_name: &str) -> PathBuf {
crate::config::config_dir().join(format!("daemon-{session_name}.pid"))
}

pub fn pid_path(session_id: &str) -> PathBuf {
crate::config::config_dir().join(format!("daemon-{session_id}.pid"))
fn log_path(session_name: &str) -> PathBuf {
crate::config::config_dir().join(format!("daemon-{session_name}.log"))
}

fn log_path(session_id: &str) -> PathBuf {
crate::config::config_dir().join(format!("daemon-{session_id}.log"))
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));
}

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));
/// 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::<i32>() 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)
}

/// Spawn a daemon process for the given session. The CDP URL is passed via
/// 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_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)
Expand All @@ -44,8 +99,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 {
Expand All @@ -62,23 +117,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::<u32>()
{
Expand All @@ -89,6 +144,6 @@ pub fn kill_daemon(session_id: &str) -> Result<()> {
.stderr(std::process::Stdio::null())
.status();
}
cleanup_stale(session_id);
cleanup_stale(session_name);
Ok(())
}
61 changes: 61 additions & 0 deletions src/browser/daemon/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,66 @@ 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<String>,
pub base_url: String,
pub mode: ApiMode,
pub session_name: String,
// Flattened CreateSessionOptions fields:
pub stealth: bool,
pub proxy_url: Option<String>,
pub timeout_ms: Option<u64>,
pub headless: Option<bool>,
pub region: Option<String>,
pub solve_captcha: bool,
pub profile_id: Option<String>,
pub persist_profile: bool,
pub namespace: Option<String>,
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<String>,
pub connect_url: Option<String>,
pub viewer_url: Option<String>,
pub profile_id: Option<String>,
/// Session timeout in milliseconds (from create params). `None` = no timeout.
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
/// 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<u64>,
}

#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct DaemonRequest {
pub id: u64,
Expand Down Expand Up @@ -158,6 +218,7 @@ pub enum DaemonCommand {
Close,
Ping,
Shutdown,
GetSessionInfo,
}

#[derive(Debug, PartialEq, Serialize, Deserialize)]
Expand Down
Loading