fix: recover from stale daemon/socket state (#1136)

When a daemon is killed or crashes without cleaning up, stale .sock/.pid
files are left behind. Previously, `close --all` would fail to connect to
these zombie daemons and simply report an error, leaving the stale files
in place and poisoning all future sessions.

Three fixes:

1. `close --all` now force-kills unreachable daemon processes and removes
   all stale files (pid, sock, stream) instead of reporting failure. It
   also cleans up dead-but-lingering PID files during enumeration and
   scans for orphaned .sock files without corresponding .pid files.

2. `ensure_daemon` handles concurrent startup races: when a spawned
   daemon exits with "Address already in use" (another instance won the
   bind race), it checks whether the winner is accepting connections and
   piggybacks on it instead of failing.

3. `cleanup_stale_files` is now public so `close --all` can reuse it.

Fixes #1118

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-04-04 10:54:46 -05:00
committed by GitHub
co-authored by ctate
parent c976212db4
commit 5e33672d08
2 changed files with 63 additions and 6 deletions
+16 -1
View File
@@ -119,7 +119,7 @@ fn get_pid_path(session: &str) -> PathBuf {
}
/// Clean up stale socket and PID files for a session
fn cleanup_stale_files(session: &str) {
pub fn cleanup_stale_files(session: &str) {
let pid_path = get_pid_path(session);
let _ = fs::remove_file(&pid_path);
let stream_path = get_socket_dir().join(format!("{}.stream", session));
@@ -429,6 +429,21 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result<DaemonResult
let _ = stderr.read_to_string(&mut stderr_output);
}
let stderr_trimmed = stderr_output.trim();
// If the daemon failed because another instance won the bind
// race ("Address already in use"), check whether that winner is
// now accepting connections and piggyback on it.
if stderr_trimmed.contains("Address already in use")
|| stderr_trimmed.contains("Failed to bind")
{
thread::sleep(Duration::from_millis(200));
if daemon_ready(session) {
return Ok(DaemonResult {
already_running: true,
});
}
}
if !stderr_trimmed.is_empty() {
let msg = if stderr_trimmed.len() > 500 {
let mut end = 500;
+47 -5
View File
@@ -21,7 +21,7 @@ use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
use commands::{gen_id, parse_command, ParseError};
use connection::{ensure_daemon, get_socket_dir, send_command, DaemonOptions};
use connection::{cleanup_stale_files, ensure_daemon, get_socket_dir, send_command, DaemonOptions};
use flags::{clean_args, parse_flags, Flags};
use install::run_install;
use output::{
@@ -380,7 +380,7 @@ fn run_dashboard_stop(json_mode: bool) {
fn run_close_all(flags: &Flags) {
let socket_dir = get_socket_dir();
let mut sessions: Vec<String> = Vec::new();
let mut sessions: Vec<(String, u32)> = Vec::new();
if let Ok(entries) = fs::read_dir(&socket_dir) {
for entry in entries.flatten() {
@@ -409,9 +409,33 @@ fn run_close_all(flags: &Flags) {
}
};
if running {
sessions.push(session_name.to_string());
sessions.push((session_name.to_string(), pid));
} else {
// Process is gone but stale files remain; clean them up
cleanup_stale_files(session_name);
}
}
} else {
// PID file exists but is unreadable; clean up stale files
cleanup_stale_files(session_name);
}
}
}
}
// Also scan for orphaned .sock files without corresponding .pid files
#[cfg(unix)]
if let Ok(entries) = fs::read_dir(&socket_dir) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(session_name) = name.strip_suffix(".sock") {
if session_name.is_empty() {
continue;
}
let pid_path = socket_dir.join(format!("{}.pid", session_name));
if !pid_path.exists() {
// Orphaned socket file with no PID file; remove it
cleanup_stale_files(session_name);
}
}
}
@@ -432,7 +456,7 @@ fn run_close_all(flags: &Flags) {
let mut closed: Vec<String> = Vec::new();
let mut failed: Vec<(String, String)> = Vec::new();
for session in &sessions {
for (session, pid) in &sessions {
let cmd = json!({ "id": gen_id(), "action": "close" });
match send_command(cmd, session) {
Ok(resp) if resp.success => closed.push(session.clone()),
@@ -440,7 +464,25 @@ fn run_close_all(flags: &Flags) {
let err = resp.error.unwrap_or_else(|| "Unknown error".to_string());
failed.push((session.clone(), err));
}
Err(e) => failed.push((session.clone(), e.to_string())),
Err(_) => {
// Daemon is unreachable despite its process existing.
// Force-kill the process and clean up stale files so future
// sessions are not poisoned.
#[cfg(unix)]
unsafe {
libc::kill(*pid as i32, libc::SIGKILL);
}
#[cfg(windows)]
unsafe {
let handle = OpenProcess(1, 0, *pid); // PROCESS_TERMINATE = 1
if handle != 0 {
windows_sys::Win32::System::Threading::TerminateProcess(handle, 1);
CloseHandle(handle);
}
}
cleanup_stale_files(session);
closed.push(session.clone());
}
}
}