Skip to content

Commit 471a426

Browse files
authored
feat: daemon-owned session lifecycle (#44)
* feat: daemon-owned session lifecycle * feat: expiry handling
1 parent af30b07 commit 471a426

File tree

15 files changed

+875
-2045
lines changed

15 files changed

+875
-2045
lines changed

src/browser/daemon/client.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,18 @@ pub struct DaemonClient {
1414
}
1515

1616
impl DaemonClient {
17-
pub async fn connect(session_id: &str) -> Result<Self> {
18-
let sock = process::socket_path(session_id);
19-
let stream = UnixStream::connect(&sock).await.map_err(|_| {
20-
anyhow::anyhow!("Cannot connect to browser daemon. Is a session running?")
21-
})?;
17+
pub async fn connect(session_name: &str) -> Result<Self> {
18+
let sock = process::socket_path(session_name);
19+
let stream = match UnixStream::connect(&sock).await {
20+
Ok(s) => s,
21+
Err(_) => {
22+
// Socket file may exist but the daemon process is dead — clean up
23+
process::cleanup_if_dead(session_name);
24+
return Err(anyhow::anyhow!(
25+
"Cannot connect to browser daemon. Is a session running?"
26+
));
27+
}
28+
};
2229
let (read_half, write_half) = tokio::io::split(stream);
2330
Ok(Self {
2431
reader: BufReader::new(read_half),

src/browser/daemon/process.rs

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,93 @@ use std::time::Duration;
33

44
use anyhow::{Result, bail};
55

6-
pub fn socket_path(session_id: &str) -> PathBuf {
7-
crate::config::config_dir().join(format!("daemon-{session_id}.sock"))
6+
use super::protocol::DaemonCreateParams;
7+
8+
/// Scan the config directory for `daemon-*.sock` files and return the session names.
9+
pub fn list_daemon_names() -> Vec<String> {
10+
let dir = crate::config::config_dir();
11+
let Ok(entries) = std::fs::read_dir(&dir) else {
12+
return vec![];
13+
};
14+
let mut names = Vec::new();
15+
for entry in entries.flatten() {
16+
let name = entry.file_name();
17+
let name = name.to_string_lossy();
18+
if let Some(rest) = name.strip_prefix("daemon-") {
19+
if let Some(session_name) = rest.strip_suffix(".sock") {
20+
if !session_name.is_empty() {
21+
names.push(session_name.to_string());
22+
}
23+
}
24+
}
25+
}
26+
names
27+
}
28+
29+
pub fn socket_path(session_name: &str) -> PathBuf {
30+
crate::config::config_dir().join(format!("daemon-{session_name}.sock"))
31+
}
32+
33+
pub fn pid_path(session_name: &str) -> PathBuf {
34+
crate::config::config_dir().join(format!("daemon-{session_name}.pid"))
835
}
936

10-
pub fn pid_path(session_id: &str) -> PathBuf {
11-
crate::config::config_dir().join(format!("daemon-{session_id}.pid"))
37+
fn log_path(session_name: &str) -> PathBuf {
38+
crate::config::config_dir().join(format!("daemon-{session_name}.log"))
1239
}
1340

14-
fn log_path(session_id: &str) -> PathBuf {
15-
crate::config::config_dir().join(format!("daemon-{session_id}.log"))
41+
pub fn cleanup_stale(session_name: &str) {
42+
let _ = std::fs::remove_file(socket_path(session_name));
43+
let _ = std::fs::remove_file(pid_path(session_name));
1644
}
1745

18-
pub fn cleanup_stale(session_id: &str) {
19-
let _ = std::fs::remove_file(socket_path(session_id));
20-
let _ = std::fs::remove_file(pid_path(session_id));
46+
/// Check if the daemon process for this session is still alive using its PID file.
47+
/// Returns `false` if the PID file is missing, unreadable, or the process is not running.
48+
pub fn is_daemon_alive(session_name: &str) -> bool {
49+
let pid_file = pid_path(session_name);
50+
let Ok(contents) = std::fs::read_to_string(&pid_file) else {
51+
return false;
52+
};
53+
let Ok(pid) = contents.trim().parse::<i32>() else {
54+
return false;
55+
};
56+
// kill -0 checks process existence without sending a signal
57+
std::process::Command::new("kill")
58+
.args(["-0", &pid.to_string()])
59+
.stdout(std::process::Stdio::null())
60+
.stderr(std::process::Stdio::null())
61+
.status()
62+
.map(|s| s.success())
63+
.unwrap_or(false)
2164
}
2265

23-
/// Spawn a daemon process for the given session. The CDP URL is passed via
66+
/// If a daemon's socket file exists but its process is dead, clean up stale files.
67+
/// Returns `true` if stale files were removed.
68+
pub fn cleanup_if_dead(session_name: &str) -> bool {
69+
if socket_path(session_name).exists() && !is_daemon_alive(session_name) {
70+
cleanup_stale(session_name);
71+
return true;
72+
}
73+
false
74+
}
75+
76+
/// Spawn a daemon process for the given session. The create params are passed via
2477
/// environment variable to avoid leaking API keys in the process list.
25-
pub fn spawn_daemon(session_id: &str, cdp_url: &str) -> Result<()> {
78+
pub fn spawn_daemon(session_name: &str, params: &DaemonCreateParams) -> Result<()> {
2679
let exe = std::env::current_exe()?;
2780

28-
cleanup_stale(session_id);
81+
cleanup_stale(session_name);
2982

3083
let config_dir = crate::config::config_dir();
3184
std::fs::create_dir_all(&config_dir)?;
3285

33-
let log = std::fs::File::create(log_path(session_id))?;
86+
let log = std::fs::File::create(log_path(session_name))?;
87+
88+
let params_json = serde_json::to_string(params)?;
3489

3590
std::process::Command::new(exe)
36-
.args(["__daemon", "--session-id", session_id])
37-
.env("STEEL_DAEMON_CDP_URL", cdp_url)
91+
.args(["__daemon", "--session-name", session_name])
92+
.env("STEEL_DAEMON_PARAMS", params_json)
3893
.stdin(std::process::Stdio::null())
3994
.stdout(log.try_clone()?)
4095
.stderr(log)
@@ -44,8 +99,8 @@ pub fn spawn_daemon(session_id: &str, cdp_url: &str) -> Result<()> {
4499
}
45100

46101
/// Wait until the daemon socket is connectable.
47-
pub async fn wait_for_daemon(session_id: &str, timeout: Duration) -> Result<()> {
48-
let sock = socket_path(session_id);
102+
pub async fn wait_for_daemon(session_name: &str, timeout: Duration) -> Result<()> {
103+
let sock = socket_path(session_name);
49104
let start = std::time::Instant::now();
50105

51106
while start.elapsed() < timeout {
@@ -62,23 +117,23 @@ pub async fn wait_for_daemon(session_id: &str, timeout: Duration) -> Result<()>
62117
}
63118

64119
/// Send a shutdown command to a running daemon and clean up files.
65-
pub async fn stop_daemon(session_id: &str) -> Result<()> {
120+
pub async fn stop_daemon(session_name: &str) -> Result<()> {
66121
use super::client::DaemonClient;
67122
use super::protocol::DaemonCommand;
68123

69-
if let Ok(mut client) = DaemonClient::connect(session_id).await {
124+
if let Ok(mut client) = DaemonClient::connect(session_name).await {
70125
let _ = client.send(DaemonCommand::Shutdown).await;
71126
}
72127

73128
tokio::time::sleep(Duration::from_millis(200)).await;
74-
cleanup_stale(session_id);
129+
cleanup_stale(session_name);
75130

76131
Ok(())
77132
}
78133

79134
/// Kill a daemon process by reading its PID file, then clean up.
80-
pub fn kill_daemon(session_id: &str) -> Result<()> {
81-
let pid_file = pid_path(session_id);
135+
pub fn kill_daemon(session_name: &str) -> Result<()> {
136+
let pid_file = pid_path(session_name);
82137
if let Ok(contents) = std::fs::read_to_string(&pid_file)
83138
&& let Ok(pid) = contents.trim().parse::<u32>()
84139
{
@@ -89,6 +144,6 @@ pub fn kill_daemon(session_id: &str) -> Result<()> {
89144
.stderr(std::process::Stdio::null())
90145
.status();
91146
}
92-
cleanup_stale(session_id);
147+
cleanup_stale(session_name);
93148
Ok(())
94149
}

src/browser/daemon/protocol.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,66 @@ use std::collections::HashMap;
22

33
use serde::{Deserialize, Serialize};
44

5+
use crate::api::session::CreateSessionOptions;
6+
use crate::config::settings::ApiMode;
7+
8+
/// Parameters passed to the daemon subprocess via env var so it can create
9+
/// the cloud session itself. Serialized as JSON into `STEEL_DAEMON_PARAMS`.
10+
#[derive(Debug, Clone, Serialize, Deserialize)]
11+
pub struct DaemonCreateParams {
12+
pub api_key: Option<String>,
13+
pub base_url: String,
14+
pub mode: ApiMode,
15+
pub session_name: String,
16+
// Flattened CreateSessionOptions fields:
17+
pub stealth: bool,
18+
pub proxy_url: Option<String>,
19+
pub timeout_ms: Option<u64>,
20+
pub headless: Option<bool>,
21+
pub region: Option<String>,
22+
pub solve_captcha: bool,
23+
pub profile_id: Option<String>,
24+
pub persist_profile: bool,
25+
pub namespace: Option<String>,
26+
pub credentials: bool,
27+
}
28+
29+
impl DaemonCreateParams {
30+
pub fn to_create_options(&self) -> CreateSessionOptions {
31+
CreateSessionOptions {
32+
stealth: self.stealth,
33+
proxy_url: self.proxy_url.clone(),
34+
timeout_ms: self.timeout_ms,
35+
headless: self.headless,
36+
region: self.region.clone(),
37+
solve_captcha: self.solve_captcha,
38+
profile_id: self.profile_id.clone(),
39+
persist_profile: self.persist_profile,
40+
namespace: self.namespace.clone(),
41+
credentials: self.credentials,
42+
}
43+
}
44+
}
45+
46+
/// Information about the daemon-managed session, returned by `GetSessionInfo`.
47+
#[derive(Debug, Clone, Serialize, Deserialize)]
48+
pub struct SessionInfo {
49+
pub session_id: String,
50+
pub session_name: String,
51+
pub mode: ApiMode,
52+
pub status: Option<String>,
53+
pub connect_url: Option<String>,
54+
pub viewer_url: Option<String>,
55+
pub profile_id: Option<String>,
56+
/// Session timeout in milliseconds (from create params). `None` = no timeout.
57+
#[serde(skip_serializing_if = "Option::is_none")]
58+
pub timeout_ms: Option<u64>,
59+
/// Epoch milliseconds when the session was created. Used with `timeout_ms`
60+
/// to compute remaining time on the client side.
61+
#[serde(skip_serializing_if = "Option::is_none")]
62+
pub created_at_ms: Option<u64>,
63+
}
64+
565
#[derive(Debug, PartialEq, Serialize, Deserialize)]
666
pub struct DaemonRequest {
767
pub id: u64,
@@ -158,6 +218,7 @@ pub enum DaemonCommand {
158218
Close,
159219
Ping,
160220
Shutdown,
221+
GetSessionInfo,
161222
}
162223

163224
#[derive(Debug, PartialEq, Serialize, Deserialize)]

0 commit comments

Comments
 (0)