use std::path::PathBuf;
use crate::core::paths::FRAMEWORK_DIR_NAME;
pub const DEFAULT_DAEMON_ADDR: &str = "127.0.0.1:7880";
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7880";
pub const DEFAULT_CONSOLE_ADDR: &str = "127.0.0.1:7788";
pub const GATEWAY_PATH: &str = "/api/mpm";
pub fn default_daemon_addr() -> std::net::SocketAddr {
DEFAULT_DAEMON_ADDR
.parse()
.expect("DEFAULT_DAEMON_ADDR is a valid SocketAddr literal")
}
pub fn lock_file_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(FRAMEWORK_DIR_NAME)
.join("daemon.lock")
}
pub fn resolve_daemon_url(explicit: Option<&str>) -> String {
if let Some(url) = explicit
&& !url.is_empty()
&& url != DEFAULT_DAEMON_URL
{
return url.to_string();
}
if let Some(url) = read_lock_file_url() {
return url;
}
explicit
.filter(|u| !u.is_empty())
.unwrap_or(DEFAULT_DAEMON_URL)
.to_string()
}
pub async fn resolve_daemon_url_probing(
client: &reqwest::Client,
explicit: Option<&str>,
) -> String {
if let Some(url) = explicit
&& !url.is_empty()
&& url != DEFAULT_DAEMON_URL
{
if probe_url(client, url).await {
return url.to_string();
}
if let Some(lock_url) = read_lock_file_url() {
return lock_url;
}
return DEFAULT_DAEMON_URL.to_string();
}
resolve_daemon_url(explicit)
}
pub async fn resolve_daemon_url_via_gateway(
client: &reqwest::Client,
explicit: Option<&str>,
) -> String {
let console_addr = trusty_common::read_daemon_addr("trusty-console")
.ok()
.flatten()
.unwrap_or_else(|| DEFAULT_CONSOLE_ADDR.to_string());
resolve_daemon_url_via_gateway_inner(client, explicit, &console_addr).await
}
async fn resolve_daemon_url_via_gateway_inner(
client: &reqwest::Client,
explicit: Option<&str>,
console_addr: &str,
) -> String {
if let Some(url) = explicit
&& !url.is_empty()
&& url != DEFAULT_DAEMON_URL
{
return url.to_string();
}
let probe_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(500))
.build()
.expect("gateway probe client is infallible");
let gateway_base = format!("http://{console_addr}{GATEWAY_PATH}");
if probe_url(&probe_client, &gateway_base).await {
return gateway_base;
}
resolve_daemon_url_probing(client, explicit).await
}
async fn probe_url(client: &reqwest::Client, url: &str) -> bool {
use std::time::Duration;
let base = url.trim_end_matches('/');
let probe = client
.get(format!("{base}/health"))
.timeout(Duration::from_millis(500))
.send()
.await;
probe.map(|r| r.status().is_success()).unwrap_or(false)
}
fn read_lock_file_url() -> Option<String> {
let path = lock_file_path();
let content = std::fs::read_to_string(&path).ok()?;
let mut addr: Option<String> = None;
let mut pid: Option<u32> = None;
for line in content.lines() {
if let Some(v) = line.strip_prefix("addr = ") {
addr = Some(v.trim_matches('"').to_string());
}
if let Some(v) = line.strip_prefix("pid = ") {
pid = v.trim().parse::<u32>().ok();
}
}
#[cfg(unix)]
if let Some(p) = pid {
if unsafe { libc::kill(p as libc::pid_t, 0) } != 0 {
let _ = std::fs::remove_file(&path);
return None;
}
}
addr
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_url_wins_over_everything() {
let result = resolve_daemon_url(Some("http://example.com:9999"));
assert_eq!(result, "http://example.com:9999");
}
#[test]
fn empty_explicit_falls_through() {
let result = resolve_daemon_url(Some(""));
assert!(!result.is_empty());
}
#[test]
fn default_returned_when_no_lock_and_no_explicit() {
let result = resolve_daemon_url(None);
assert!(result.starts_with("http"));
}
#[test]
fn default_url_matches_addr() {
assert_eq!(
DEFAULT_DAEMON_URL,
format!("http://{DEFAULT_DAEMON_ADDR}"),
"client default URL and daemon bind addr drifted (issue #1268)"
);
}
#[test]
fn default_addr_parses() {
let addr = default_daemon_addr();
assert_eq!(addr.to_string(), DEFAULT_DAEMON_ADDR);
assert_eq!(addr.port(), 7880);
}
#[test]
fn default_url_passed_as_explicit_falls_through_to_lock() {
let result = resolve_daemon_url(Some(DEFAULT_DAEMON_URL));
assert!(
result.starts_with("http"),
"resolve_daemon_url(DEFAULT_DAEMON_URL) must return an HTTP URL: {result}"
);
}
#[test]
fn lock_file_path_is_under_framework_root() {
let path = lock_file_path();
assert!(
path.ends_with(format!("{FRAMEWORK_DIR_NAME}/daemon.lock")),
"lock file path {path:?} is not under the {FRAMEWORK_DIR_NAME} root"
);
assert_eq!(
path.parent().and_then(|p| p.file_name()),
Some(std::ffi::OsStr::new(FRAMEWORK_DIR_NAME))
);
}
#[tokio::test]
async fn probing_resolver_falls_back_when_explicit_unreachable() {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(200))
.build()
.expect("build client");
let stale = "http://127.0.0.1:1"; let result = resolve_daemon_url_probing(&client, Some(stale)).await;
assert!(
result.starts_with("http"),
"expected HTTP URL, got: {result}"
);
assert_ne!(
result, stale,
"probing resolver must not return the unreachable stale URL"
);
}
#[tokio::test]
async fn probing_resolver_wins_when_explicit_reachable() {
use tokio::io::AsyncWriteExt as _;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind listener");
let addr = listener.local_addr().expect("local_addr");
let url = format!("http://{addr}");
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
let _ = sock
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
.await;
}
});
let client = reqwest::Client::new();
let result = resolve_daemon_url_probing(&client, Some(&url)).await;
assert_eq!(
result, url,
"reachable explicit URL must win over lock file / default"
);
}
#[tokio::test]
async fn resolve_via_gateway_explicit_override_wins() {
let client = reqwest::Client::new();
let explicit = "http://127.0.0.1:9999";
let result =
resolve_daemon_url_via_gateway_inner(&client, Some(explicit), "127.0.0.1:1").await;
assert_eq!(
result, explicit,
"explicit override must win over gateway probe"
);
}
#[tokio::test]
async fn resolve_via_gateway_uses_gateway_when_probe_succeeds() {
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind listener");
let addr = listener.local_addr().expect("local_addr");
let (path_tx, path_rx) = tokio::sync::oneshot::channel::<String>();
tokio::spawn(async move {
if let Ok((sock, _)) = listener.accept().await {
let (reader, mut writer) = sock.into_split();
let mut lines = BufReader::new(reader).lines();
let req_line = lines.next_line().await.unwrap().unwrap_or_default();
let _ = path_tx.send(req_line);
let _ = writer
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
.await;
}
});
let client = reqwest::Client::new();
let console_addr = addr.to_string();
let result = resolve_daemon_url_via_gateway_inner(&client, None, &console_addr).await;
let expected_gateway = format!("http://{console_addr}{GATEWAY_PATH}");
assert_eq!(
result, expected_gateway,
"gateway URL must be returned when console probe succeeds"
);
let req_line = path_rx.await.expect("request line must be captured");
assert!(
req_line.starts_with("GET /api/mpm/health "),
"gateway probe must target /api/mpm/health; got: {req_line:?}"
);
}
#[tokio::test]
async fn resolve_via_gateway_falls_back_to_direct_on_probe_failure() {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(200))
.build()
.expect("build client");
let dead_console = "127.0.0.1:1";
let result = resolve_daemon_url_via_gateway_inner(&client, None, dead_console).await;
let gateway_url = format!("http://{dead_console}{GATEWAY_PATH}");
assert!(
result.starts_with("http"),
"fallback must still return an HTTP URL, got: {result}"
);
assert_ne!(
result, gateway_url,
"must not return the unreachable gateway URL on probe failure"
);
}
#[tokio::test]
async fn probe_url_trims_trailing_slash() {
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind listener");
let addr = listener.local_addr().expect("local_addr");
let (path_tx, path_rx) = tokio::sync::oneshot::channel::<String>();
tokio::spawn(async move {
if let Ok((sock, _)) = listener.accept().await {
let (reader, mut writer) = sock.into_split();
let mut lines = BufReader::new(reader).lines();
let req_line = lines.next_line().await.unwrap().unwrap_or_default();
let _ = path_tx.send(req_line);
let _ = writer
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
.await;
}
});
let client = reqwest::Client::new();
let url_with_slash = format!("http://{addr}/");
probe_url(&client, &url_with_slash).await;
let req_line = path_rx.await.expect("request line captured");
assert!(
req_line.starts_with("GET /health "),
"probe must request /health (no double slash); got: {req_line:?}"
);
}
}