hydra

Terminal replacement for Loopback — virtual audio devices and routing on macOS, from a ratatui TUI.
Log | Files | Refs | README | LICENSE

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:
Mcrates/hydrad/src/main.rs | 31+++++++++++++++++++++++++++++--
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, + } +}