commit 8a303ac6a762be9cd7e33b8e17bb161945ef6031
parent 05eef3c033b90cf482b966a39dfc89d624796a38
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Sun, 31 May 2026 23:25:17 -0500
fix: single-instance daemon guard (no more competing-daemon pileup)
Startup unconditionally removed the socket then bound it, so a second hydrad
would yank the socket from the first and both would run — that's how 13
daemons accumulated over a session, causing socket contention and flaky reads.
Now startup probes the existing socket with a 500ms Ping: if a live daemon
answers, log "already running" and exit 0 (don't disturb it); only if nothing
answers (stale socket from a crash) is it removed and rebound. Verified:
daemon #2 refuses with "hydrad already running (protocol v1); exiting",
daemon #1 keeps serving, exactly 1 process remains. Stale-socket recovery
still works (crashed daemon doesn't block restart).
19 tests green, 0 warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
1 file changed, 29 insertions(+), 2 deletions(-)
diff --git a/crates/hydrad/src/main.rs b/crates/hydrad/src/main.rs
@@ -6,20 +6,30 @@ mod server;
use std::error::Error;
use std::fs;
+use std::io::{BufReader, BufWriter};
use std::os::unix::fs::PermissionsExt;
-use std::os::unix::net::UnixListener;
+use std::os::unix::net::{UnixListener, UnixStream};
use std::sync::{Arc, Mutex};
use hydra_core::config::Config;
use hydra_core::engine::Engine;
use hydra_core::ffi::process;
+use hydra_ipc::{read_msg, write_msg, Command, Response};
fn main() -> Result<(), Box<dyn Error>> {
let sock = hydra_ipc::socket_path();
if let Some(dir) = sock.parent() {
fs::create_dir_all(dir)?;
}
- // A stale socket from a previous run would make bind() fail with EADDRINUSE.
+
+ // Single-instance guard. The socket file existing isn't enough to tell a live daemon
+ // from a stale socket left by a crash, so probe it: if something answers Ping, another
+ // daemon owns CoreAudio — bail rather than yank the socket out from under it (which is
+ // how a pile of competing daemons accumulated before). If nothing answers, it's stale.
+ if let Some(version) = ping_existing(&sock) {
+ eprintln!("hydrad already running (protocol v{version}) on {}; exiting.", sock.display());
+ return Ok(());
+ }
let _ = fs::remove_file(&sock);
let listener = UnixListener::bind(&sock)?;
@@ -57,3 +67,20 @@ fn main() -> Result<(), Box<dyn Error>> {
Ok(())
}
+
+/// Probe an existing socket: returns the protocol version if a live daemon answers `Ping`,
+/// or `None` if nothing's there / it's a stale socket. A short timeout keeps a wedged peer
+/// from hanging startup.
+fn ping_existing(sock: &std::path::Path) -> Option<u32> {
+ use std::time::Duration;
+ let stream = UnixStream::connect(sock).ok()?;
+ stream.set_read_timeout(Some(Duration::from_millis(500))).ok()?;
+ stream.set_write_timeout(Some(Duration::from_millis(500))).ok()?;
+ let mut writer = BufWriter::new(stream.try_clone().ok()?);
+ let mut reader = BufReader::new(stream);
+ write_msg(&mut writer, &Command::Ping).ok()?;
+ match read_msg::<_, Response>(&mut reader).ok()? {
+ Some(Response::Pong { version }) => Some(version),
+ _ => None,
+ }
+}