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 explicit_url_from_env() -> Option<String> {
std::env::var("TRUSTY_MPM_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}
pub fn resolve_daemon_url(explicit: Option<&str>) -> String {
if let Some(url) = explicit
&& !url.is_empty()
{
return url.to_string();
}
if let Some(url) = read_lock_file_url() {
return url;
}
DEFAULT_DAEMON_URL.to_string()
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum DaemonUrlError {
#[error(
"daemon unreachable at explicitly configured URL {url} (probed GET {url}/health); \
refusing to fall back to a different daemon — check the address or drop --url/TRUSTY_MPM_URL \
to use automatic discovery"
)]
Unreachable {
url: String,
},
}
pub const EXIT_DAEMON_URL_UNREACHABLE: i32 = crate::core::exit_codes::EXIT_UNAVAILABLE;
pub async fn resolve_daemon_url_probing(
client: &reqwest::Client,
explicit: Option<&str>,
) -> Result<String, DaemonUrlError> {
if let Some(url) = explicit
&& !url.is_empty()
{
return if probe_url(client, url).await {
Ok(url.to_string())
} else {
Err(DaemonUrlError::Unreachable {
url: url.to_string(),
})
};
}
Ok(resolve_daemon_url(explicit))
}
pub async fn resolve_daemon_url_for_cli(
client: &reqwest::Client,
explicit: Option<&str>,
) -> Result<String, DaemonUrlError> {
if explicit.is_some_and(|url| !url.is_empty()) {
resolve_daemon_url_probing(client, explicit).await
} else {
Ok(resolve_daemon_url_via_gateway(client, explicit).await)
}
}
pub const GATEWAY_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
pub async fn resolve_daemon_url_via_gateway(
client: &reqwest::Client,
explicit: Option<&str>,
) -> String {
let console_addr = console_addr();
resolve_daemon_url_via_gateway_inner(client, explicit, &console_addr).await
}
pub fn console_addr() -> String {
console_addr_from(
trusty_common::read_daemon_addr("trusty-console")
.ok()
.flatten(),
)
}
fn console_addr_from(recorded: Option<String>) -> String {
recorded
.map(|a| a.trim().to_string())
.filter(|a| !a.is_empty())
.unwrap_or_else(|| DEFAULT_CONSOLE_ADDR.to_string())
}
pub fn console_base_url() -> String {
format!("http://{}", console_addr())
}
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()
{
return url.to_string();
}
let probe_client = reqwest::Client::builder()
.timeout(GATEWAY_PROBE_TIMEOUT)
.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.expect(
"explicit is None/empty at this call site (step (a) already returned \
otherwise), so resolve_daemon_url_probing cannot error here",
)
}
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> {
super::daemon_identity::read_lock().map(|lock| lock.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 explicit_value_equal_to_default_wins_immediately() {
let result = resolve_daemon_url(Some(DEFAULT_DAEMON_URL));
assert_eq!(
result, DEFAULT_DAEMON_URL,
"an explicit value equal to the default must win outright, not fall through to the lock file"
);
}
#[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_errors_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_eq!(
result,
Err(DaemonUrlError::Unreachable {
url: stale.to_string()
}),
"explicit unreachable URL must error, not fall back to a different daemon"
);
}
#[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,
Ok(url),
"reachable explicit URL must win over lock file / default"
);
}
#[tokio::test]
async fn probing_resolver_implicit_none_falls_back_ok() {
let client = reqwest::Client::new();
let result = resolve_daemon_url_probing(&client, None).await;
let url = result.expect("implicit resolution must never error");
assert!(url.starts_with("http"), "expected HTTP URL, got: {url}");
}
#[test]
fn exit_code_matches_sm_unavailable_convention() {
assert_eq!(EXIT_DAEMON_URL_UNREACHABLE, 75);
}
#[tokio::test]
async fn resolve_for_cli_explicit_unreachable_errors() {
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_for_cli(&client, Some(stale)).await;
assert_eq!(
result,
Err(DaemonUrlError::Unreachable {
url: stale.to_string()
})
);
}
#[tokio::test]
async fn resolve_for_cli_implicit_falls_back() {
let client = reqwest::Client::new();
let result = resolve_daemon_url_for_cli(&client, None).await;
let url = result.expect("implicit resolution must never error");
assert!(url.starts_with("http"), "expected HTTP URL, got: {url}");
}
#[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_explicit_override_equal_to_default_still_wins() {
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 hit_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let hit_count_clone = hit_count.clone();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
hit_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
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 console_addr = addr.to_string();
let result =
resolve_daemon_url_via_gateway_inner(&client, Some(DEFAULT_DAEMON_URL), &console_addr)
.await;
assert_eq!(
result, DEFAULT_DAEMON_URL,
"TRUSTY_MPM_URL equal to the default must win outright, never the gateway URL"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(
hit_count.load(std::sync::atomic::Ordering::SeqCst),
0,
"the gateway/console must never be probed when an explicit override is present"
);
}
#[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:?}"
);
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::core::trusty_tools_config::env_test_lock()
}
#[test]
fn explicit_url_from_env_reads_set_var() {
let _g = env_lock();
let prior = std::env::var("TRUSTY_MPM_URL").ok();
unsafe { std::env::set_var("TRUSTY_MPM_URL", "http://127.0.0.1:7880") };
let result = explicit_url_from_env();
match prior {
Some(v) => unsafe { std::env::set_var("TRUSTY_MPM_URL", v) },
None => unsafe { std::env::remove_var("TRUSTY_MPM_URL") },
}
assert_eq!(
result.as_deref(),
Some("http://127.0.0.1:7880"),
"must read TRUSTY_MPM_URL verbatim, even when it equals the default"
);
}
#[test]
fn explicit_url_from_env_treats_empty_as_unset() {
let _g = env_lock();
let prior = std::env::var("TRUSTY_MPM_URL").ok();
unsafe { std::env::set_var("TRUSTY_MPM_URL", " ") };
let result = explicit_url_from_env();
match prior {
Some(v) => unsafe { std::env::set_var("TRUSTY_MPM_URL", v) },
None => unsafe { std::env::remove_var("TRUSTY_MPM_URL") },
}
assert_eq!(
result, None,
"a whitespace-only TRUSTY_MPM_URL must resolve to None, same as unset"
);
}
#[test]
fn explicit_url_from_env_unset_is_none() {
let _g = env_lock();
let prior = std::env::var("TRUSTY_MPM_URL").ok();
unsafe { std::env::remove_var("TRUSTY_MPM_URL") };
let result = explicit_url_from_env();
if let Some(v) = prior {
unsafe { std::env::set_var("TRUSTY_MPM_URL", v) };
}
assert_eq!(result, None, "an unset TRUSTY_MPM_URL must resolve to None");
}
#[test]
fn console_addr_from_prefers_the_recorded_addr() {
assert_eq!(
console_addr_from(Some("127.0.0.1:9911".to_string())),
"127.0.0.1:9911"
);
assert_eq!(
console_addr_from(Some(" 127.0.0.1:9911\n".to_string())),
"127.0.0.1:9911"
);
}
#[test]
fn console_addr_from_falls_back_when_absent_or_blank() {
assert_eq!(console_addr_from(None), DEFAULT_CONSOLE_ADDR);
assert_eq!(console_addr_from(Some(String::new())), DEFAULT_CONSOLE_ADDR);
assert_eq!(
console_addr_from(Some(" \n".to_string())),
DEFAULT_CONSOLE_ADDR
);
}
#[test]
fn console_base_url_wraps_the_resolved_addr() {
let url = console_base_url();
assert!(
url.starts_with("http://"),
"console base URL must be an http:// URL: {url}"
);
assert_eq!(url, format!("http://{}", console_addr()));
}
}