Skip to main content

skiff_cli/session/
spawn.rs

1//! Spawn / stop / list session daemons.
2//!
3//! Start writes a `0o600` config JSON and execs
4//! `current_exe() __session_daemon <config-path>` in a new process group.
5//! Stop sends SIGTERM then SIGKILL after 2s.
6
7use std::collections::BTreeMap;
8use std::fs::{self, OpenOptions};
9use std::process::{Command, Stdio};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{Error, Result};
16use crate::session::paths::chmod_0600;
17use crate::session::paths::{
18    clear_stale_session, ensure_sessions_dir, load_meta, release_session_lock, session_config_path,
19    session_is_alive, session_log_path, session_sock_path, sessions_dir, try_acquire_session_lock,
20    unlink_session_files, validate_session_name,
21};
22
23pub const DEFAULT_IDLE_SECS: u64 = 1800;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct DaemonConfig {
27    pub name: String,
28    pub source: String,
29    pub is_stdio: bool,
30    pub auth_headers: Vec<(String, String)>,
31    pub env_vars: BTreeMap<String, String>,
32    pub transport: String,
33    #[serde(default)]
34    pub clean_env: bool,
35    #[serde(default = "default_idle")]
36    pub idle_secs: u64,
37}
38
39fn default_idle() -> u64 {
40    DEFAULT_IDLE_SECS
41}
42
43/// Start a session daemon. Blocks until the socket appears (≤15s).
44pub fn session_start(config: DaemonConfig) -> Result<()> {
45    #[cfg(not(unix))]
46    {
47        return Err(crate::session::sessions_unsupported());
48    }
49    #[cfg(unix)]
50    {
51        session_start_unix(config)
52    }
53}
54
55/// RAII guard that releases the per-session start lock on drop, covering both
56/// early-return error paths and the success path.
57struct SessionLockGuard<'a> {
58    name: &'a str,
59    released: bool,
60}
61
62impl<'a> SessionLockGuard<'a> {
63    fn release(mut self) {
64        release_session_lock(self.name);
65        self.released = true;
66    }
67}
68
69impl<'a> Drop for SessionLockGuard<'a> {
70    fn drop(&mut self) {
71        if !self.released {
72            release_session_lock(self.name);
73        }
74    }
75}
76
77#[cfg(unix)]
78fn session_start_unix(config: DaemonConfig) -> Result<()> {
79    validate_session_name(&config.name)?;
80    ensure_sessions_dir()?;
81
82    // Serialize the entire check-then-spawn sequence for this session name so
83    // two concurrent `session_start_unix` calls can't both pass the
84    // is-it-already-running check and race to spawn/bind duplicate daemons
85    // (the second daemon's startup would unlink the first's live socket).
86    if !try_acquire_session_lock(&config.name)? {
87        return Err(Error::runtime(format!(
88            "session {:?} is already starting (concurrent start in progress)",
89            config.name
90        )));
91    }
92    let lock_guard = SessionLockGuard {
93        name: &config.name,
94        released: false,
95    };
96
97    let _ = clear_stale_session(&config.name)?;
98
99    if let Some(meta) = load_meta(&config.name)? {
100        if session_is_alive(&meta) {
101            return Err(Error::runtime(format!(
102                "session {:?} is already running (pid {})",
103                config.name, meta.pid
104            )));
105        }
106        unlink_session_files(&config.name);
107    }
108
109    let config_path = session_config_path(&config.name);
110    let json = serde_json::to_vec_pretty(&config)?;
111    crate::fsutil::atomic_write_0600(&config_path, &json)?;
112
113    let log_path = session_log_path(&config.name);
114    // Create empty log with 0o600 before redirecting stderr.
115    crate::fsutil::atomic_write_0600(&log_path, b"")?;
116    let log_file = OpenOptions::new()
117        .create(true)
118        .append(true)
119        .open(&log_path)
120        .map_err(|e| Error::runtime(format!("cannot open session log: {e}")))?;
121    chmod_0600(&log_path)?;
122
123    let exe = std::env::current_exe()
124        .map_err(|e| Error::runtime(format!("cannot resolve skiff binary: {e}")))?;
125
126    let mut cmd = Command::new(&exe);
127    cmd.arg("__session_daemon")
128        .arg(&config_path)
129        .stdin(Stdio::null())
130        .stdout(Stdio::null())
131        .stderr(Stdio::from(log_file));
132    // New process group so CLI Ctrl-C doesn't kill the daemon.
133    use std::os::unix::process::CommandExt;
134    cmd.process_group(0);
135
136    let mut child = cmd
137        .spawn()
138        .map_err(|e| Error::runtime(format!("failed to spawn session daemon: {e}")))?;
139
140    let sock = session_sock_path(&config.name);
141    let deadline = Instant::now() + Duration::from_secs(15);
142    while Instant::now() < deadline {
143        if sock.exists() {
144            // Release the start-lock now that the daemon socket is confirmed
145            // ready; other `session_start` calls for this name may proceed.
146            lock_guard.release();
147            println!(
148                "Session '{}' started (pid {}). Use --session {} ...",
149                config.name,
150                child.id(),
151                config.name
152            );
153            return Ok(());
154        }
155        if let Ok(Some(status)) = child.try_wait() {
156            unlink_session_files(&config.name);
157            return Err(Error::runtime(format!(
158                "session daemon exited early with {status}. See {}",
159                log_path.display()
160            )));
161        }
162        thread::sleep(Duration::from_millis(50));
163    }
164    let _ = child.kill();
165    let _ = child.wait();
166    unlink_session_files(&config.name);
167    Err(Error::runtime(format!(
168        "session daemon did not become ready within 15s. See {}",
169        log_path.display()
170    )))
171}
172
173/// Stop a session daemon (SIGTERM, then SIGKILL).
174pub fn session_stop(name: &str) -> Result<()> {
175    validate_session_name(name)?;
176    let Some(meta) = load_meta(name)? else {
177        unlink_session_files(name);
178        println!("Session '{name}' is not running.");
179        return Ok(());
180    };
181    if session_is_alive(&meta) {
182        let pid = meta.pid as i32;
183        unsafe {
184            libc::kill(pid, libc::SIGTERM);
185        }
186        let deadline = Instant::now() + Duration::from_secs(2);
187        while Instant::now() < deadline {
188            if !crate::session::paths::is_process_alive(meta.pid) {
189                break;
190            }
191            thread::sleep(Duration::from_millis(50));
192        }
193        if crate::session::paths::is_process_alive(meta.pid) {
194            unsafe {
195                libc::kill(pid, libc::SIGKILL);
196            }
197            thread::sleep(Duration::from_millis(100));
198        }
199    }
200    unlink_session_files(name);
201    println!("Session '{name}' stopped.");
202    Ok(())
203}
204
205#[derive(Debug, Clone, Serialize)]
206pub struct SessionListEntry {
207    pub name: String,
208    pub pid: u32,
209    pub alive: bool,
210    pub source: String,
211    pub transport: String,
212    pub idle_secs: u64,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub idle_remaining_secs: Option<u64>,
215}
216
217/// List known sessions (from meta files).
218pub fn session_list() -> Result<Vec<SessionListEntry>> {
219    ensure_sessions_dir()?;
220    let mut out = Vec::new();
221    let dir = sessions_dir();
222    let entries = match fs::read_dir(&dir) {
223        Ok(e) => e,
224        Err(_) => return Ok(out),
225    };
226    for ent in entries.flatten() {
227        let path = ent.path();
228        if path.extension().and_then(|e| e.to_str()) != Some("json") {
229            continue;
230        }
231        // skip *.config.json
232        if path
233            .file_name()
234            .and_then(|n| n.to_str())
235            .map(|n| n.ends_with(".config.json"))
236            .unwrap_or(false)
237        {
238            continue;
239        }
240        let name = path
241            .file_stem()
242            .and_then(|s| s.to_str())
243            .unwrap_or("")
244            .to_string();
245        if name.is_empty() {
246            continue;
247        }
248        let Ok(Some(meta)) = load_meta(&name) else {
249            continue;
250        };
251        let alive = session_is_alive(&meta);
252        let idle_remaining_secs = if meta.idle_secs == 0 || !alive {
253            None
254        } else {
255            let now = std::time::SystemTime::now()
256                .duration_since(std::time::UNIX_EPOCH)
257                .map(|d| d.as_secs_f64())
258                .unwrap_or(0.0);
259            let last = if meta.last_activity_at > 0.0 {
260                meta.last_activity_at
261            } else {
262                meta.created_at
263            };
264            let elapsed = (now - last).max(0.0) as u64;
265            Some(meta.idle_secs.saturating_sub(elapsed))
266        };
267        out.push(SessionListEntry {
268            name,
269            pid: meta.pid,
270            alive,
271            source: meta.source,
272            transport: meta.transport,
273            idle_secs: meta.idle_secs,
274            idle_remaining_secs,
275        });
276    }
277    out.sort_by(|a, b| a.name.cmp(&b.name));
278    Ok(out)
279}
280
281pub fn resolve_idle_secs(cli: Option<u64>) -> u64 {
282    if let Some(v) = cli {
283        return v;
284    }
285    if let Ok(v) = std::env::var("SKIFF_SESSION_IDLE_SECS") {
286        if let Ok(n) = v.parse::<u64>() {
287            return n;
288        }
289    }
290    DEFAULT_IDLE_SECS
291}