#![cfg(unix)]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::sync::Notify;
use clap::Parser as _;
use omni_dev::cli::format::TableOrJson;
use omni_dev::cli::worktrees::{ListCommand, WorktreesCommand, WorktreesSubcommands};
use omni_dev::cli::Cli;
use omni_dev::daemon::client::DaemonClient;
use omni_dev::daemon::protocol::{DaemonEnvelope, DaemonReply, MAX_LINE_BYTES};
use omni_dev::daemon::registry::ServiceRegistry;
use omni_dev::daemon::server::{run, DaemonOptions};
use omni_dev::daemon::service::{DaemonService, MenuSnapshot, ServiceStatus};
use omni_dev::daemon::services::echo::EchoService;
use omni_dev::daemon::services::worktrees::WorktreesService;
use omni_dev::daemon::single_instance::{bind_or_reclaim, bind_private};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
static UMASK_GATE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[tokio::test]
async fn bind_private_creates_an_owner_only_socket() {
let _gate = UMASK_GATE.lock().await;
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("d.sock");
let listener = bind_private(&socket).unwrap();
let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "socket mode was {mode:o}, expected 600");
drop(listener);
}
async fn with_daemon<F, Fut>(body: F)
where
F: FnOnce(PathBuf) -> Fut,
Fut: std::future::Future<Output = ()>,
{
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("d.sock");
let mut registry = ServiceRegistry::new();
registry.register(Arc::new(EchoService));
let opts = DaemonOptions {
socket_path: socket.clone(),
};
let handle = tokio::spawn(run(registry, opts));
let client = DaemonClient::new(&socket);
for _ in 0..50 {
if client.ping().await.is_ok() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
body(socket.clone()).await;
client.shutdown().await.ok();
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn ping_and_status_and_routing() {
let _gate = UMASK_GATE.lock().await;
with_daemon(|socket| async move {
let client = DaemonClient::new(&socket);
client.ping().await.unwrap();
let report = client.status().await.unwrap();
assert_eq!(report.services.len(), 1);
assert_eq!(report.services[0].name, "echo");
assert!(report.services[0].healthy);
let reply = client
.request(DaemonEnvelope::service(
"echo",
"echo",
json!({ "hello": "world" }),
))
.await
.unwrap();
assert!(reply.ok);
assert_eq!(reply.payload, json!({ "hello": "world" }));
let reply = client
.request(DaemonEnvelope::service("nope", "x", Value::Null))
.await
.unwrap();
assert!(!reply.ok);
assert!(reply.error.unwrap().contains("unknown service"));
let reply = client
.request(DaemonEnvelope::builtin("frobnicate"))
.await
.unwrap();
assert!(!reply.ok);
})
.await;
}
#[tokio::test]
async fn worktrees_register_list_unregister_round_trip() {
let _gate = UMASK_GATE.lock().await;
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("d.sock");
let mut registry = ServiceRegistry::new();
registry.register(Arc::new(WorktreesService::new()));
let opts = DaemonOptions {
socket_path: socket.clone(),
};
let handle = tokio::spawn(run(registry, opts));
let client = DaemonClient::new(&socket);
for _ in 0..50 {
if client.ping().await.is_ok() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
let reply = client
.request(DaemonEnvelope::service(
"worktrees",
"register",
json!({
"key": "win-1",
"folders": ["/tmp/project"],
"repo": "project",
"title": "project — main",
"pid": 4321,
}),
))
.await
.unwrap();
assert!(reply.ok, "register failed: {:?}", reply.error);
let reply = client
.request(DaemonEnvelope::service("worktrees", "list", Value::Null))
.await
.unwrap();
assert!(reply.ok);
let windows = reply.payload["windows"].as_array().unwrap();
assert_eq!(windows.len(), 1);
assert_eq!(windows[0]["key"], json!("win-1"));
assert_eq!(windows[0]["repo"], json!("project"));
let reply = client
.request(DaemonEnvelope::service(
"worktrees",
"heartbeat",
json!({ "key": "win-1" }),
))
.await
.unwrap();
assert_eq!(reply.payload, json!({ "known": true }));
let report = client.status().await.unwrap();
assert_eq!(report.services.len(), 1);
assert_eq!(report.services[0].name, "worktrees");
assert_eq!(report.services[0].summary, "1 window(s) across 1 repo(s)");
let reply = client
.request(DaemonEnvelope::service(
"worktrees",
"unregister",
json!({ "key": "win-1" }),
))
.await
.unwrap();
assert_eq!(reply.payload, json!({ "removed": true }));
let reply = client
.request(DaemonEnvelope::service("worktrees", "list", Value::Null))
.await
.unwrap();
assert_eq!(reply.payload, json!({ "windows": [] }));
client.shutdown().await.ok();
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}
#[tokio::test]
async fn worktrees_cli_list_against_live_daemon() {
let _gate = UMASK_GATE.lock().await;
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("d.sock");
let mut registry = ServiceRegistry::new();
registry.register(Arc::new(WorktreesService::new()));
let handle = tokio::spawn(run(
registry,
DaemonOptions {
socket_path: socket.clone(),
},
));
let client = DaemonClient::new(&socket);
for _ in 0..50 {
if client.ping().await.is_ok() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
client
.request(DaemonEnvelope::service(
"worktrees",
"register",
json!({ "key": "k1", "folders": ["/tmp/p"], "repo": "p", "title": "p" }),
))
.await
.unwrap();
ListCommand {
socket: Some(socket.clone()),
output: TableOrJson::Table,
json: false,
}
.execute()
.await
.unwrap();
ListCommand {
socket: Some(socket.clone()),
output: TableOrJson::Json,
json: false,
}
.execute()
.await
.unwrap();
WorktreesCommand {
command: WorktreesSubcommands::List(ListCommand {
socket: Some(socket.clone()),
output: TableOrJson::Table,
json: false,
}),
}
.execute()
.await
.unwrap();
let sock_str = socket.to_str().unwrap();
Cli::try_parse_from([
"omni-dev",
"worktrees",
"list",
"--socket",
sock_str,
"--json",
])
.unwrap()
.execute()
.await
.unwrap();
client.shutdown().await.ok();
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
let missing = dir.path().join("absent.sock");
assert!(ListCommand {
socket: Some(missing),
output: TableOrJson::Table,
json: false,
}
.execute()
.await
.is_err());
}
#[tokio::test]
async fn second_bind_is_refused_while_first_is_live() {
let _gate = UMASK_GATE.lock().await;
with_daemon(|socket| async move {
let mut registry = ServiceRegistry::new();
registry.register(Arc::new(EchoService));
let err = bind_or_reclaim(&socket).await.unwrap_err();
assert!(err.to_string().contains("already running"));
})
.await;
}
#[tokio::test]
async fn stale_socket_is_reclaimed() {
let _gate = UMASK_GATE.lock().await;
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("d.sock");
std::fs::write(&socket, b"stale").unwrap();
let listener = bind_or_reclaim(&socket).await.unwrap();
drop(listener);
}
struct SlowService {
started: Arc<Notify>,
release: Arc<Notify>,
completed: Arc<AtomicBool>,
}
#[async_trait]
impl DaemonService for SlowService {
fn name(&self) -> &'static str {
"slow"
}
async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
match op {
"slow" => {
self.started.notify_one();
self.release.notified().await;
self.completed.store(true, Ordering::SeqCst);
Ok(payload)
}
other => anyhow::bail!("unknown slow op: {other}"),
}
}
fn menu(&self) -> MenuSnapshot {
MenuSnapshot::default()
}
async fn menu_action(&self, _action_id: &str) -> Result<()> {
Ok(())
}
async fn status(&self) -> ServiceStatus {
ServiceStatus {
name: self.name().to_string(),
healthy: true,
summary: "ready".to_string(),
detail: Value::Null,
}
}
async fn shutdown(&self) {}
}
#[tokio::test]
async fn in_flight_request_is_drained_on_shutdown() {
let _gate = UMASK_GATE.lock().await;
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("d.sock");
let started = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
let completed = Arc::new(AtomicBool::new(false));
let mut registry = ServiceRegistry::new();
registry.register(Arc::new(SlowService {
started: started.clone(),
release: release.clone(),
completed: completed.clone(),
}));
let opts = DaemonOptions {
socket_path: socket.clone(),
};
let mut server = tokio::spawn(run(registry, opts));
let client = DaemonClient::new(&socket);
for _ in 0..50 {
if client.ping().await.is_ok() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
let slow = tokio::spawn({
let socket = socket.clone();
async move {
DaemonClient::new(&socket)
.request(DaemonEnvelope::service("slow", "slow", json!({ "v": 1 })))
.await
}
});
started.notified().await;
DaemonClient::new(&socket).shutdown().await.ok();
assert!(
tokio::time::timeout(Duration::from_millis(200), &mut server)
.await
.is_err(),
"run() returned before the in-flight request was drained"
);
assert!(!completed.load(Ordering::SeqCst));
release.notify_one();
let reply = slow.await.unwrap().unwrap();
assert!(reply.ok);
assert_eq!(reply.payload, json!({ "v": 1 }));
assert!(completed.load(Ordering::SeqCst));
tokio::time::timeout(Duration::from_secs(2), server)
.await
.expect("server should stop after draining")
.expect("server task should not panic")
.expect("run() should return Ok");
}
#[tokio::test]
async fn invalid_utf8_line_yields_read_error_reply() {
let _gate = UMASK_GATE.lock().await;
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("d.sock");
let mut registry = ServiceRegistry::new();
registry.register(Arc::new(EchoService));
let opts = DaemonOptions {
socket_path: socket.clone(),
};
let server = tokio::spawn(run(registry, opts));
let client = DaemonClient::new(&socket);
for _ in 0..50 {
if client.ping().await.is_ok() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
let mut stream = UnixStream::connect(&socket).await.unwrap();
let mut line = String::new();
{
stream.write_all(b"\xff\xff\n").await.unwrap();
let mut reader = BufReader::new(&mut stream);
reader.read_line(&mut line).await.unwrap();
}
let reply: Value = serde_json::from_str(line.trim()).unwrap();
assert_eq!(reply["ok"], json!(false));
assert!(reply["error"].as_str().unwrap().contains("read error"));
drop(stream);
client.shutdown().await.ok();
let _ = tokio::time::timeout(Duration::from_secs(2), server).await;
}
#[tokio::test]
async fn over_limit_line_is_rejected_and_closes() {
let _gate = UMASK_GATE.lock().await;
with_daemon(|socket| async move {
let mut stream = UnixStream::connect(&socket).await.unwrap();
let payload = vec![b'x'; MAX_LINE_BYTES + 1];
stream.write_all(&payload).await.unwrap();
stream.flush().await.unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
let n = reader.read_line(&mut line).await.unwrap();
assert!(n > 0, "expected an error reply line");
let reply: DaemonReply = serde_json::from_str(line.trim_end()).unwrap();
assert!(!reply.ok);
assert!(reply.error.unwrap().contains("limit"));
line.clear();
let n = reader.read_line(&mut line).await.unwrap();
assert_eq!(n, 0, "daemon should close after an over-limit line");
})
.await;
}
#[tokio::test]
async fn client_hangup_before_reply_keeps_daemon_serving() {
let _gate = UMASK_GATE.lock().await;
with_daemon(|socket| async move {
{
let mut stream = UnixStream::connect(&socket).await.unwrap();
let env = serde_json::to_string(&DaemonEnvelope::builtin("ping")).unwrap();
stream.write_all(env.as_bytes()).await.unwrap();
stream.write_all(b"\n").await.unwrap();
stream.flush().await.unwrap();
}
let client = DaemonClient::new(&socket);
client.ping().await.unwrap();
})
.await;
}
struct GatedService {
entered: Arc<Notify>,
release: Arc<Notify>,
}
#[async_trait]
impl DaemonService for GatedService {
fn name(&self) -> &'static str {
"gated"
}
async fn handle(&self, _op: &str, payload: Value) -> Result<Value> {
Ok(payload)
}
fn menu(&self) -> MenuSnapshot {
MenuSnapshot::default()
}
async fn menu_action(&self, _action_id: &str) -> Result<()> {
Ok(())
}
async fn status(&self) -> ServiceStatus {
ServiceStatus {
name: self.name().to_string(),
healthy: true,
summary: "gated".to_string(),
detail: Value::Null,
}
}
async fn shutdown(&self) {
self.entered.notify_one();
self.release.notified().await;
}
}
#[tokio::test]
async fn stray_ping_fails_fast_while_draining() {
let _gate = UMASK_GATE.lock().await;
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("d.sock");
let entered = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
let mut registry = ServiceRegistry::new();
registry.register(Arc::new(GatedService {
entered: entered.clone(),
release: release.clone(),
}));
let opts = DaemonOptions {
socket_path: socket.clone(),
};
let handle = tokio::spawn(run(registry, opts));
let client = DaemonClient::new(&socket);
for _ in 0..50 {
if client.ping().await.is_ok() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
client.ping().await.unwrap();
client.shutdown().await.unwrap();
tokio::time::timeout(Duration::from_secs(2), entered.notified())
.await
.expect("drain should begin");
let probe = tokio::time::timeout(Duration::from_millis(500), client.ping()).await;
assert!(
matches!(probe, Ok(Err(_))),
"stray ping during drain should fail fast, got {probe:?}"
);
release.notify_one();
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
}