use std::{
io,
path::PathBuf,
process::Child,
time::{Duration, Instant},
};
use fs2::FileExt as _;
use tracing::{debug, info, warn};
use crate::{
config::DaemonConfig,
error::{DaemonError, DaemonResult},
lifecycle::pidfile::read_pid,
};
pub async fn start_detached(cfg: &DaemonConfig) -> DaemonResult<u32> {
let socket_path = cfg.socket_path();
let timeout = Duration::from_secs(cfg.auto_start_ready_timeout_secs);
let deadline = Instant::now() + timeout;
let bootstrap_lock_path = bootstrap_lock_path(cfg);
debug!(
path = %bootstrap_lock_path.display(),
"acquiring bootstrap lock for auto-spawn"
);
let bootstrap_file = open_bootstrap_lock(&bootstrap_lock_path)?;
let bootstrap_file = {
let lock_path_clone = bootstrap_lock_path.clone();
tokio::task::spawn_blocking(move || {
lock_bootstrap(&bootstrap_file, &lock_path_clone).map(|()| bootstrap_file)
})
.await
.map_err(|join_err| {
DaemonError::Io(io::Error::other(format!(
"bootstrap lock task panicked: {join_err}"
)))
})??
};
debug!(path = %bootstrap_lock_path.display(), "bootstrap lock acquired");
let _bootstrap_guard = BootstrapLockGuard {
file: bootstrap_file,
path: bootstrap_lock_path.clone(),
};
if try_connect(&socket_path).await {
let pid = read_pid(&cfg.pid_path()).unwrap_or(0);
info!(pid, socket = %socket_path.display(), "daemon already running — fast path");
return Ok(pid);
}
let (grandchild_pid, mut grandchild) = spawn_daemon_grandchild(cfg)?;
info!(
pid = grandchild_pid,
socket = %socket_path.display(),
timeout_secs = cfg.auto_start_ready_timeout_secs,
"spawned detached sqryd; polling socket for readiness"
);
loop {
if try_connect(&socket_path).await {
info!(
pid = grandchild_pid,
socket = %socket_path.display(),
"daemon socket connectable — auto-spawn complete"
);
drop(grandchild);
return Ok(grandchild_pid);
}
if Instant::now() >= deadline {
warn!(
socket = %socket_path.display(),
timeout_secs = cfg.auto_start_ready_timeout_secs,
"daemon did not become ready within timeout"
);
if let Err(e) = grandchild.kill() {
warn!(
pid = grandchild_pid,
err = %e,
"failed to kill timed-out grandchild (may have exited already)"
);
} else {
debug!(pid = grandchild_pid, "sent SIGKILL to timed-out grandchild");
}
let _ = grandchild.wait();
return Err(DaemonError::AutoStartTimeout {
timeout_secs: cfg.auto_start_ready_timeout_secs,
socket: socket_path,
});
}
tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
}
}
const POLL_INTERVAL_MS: u64 = 50;
#[must_use]
pub fn bootstrap_lock_path(cfg: &DaemonConfig) -> PathBuf {
let lock = cfg.lock_path();
lock.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("sqryd.bootstrap.lock")
}
fn open_bootstrap_lock(path: &std::path::Path) -> DaemonResult<std::fs::File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let perms = std::fs::Permissions::from_mode(0o700);
std::fs::set_permissions(parent, perms)?;
}
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(path)?;
Ok(f)
}
#[cfg(not(unix))]
{
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?;
Ok(f)
}
}
fn lock_bootstrap(file: &std::fs::File, path: &std::path::Path) -> DaemonResult<()> {
debug!(path = %path.display(), "blocking on bootstrap flock");
file.lock_exclusive().map_err(|e| {
warn!(path = %path.display(), err = %e, "bootstrap flock failed");
DaemonError::Io(e)
})?;
Ok(())
}
pub async fn try_connect_path(socket_path: &std::path::Path) -> bool {
try_connect(socket_path).await
}
async fn try_connect(socket_path: &std::path::Path) -> bool {
#[cfg(unix)]
{
use tokio::net::UnixStream;
match UnixStream::connect(socket_path).await {
Ok(_stream) => {
debug!(socket = %socket_path.display(), "socket connect succeeded");
true
}
Err(_) => false,
}
}
#[cfg(windows)]
{
use tokio::net::windows::named_pipe::ClientOptions;
let pipe_path = socket_path.to_string_lossy();
match ClientOptions::new().open(pipe_path.as_ref()) {
Ok(_) => {
debug!(pipe = %pipe_path, "named pipe connect succeeded");
true
}
Err(_) => false,
}
}
}
fn spawn_daemon_grandchild(cfg: &DaemonConfig) -> DaemonResult<(u32, Child)> {
let exe = std::env::current_exe().map_err(|e| {
warn!(err = %e, "current_exe() failed");
DaemonError::Io(e)
})?;
debug!(exe = %exe.display(), "spawning detached sqryd grandchild");
let mut cmd = std::process::Command::new(&exe);
cmd.args(["start", "--detach", "--spawned-by-client"]);
if let Some(path) = &cfg.socket.path {
cmd.env(crate::config::ENV_SOCKET_PATH, path);
}
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
let child = cmd.spawn().map_err(|e| {
warn!(exe = %exe.display(), err = %e, "failed to spawn sqryd grandchild");
DaemonError::Io(e)
})?;
let pid = child.id();
debug!(pid, exe = %exe.display(), "sqryd grandchild spawned");
Ok((pid, child))
}
struct BootstrapLockGuard {
file: std::fs::File,
path: PathBuf,
}
impl Drop for BootstrapLockGuard {
fn drop(&mut self) {
match self.file.unlock() {
Ok(()) => {
debug!(path = %self.path.display(), "bootstrap lock released");
}
Err(e) => {
warn!(
path = %self.path.display(),
err = %e,
"failed to explicitly unlock bootstrap lock (kernel will clean up)"
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tempfile::TempDir;
use crate::TEST_ENV_LOCK as ENV_LOCK;
struct TestCfg {
_tmp: TempDir,
cfg: DaemonConfig,
prior_xdg: Option<String>,
_guard: std::sync::MutexGuard<'static, ()>,
}
impl TestCfg {
fn new() -> Self {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().expect("TempDir::new");
let prior_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
#[allow(unsafe_code)]
unsafe {
std::env::set_var("XDG_RUNTIME_DIR", tmp.path());
}
let mut cfg = DaemonConfig::default();
cfg.socket.path = Some(tmp.path().join("sqry").join("sqryd.sock"));
cfg.auto_start_ready_timeout_secs = 2;
Self {
_tmp: tmp,
cfg,
prior_xdg,
_guard: guard,
}
}
fn cfg(&self) -> &DaemonConfig {
&self.cfg
}
}
impl Drop for TestCfg {
fn drop(&mut self) {
#[allow(unsafe_code)]
unsafe {
match self.prior_xdg.take() {
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
None => std::env::remove_var("XDG_RUNTIME_DIR"),
}
}
}
}
#[test]
fn bootstrap_lock_path_is_sibling_of_lock_path() {
let fix = TestCfg::new();
let lock_path = fix.cfg().lock_path();
let bootstrap = bootstrap_lock_path(fix.cfg());
assert_eq!(
lock_path.parent(),
bootstrap.parent(),
"bootstrap lock must be in the same directory as the main lock"
);
assert_eq!(
bootstrap.file_name().and_then(|n| n.to_str()),
Some("sqryd.bootstrap.lock"),
"bootstrap lock filename must be 'sqryd.bootstrap.lock'"
);
}
#[cfg(unix)]
#[test]
fn open_bootstrap_lock_creates_with_0600() {
use std::os::unix::fs::MetadataExt as _;
let fix = TestCfg::new();
let path = bootstrap_lock_path(fix.cfg());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let _file = open_bootstrap_lock(&path).expect("open_bootstrap_lock");
let mode = std::fs::metadata(&path).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600, "bootstrap lock must be 0600");
}
#[test]
fn bootstrap_lock_guard_releases_on_drop() {
let fix = TestCfg::new();
let path = bootstrap_lock_path(fix.cfg());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = open_bootstrap_lock(&path).expect("open");
lock_bootstrap(&file, &path).expect("lock");
let guard = BootstrapLockGuard {
file,
path: path.clone(),
};
{
let f2 = open_bootstrap_lock(&path).expect("open 2");
let result = fs2::FileExt::try_lock_exclusive(&f2);
assert!(
result.is_err(),
"lock must be held while BootstrapLockGuard is alive"
);
}
drop(guard);
let f3 = open_bootstrap_lock(&path).expect("open 3");
fs2::FileExt::try_lock_exclusive(&f3)
.expect("lock must succeed after BootstrapLockGuard is dropped");
}
#[tokio::test]
async fn start_detached_respects_timeout_when_socket_never_appears() {
let fix = TestCfg::new();
let mut cfg = fix.cfg().clone();
cfg.auto_start_ready_timeout_secs = 1;
let result = start_detached(&cfg).await;
match result {
Err(DaemonError::AutoStartTimeout { timeout_secs, .. }) => {
assert_eq!(timeout_secs, 1, "timeout_secs in error must match config");
}
Err(DaemonError::Io(_)) => {
}
Ok(pid) => panic!("expected AutoStartTimeout or Io, got Ok({pid})"),
Err(other) => panic!("unexpected error variant: {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn start_detached_bootstrap_lock_serialises_concurrent_callers() {
let fix = TestCfg::new();
let mut cfg = fix.cfg().clone();
cfg.auto_start_ready_timeout_secs = 1;
let cfg = Arc::new(cfg);
let lock_path = bootstrap_lock_path(&cfg);
std::fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
let observed_contention = Arc::new(std::sync::atomic::AtomicBool::new(false));
let probe_path = lock_path.clone();
let contention_flag = Arc::clone(&observed_contention);
let probe_handle = tokio::spawn(async move {
for _ in 0..3000 {
if let Ok(f) = open_bootstrap_lock(&probe_path)
&& fs2::FileExt::try_lock_exclusive(&f).is_err()
{
contention_flag.store(true, std::sync::atomic::Ordering::SeqCst);
break; }
tokio::time::sleep(Duration::from_millis(5)).await;
}
});
let mut handles = Vec::new();
for _ in 0..5 {
let cfg_clone = Arc::clone(&cfg);
handles.push(tokio::spawn(async move {
let _ = start_detached(&cfg_clone).await;
}));
}
for h in handles {
h.await.expect("task panicked");
}
probe_handle.abort();
assert!(
lock_path.exists(),
"bootstrap lock file must be created by start_detached callers"
);
assert!(
observed_contention.load(std::sync::atomic::Ordering::SeqCst),
"probe must have observed WouldBlock on the bootstrap lock at least once, \
proving exclusive lock ownership (M2 single-spawner guarantee)"
);
}
#[cfg(unix)]
#[tokio::test]
async fn try_connect_returns_false_for_nonexistent_socket() {
let tmp = TempDir::new().unwrap();
let socket_path = tmp.path().join("nonexistent.sock");
let result = try_connect(&socket_path).await;
assert!(!result, "try_connect must return false for missing socket");
}
#[cfg(unix)]
#[tokio::test]
async fn try_connect_returns_true_for_listening_socket() {
let tmp = TempDir::new().unwrap();
let socket_path = tmp.path().join("test.sock");
let listener = tokio::net::UnixListener::bind(&socket_path).expect("UnixListener::bind");
tokio::spawn(async move {
let _ = listener.accept().await;
});
let result = try_connect(&socket_path).await;
assert!(
result,
"try_connect must return true for a listening socket"
);
}
#[cfg(unix)]
#[tokio::test]
async fn start_detached_fast_path_returns_pid_not_sentinel() {
use crate::lifecycle::pidfile::acquire_pidfile_lock;
let fix = TestCfg::new();
let socket_path = fix.cfg().socket.path.clone().unwrap();
std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap();
let listener = tokio::net::UnixListener::bind(&socket_path).expect("UnixListener::bind");
tokio::spawn(async move {
loop {
if listener.accept().await.is_err() {
break;
}
}
});
let _lock = acquire_pidfile_lock(fix.cfg()).expect("acquire_pidfile_lock");
let expected_pid = std::process::id();
let result = start_detached(fix.cfg()).await;
match result {
Ok(pid) => {
assert_eq!(
pid, expected_pid,
"fast path must return pidfile PID, not a sentinel"
);
}
Err(e) => panic!("expected Ok(pid), got {e:?}"),
}
}
}