use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::BufRead;
use std::os::unix::net::UnixStream;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Condvar, Mutex, OnceLock};
use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Context, Result};
#[cfg(unix)]
use libc;
use rand::RngCore;
use serde_json;
use super::{DaemonHandle, DaemonHost, InstanceProfile};
use crate::commands::harness::ports;
use crate::commands::harness::state::HarnessState;
use crate::tui::{self, LogSource, LogTx, ServiceStatus, TuiEvent};
fn fresh_hex_secret(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
hex::encode(buf)
}
pub(crate) fn materialize_base_env_from_example(base_env_path: &Path) -> Result<()> {
let example_path = PathBuf::from(format!("{}.example", base_env_path.display()));
if !example_path.exists() {
bail!(
"no template at {} to generate {} from — this should ship in the repo \
(system/server/{{alice,bob}}.env.example); if it's missing, something is wrong \
with this checkout.",
example_path.display(),
base_env_path.display()
);
}
let template = fs::read_to_string(&example_path)
.with_context(|| format!("read {}", example_path.display()))?;
let materialized = template
.replace("__GENERATE_FRESH_JWT_SECRET__", &fresh_hex_secret(32))
.replace(
"__GENERATE_FRESH_REFRESH_TOKEN_SECRET__",
&fresh_hex_secret(32),
)
.replace(
"__GENERATE_FRESH_LIBP2P_IDENTITY_SEED__",
&fresh_hex_secret(32),
);
fs::write(base_env_path, materialized)
.with_context(|| format!("write generated base env {}", base_env_path.display()))?;
eprintln!(
"harness: generated {} from {} with a fresh JWT secret, refresh-token secret, and \
libp2p identity seed for this worktree (gap 0c/0e) — edit it directly if you need \
non-default dev settings; delete it to regenerate.",
base_env_path.display(),
example_path.display()
);
Ok(())
}
const HEALTH_TIMEOUT: Duration = Duration::from_secs(240);
const HEALTH_POLL: Duration = Duration::from_secs(2);
const HTTP_READY_TIMEOUT: Duration = Duration::from_secs(60);
const VITE_READY_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(unix)]
fn pids_listening_on(port: u16) -> Vec<i32> {
let arg = format!("-iTCP:{port}");
Command::new("lsof")
.args(["-sTCP:LISTEN", "-t", "-P", "-n", &arg])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.filter_map(|l| l.trim().parse::<i32>().ok())
.collect()
})
.unwrap_or_default()
}
#[cfg(not(unix))]
fn pids_listening_on(_port: u16) -> Vec<i32> {
Vec::new()
}
fn tee_daemon_stream<R: std::io::Read + Send + 'static>(
reader: R,
prefix: String,
log_file: Option<std::sync::Arc<std::sync::Mutex<std::fs::File>>>,
tui_tx: Option<LogTx>,
is_stderr: bool,
) {
use std::io::Write as _;
std::thread::spawn(move || {
for line in std::io::BufReader::new(reader).lines().map_while(Result::ok) {
let formatted = format!("{prefix}{line}");
if let Some(file) = &log_file {
if let Ok(mut f) = file.lock() {
let _ = writeln!(f, "{formatted}");
}
}
if let Some(tx) = &tui_tx {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Daemon,
line: formatted,
}));
} else if is_stderr {
eprintln!("{formatted}");
} else {
println!("{formatted}");
}
}
});
}
pub(crate) const CLIENT_NODE_PORT_OFFSET: u16 = 300;
pub struct MonorepoHost {
monorepo_path: PathBuf,
profile: InstanceProfile,
socket_override: Option<PathBuf>,
dev_dir_override: Option<PathBuf>,
log_tx: Option<LogTx>,
client_node: bool,
child: Mutex<Option<Child>>,
ui_child: Mutex<Option<Child>>,
env_file: Mutex<Option<PathBuf>>,
}
impl MonorepoHost {
pub fn new(
monorepo_path: PathBuf,
profile: InstanceProfile,
socket_override: Option<PathBuf>,
dev_dir_override: Option<PathBuf>,
log_tx: Option<LogTx>,
client_node: bool,
port_override: Option<ports::PortSet>,
) -> Self {
let profile = if client_node {
let ports = port_override.unwrap_or_else(|| {
ports::PortSet::base_for(
&profile.name,
profile.http_port,
profile.https_port,
profile.p2p_port,
profile.ui_port,
)
});
InstanceProfile {
name: lane_instance_name(&profile.name, true),
http_port: ports.http,
https_port: ports.https,
p2p_port: ports.p2p,
ui_port: ports.ui,
env_file_name: profile.env_file_name,
}
} else {
profile
};
Self {
monorepo_path,
profile,
socket_override,
dev_dir_override,
log_tx,
client_node,
child: Mutex::new(None),
ui_child: Mutex::new(None),
env_file: Mutex::new(None),
}
}
fn syslog(&self, msg: impl Into<String>) {
tui::sys_log(self.log_tx.as_ref(), msg);
}
fn spawn_daemon(&self, env_file: &Path) -> Result<Child> {
let binary_path = daemon_binary_path(&self.monorepo_path);
if !binary_path.exists() {
bail!(
"daemon binary not found at {} ({}). If a `cargo build -p node-server` ran \
above and failed, its error is the real cause — scroll up. If it reported \
success, the binary was removed or moved after that build finished; rerun \
`cargo build -p node-server --features agentic_payments` in {} (with the \
same CARGO_TARGET_DIR, if you have one set) and confirm the binary lands at \
the path above.",
binary_path.display(),
target_dir_diagnostic(&self.monorepo_path),
self.monorepo_path.display()
);
}
let mut cmd = Command::new(&binary_path);
cmd.args(["--env"])
.arg(env_file)
.current_dir(&self.monorepo_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
cmd.process_group(0);
let mut child = cmd
.spawn()
.with_context(|| format!("spawn {}", binary_path.display()))?;
let log_file_path = env_file
.parent()
.map(|d| d.join("daemon.log"))
.unwrap_or_else(|| PathBuf::from("daemon.log"));
let log_file: Option<std::sync::Arc<std::sync::Mutex<std::fs::File>>> =
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_file_path)
.ok()
.map(|f| std::sync::Arc::new(std::sync::Mutex::new(f)));
if log_file.is_some() {
self.syslog(format!("→ daemon log: {}", log_file_path.display()));
} else {
self.syslog(format!(
"⚠ could not open daemon log file at {} — output will only show in TUI/terminal",
log_file_path.display()
));
}
let prefix = format!("[{}] ", self.profile.name);
if let Some(stdout) = child.stdout.take() {
tee_daemon_stream(
stdout,
prefix.clone(),
log_file.clone(),
self.log_tx.clone(),
false,
);
}
if let Some(stderr) = child.stderr.take() {
tee_daemon_stream(stderr, prefix, log_file, self.log_tx.clone(), true);
}
Ok(child)
}
fn spawn_ui(&self) -> Option<Child> {
if self.client_node {
return None;
}
let ui_dir = self.monorepo_path.join("system/ui");
if !ui_dir.join("package.json").exists() {
return None;
}
let yarn = if which_bin("yarn") { "yarn" } else { "npm" };
let ui_port = self.profile.ui_port;
let api_port = self.profile.http_port;
self.syslog(format!(
"→ starting UI dev server ({yarn} dev --port {ui_port}) in {}",
ui_dir.display()
));
let mut cmd = Command::new(yarn);
cmd.args(["dev", "--port", &ui_port.to_string()])
.env("VITE_BACKEND_PORT", api_port.to_string())
.current_dir(&ui_dir)
.stdin(Stdio::null());
if self.log_tx.is_some() {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
}
#[cfg(unix)]
cmd.process_group(0);
match cmd.spawn() {
Ok(mut child) => {
if let Some(tx) = &self.log_tx {
let prefix = format!("[{}] ", self.profile.name);
if let Some(stdout) = child.stdout.take() {
let tx = tx.clone();
let p = prefix.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::UiServer,
line: format!("{p}{line}"),
}));
}
});
}
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
let p = prefix.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::UiServer,
line: format!("{p}{line}"),
}));
}
});
}
}
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Ready,
Some(format!("[{}] http://localhost:{ui_port}", self.profile.name)),
);
self.syslog(format!(
"✓ UI available at http://localhost:{ui_port} (Vite, hot reload) \
or http://localhost:{api_port} (backend-served, requires `yarn build` in system/ui)"
));
Some(child)
}
Err(e) => {
self.syslog(format!(
"⚠ could not start UI dev server: {e} — run manually: \
cd system/ui && VITE_BACKEND_PORT={api_port} {yarn} dev --port {ui_port}"
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Disabled,
Some(format!("[{}]", self.profile.name)),
);
None
}
}
}
fn shutdown_daemon_only(&self) {
if let Some(mut child) = self.child.lock().unwrap().take() {
let pid = child.id() as i32;
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
let t = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if t.elapsed() > Duration::from_secs(10) => {
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
let _ = child.wait();
break;
}
Ok(None) => std::thread::sleep(Duration::from_millis(200)),
Err(_) => break,
}
}
}
}
}
impl MonorepoHost {
#[cfg(unix)]
fn free_port(&self, port: u16, label: &str) -> Result<()> {
let pids = pids_listening_on(port);
if pids.is_empty() {
return Ok(());
}
let expected_env_path = monorepo_env_dir(&self.monorepo_path, &self.profile.name)
.ok()
.map(|dir| dir.join("daemon.env"));
let state = HarnessState::load_for(&self.monorepo_path).ok();
for pid in &pids {
let holder = ports::describe_pid(*pid as u32);
if let ports::PortOwnership::Foreign(h) =
ports::classify_port_holder(holder, expected_env_path.as_deref(), state.as_ref())
{
bail!(
"refusing to free {label} port {port}: it is held by pid {} (cwd {}, \
env {}), which this invocation did not start and cannot attribute to \
itself. If you own that process and it's a stale harness node, stop it \
with `node-app harness down` (or its own supervisor) first, then re-run. \
This command will never kill a process it cannot attribute to itself.",
h.pid,
h.cwd.display(),
h.env_path
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unknown>".into()),
);
}
}
self.syslog(format!(
"→ freeing {label} port {port} from stale pid(s) {}…",
pids.iter()
.map(i32::to_string)
.collect::<Vec<_>>()
.join(",")
));
for pid in &pids {
unsafe {
libc::kill(*pid, libc::SIGTERM);
}
}
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if pids_listening_on(port).is_empty() {
self.syslog(format!("✓ {label} port {port} freed"));
return Ok(());
}
std::thread::sleep(Duration::from_millis(200));
}
let remaining = pids_listening_on(port);
if !remaining.is_empty() {
self.syslog(format!(
"⚠ {label} port {port} still held after SIGTERM — escalating to SIGKILL"
));
for pid in &remaining {
unsafe {
libc::kill(*pid, libc::SIGKILL);
}
}
}
Ok(())
}
#[cfg(not(unix))]
fn free_port(&self, _port: u16, _label: &str) -> Result<()> {
Ok(())
}
fn force_dev_http_only(&self, db_path: &Path) {
if !db_path.exists() {
return;
}
let conn = match rusqlite::Connection::open(db_path) {
Ok(c) => c,
Err(e) => {
self.syslog(format!("⚠ could not open dev DB to pin HTTP: {e}"));
return;
}
};
let has_settings = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'",
[],
|_| Ok(()),
)
.is_ok();
if !has_settings {
return;
}
match conn.execute(
"UPDATE settings SET value='false' WHERE key='ssl_enabled' AND value!='false'",
[],
) {
Ok(n) if n > 0 => self
.syslog("✓ pinned dev daemon to HTTP (ssl_enabled=false in dev DB)".to_string()),
Ok(_) => {}
Err(e) => self.syslog(format!("⚠ could not pin ssl_enabled=false: {e}")),
}
}
fn wait_http_ready(&self, port: u16) -> Result<()> {
let url = format!("http://127.0.0.1:{port}/api/healthz");
let started = Instant::now();
self.syslog(format!("→ waiting for daemon HTTP on {url}…"));
loop {
let ready = match ureq::get(&url)
.timeout(Duration::from_secs(2))
.call()
{
Ok(_) => true,
Err(ureq::Error::Status(code, _)) => code < 500,
Err(_) => false,
};
if ready {
self.syslog(format!(
"✓ daemon HTTP ready ({}ms)",
started.elapsed().as_millis()
));
return Ok(());
}
if let Some(child) = self.child.lock().unwrap().as_mut() {
if let Ok(Some(status)) = child.try_wait() {
bail!(
"daemon exited with {status} while waiting for HTTP readiness on {url}"
);
}
}
if started.elapsed() >= HTTP_READY_TIMEOUT {
bail!(
"daemon HTTP server not responding on {} after {}s",
url,
HTTP_READY_TIMEOUT.as_secs()
);
}
std::thread::sleep(Duration::from_millis(300));
}
}
fn wait_tcp_ready(&self, port: u16, label: &str) -> Result<()> {
let addr = format!("127.0.0.1:{port}");
let started = Instant::now();
self.syslog(format!("→ waiting for {label} on {addr}…"));
loop {
if std::net::TcpStream::connect_timeout(
&addr.parse().expect("127.0.0.1:<port> always parses"),
Duration::from_secs(2),
)
.is_ok()
{
self.syslog(format!(
"✓ {label} ready ({}ms)",
started.elapsed().as_millis()
));
return Ok(());
}
if let Some(child) = self.child.lock().unwrap().as_mut() {
if let Ok(Some(status)) = child.try_wait() {
bail!("daemon exited with {status} while waiting for {label} on {addr}");
}
}
if started.elapsed() >= HTTP_READY_TIMEOUT {
bail!(
"{label} not accepting connections on {} after {}s",
addr,
HTTP_READY_TIMEOUT.as_secs()
);
}
std::thread::sleep(Duration::from_millis(300));
}
}
fn verify_ws_proxy(&self, ui_port: u16) -> Result<()> {
use std::io::{Read, Write};
use std::net::TcpStream;
let addr_candidates: Vec<std::net::SocketAddr> = std::net::ToSocketAddrs::to_socket_addrs(
&format!("localhost:{ui_port}"),
)
.map(|iter| iter.collect())
.unwrap_or_default();
if addr_candidates.is_empty() {
bail!("could not resolve localhost:{ui_port} for WS probe");
}
let req = format!(
"GET /api/ws HTTP/1.1\r\n\
Host: localhost:{ui_port}\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
Sec-WebSocket-Version: 13\r\n\
\r\n"
);
let mut last_err = String::from("no candidate addresses tried");
for addr in &addr_candidates {
match TcpStream::connect_timeout(addr, Duration::from_secs(2)) {
Ok(mut sock) => {
let _ = sock.set_read_timeout(Some(Duration::from_secs(2)));
let _ = sock.write_all(req.as_bytes());
let mut buf = [0u8; 256];
match sock.read(&mut buf) {
Ok(n) if n > 0 => {
let response = String::from_utf8_lossy(&buf[..n]);
let first_line =
response.lines().next().unwrap_or("").to_string();
if first_line.contains("101") {
self.syslog(format!(
"✓ ws proxy verified: localhost:{ui_port}/api/ws → backend ({first_line})"
));
return Ok(());
}
last_err = format!(
"{addr} replied {first_line} (expected 101 Switching Protocols)"
);
}
Ok(_) => last_err = format!("{addr} closed without responding"),
Err(e) => last_err = format!("{addr} read: {e}"),
}
}
Err(e) => last_err = format!("{addr} connect: {e}"),
}
}
bail!("ws proxy probe failed: {last_err}");
}
fn verify_proxy(&self, ui_port: u16, http_port: u16) -> Result<()> {
let urls = [
format!("http://localhost:{ui_port}/api/healthz"),
format!("http://127.0.0.1:{ui_port}/api/healthz"),
format!("http://[::1]:{ui_port}/api/healthz"),
];
let started = Instant::now();
let mut last_err = String::new();
loop {
for url in &urls {
match ureq::get(url).timeout(Duration::from_secs(2)).call() {
Ok(resp) => {
self.syslog(format!(
"✓ proxy verified: {url} → 127.0.0.1:{http_port} (status {})",
resp.status()
));
return Ok(());
}
Err(ureq::Error::Status(code, _)) if code < 500 => {
self.syslog(format!(
"✓ proxy verified: {url} → 127.0.0.1:{http_port} (status {code})"
));
return Ok(());
}
Err(e) => last_err = format!("{url}: {e}"),
}
}
if started.elapsed() >= VITE_READY_TIMEOUT {
bail!(
"vite proxy unreachable on port {ui_port} after {}s (last: {last_err}) — \
check that VITE_BACKEND_PORT was set when vite started, \
and that the daemon is listening on 127.0.0.1:{http_port}",
VITE_READY_TIMEOUT.as_secs()
);
}
std::thread::sleep(Duration::from_millis(400));
}
}
fn run_build_cmd(
&self,
cmd: &mut Command,
log_source: LogSource,
) -> Result<(std::process::ExitStatus, Vec<String>)> {
use std::sync::mpsc;
cmd.stdout(Stdio::null()).stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stderr = child.stderr.take().expect("piped");
let log_tx = self.log_tx.clone();
let (tx, rx) = mpsc::channel::<Vec<String>>();
std::thread::spawn(move || {
let mut lines = Vec::new();
for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
if let Some(ref ltx) = log_tx {
let _ = ltx.send(TuiEvent::Log(crate::tui::LogEntry {
source: log_source,
line: line.clone(),
}));
} else {
eprintln!("{line}");
}
lines.push(line);
}
let _ = tx.send(lines);
});
let status = loop {
match child.try_wait()? {
Some(s) => break s,
None => {
if crate::commands::dev::is_cancelled() {
let _ = child.kill();
let _ = child.wait();
let _lines = rx.recv().unwrap_or_default();
bail!("build cancelled");
}
std::thread::sleep(Duration::from_millis(50));
}
}
};
let lines = rx.recv().unwrap_or_default();
Ok((status, lines))
}
}
#[derive(Debug, serde::Deserialize)]
pub struct ControlRequest {
pub action: String,
#[serde(default)]
pub build: Option<String>,
#[serde(default)]
pub ts: u64,
}
impl DaemonHost for MonorepoHost {
fn instance_name(&self) -> &str {
&self.profile.name
}
fn rebuild_daemon_binary(&self) -> Result<()> {
let binary_path = daemon_binary_path(&self.monorepo_path);
if binary_path.exists() {
let _ = fs::remove_file(&binary_path);
}
self.syslog("→ rebuilding daemon: cargo build -p node-server");
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Building,
Some("cargo build (external request)".into()),
);
let (status, errors) = self
.run_build_cmd(
Command::new("cargo")
.args(["build", "-p", "node-server", "--features", "agentic_payments"])
.current_dir(&self.monorepo_path)
.stdin(Stdio::null()),
LogSource::Daemon,
)
.with_context(|| "spawn `cargo build -p node-server`")?;
if !status.success() {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed("cargo build failed".into()),
None,
);
let detail = if errors.is_empty() {
String::new()
} else {
format!("\n\n{}", errors.join("\n"))
};
bail!("cargo build -p node-server failed (exit {}){detail}", status);
}
Ok(())
}
fn take_control_request(&self) -> Option<ControlRequest> {
let env_dir = monorepo_env_dir(&self.monorepo_path, &self.profile.name).ok()?;
let path = env_dir.join("control/request.json");
let text = fs::read_to_string(&path).ok()?;
let _ = fs::remove_file(&path);
match serde_json::from_str::<ControlRequest>(&text) {
Ok(req) => Some(req),
Err(e) => {
self.syslog(format!("⚠ ignoring malformed control request: {e}"));
None
}
}
}
fn write_control_result(&self, ts: u64, ok: bool, message: &str) {
let Ok(env_dir) = monorepo_env_dir(&self.monorepo_path, &self.profile.name) else {
return;
};
let dir = env_dir.join("control");
let _ = fs::create_dir_all(&dir);
let body = serde_json::json!({ "ts": ts, "ok": ok, "message": message });
let tmp = dir.join("last-result.json.tmp");
if fs::write(&tmp, body.to_string()).is_ok() {
let _ = fs::rename(&tmp, dir.join("last-result.json"));
}
}
fn ensure_running(&self) -> Result<DaemonHandle> {
let cargo_toml = self.monorepo_path.join("Cargo.toml");
if !cargo_toml.exists() {
bail!(
"{} doesn't look like a monorepo root (no Cargo.toml found)",
self.monorepo_path.display()
);
}
let manifest = fs::read_to_string(&cargo_toml)
.with_context(|| format!("read {}", cargo_toml.display()))?;
let has_server = manifest.contains("\"system/server\"")
|| manifest.contains("system/server")
|| manifest.contains("\"apps/server\"")
|| manifest.contains("apps/server");
if !has_server {
bail!(
"{}/Cargo.toml does not include 'system/server' — is this the right path?",
self.monorepo_path.display()
);
}
let env_dir = monorepo_env_dir(&self.monorepo_path, &self.profile.name)?;
fs::create_dir_all(&env_dir).ok();
let pid_file = env_dir.join("daemon.pid");
if let Ok(contents) = fs::read_to_string(&pid_file) {
if let Ok(old_pid) = contents.trim().parse::<i32>() {
let alive = unsafe { libc::kill(old_pid, 0) == 0 };
if alive {
self.syslog(format!(
"→ previous instance found (PID {old_pid}), sending SIGTERM…"
));
unsafe {
libc::kill(-old_pid, libc::SIGTERM);
}
let deadline = Instant::now();
loop {
std::thread::sleep(Duration::from_millis(200));
if unsafe { libc::kill(old_pid, 0) } != 0 {
self.syslog(format!(
"✓ previous instance exited ({}ms)",
deadline.elapsed().as_millis()
));
break;
}
if deadline.elapsed() > Duration::from_secs(15) {
self.syslog("⚠ previous instance did not exit after 15s, SIGKILL");
unsafe {
libc::kill(-old_pid, libc::SIGKILL);
}
break;
}
}
}
let _ = fs::remove_file(&pid_file);
}
}
self.free_port(self.profile.http_port, "backend")?;
self.free_port(self.profile.https_port, "https")?;
self.free_port(self.profile.ui_port, "ui")?;
let vite_pid_file = env_dir.join("vite.pid");
if let Ok(contents) = fs::read_to_string(&vite_pid_file) {
if let Ok(old_pid) = contents.trim().parse::<i32>() {
let alive = unsafe { libc::kill(old_pid, 0) == 0 };
if alive {
self.syslog(format!(
"→ previous vite found (PID {old_pid}), sending SIGTERM…"
));
unsafe {
libc::kill(-old_pid, libc::SIGTERM);
}
}
}
let _ = fs::remove_file(&vite_pid_file);
}
let socket_path = self
.socket_override
.clone()
.unwrap_or_else(|| env_dir.join("control.sock"));
let dev_dir = self
.dev_dir_override
.clone()
.unwrap_or_else(|| env_dir.join("dev-apps"));
let db_path = env_dir.join("dev.db");
fs::create_dir_all(&dev_dir).ok();
let env_file = env_dir.join("daemon.env");
let base_env_path = self
.monorepo_path
.join("system/server")
.join(&self.profile.env_file_name);
if !base_env_path.exists() {
materialize_base_env_from_example(&base_env_path).with_context(|| {
format!(
"no base env file for instance '{}' at {}, and generating one from its \
.env.example template also failed — pick a different instance via \
--instances, or create {} by hand from {}.example.",
self.profile.name,
base_env_path.display(),
base_env_path.display(),
base_env_path.display(),
)
})?;
}
let base_env = fs::read_to_string(&base_env_path)
.with_context(|| format!("read base env {}", base_env_path.display()))?;
let log_dir = env_dir.join("logs");
fs::create_dir_all(&log_dir).ok();
let ldk_dir = env_dir.join("ldk_data");
fs::create_dir_all(&ldk_dir).ok();
let api_port = self.profile.http_port;
let apt_apps_dir = self.monorepo_path.join("modules");
let static_dir = self.monorepo_path.join("system/ui/dist");
let pwa_static_dir = self.monorepo_path.join("system/pwa/dist");
let https_port = self.profile.https_port;
let p2p_port = self.profile.p2p_port;
let mut pairs: Vec<(&str, String, &str)> = vec![
("APT_APPS_DIR", apt_apps_dir.display().to_string(),
"load builtin apps from the monorepo's modules/ instead of /usr/lib/node/apps"),
("NODE_DEV_APPS_DIR", dev_dir.display().to_string(),
"stage dep apps into a per-instance cache dir so alice/bob don't collide"),
("DATABASE_URL", format!("sqlite://{}", db_path.display()),
"per-instance dev DB under cache dir"),
("NODE_IPC_SOCKET", socket_path.display().to_string(),
"per-instance IPC socket the orchestrator polls for readiness"),
("NODE_RUNTIME_TLS_STATE", env_dir.join("tls.json").display().to_string(),
"per-instance writable path for the runtime TLS handoff manifest — the production default (/run/node/tls.json) doesn't exist and isn't writable in a macOS/dev checkout, which silently broke TLS-state publish (and, in --client-node mode, client-transport origin eligibility, since it reads the same manifest) (#1867)"),
("SERVER_ADDRESS", format!("0.0.0.0:{api_port}"),
"profile-specific HTTP port (alice=3001, bob=3002, …) — bind 0.0.0.0 so peer-to-peer fetches via the LAN IP advertised through gossip resolve (loopback-only binding broke api_store catalog mirroring between alice and bob)"),
("HTTPS_SERVER_ADDRESS", format!("0.0.0.0:{https_port}"),
"profile-specific HTTPS/TLS port (alice=4431, bob=4432, …) so two nodes don't both bind the default :443 and collide; the advertised port is derived from this, so peers resolve it via gossip"),
("LDK_LISTENING_ADDRESS", format!("127.0.0.1:{p2p_port}"),
"profile-specific LDK P2P listen port (InstanceProfile::p2p_port, offset by CLIENT_NODE_PORT_OFFSET in --client-node mode) — without this override the daemon falls back to whatever LDK_LISTENING_ADDRESS the base env file hardcodes, so a --client-node lane and a legacy lane for the SAME instance would both try to bind the same LDK port (#1529)"),
("APPLICATION_LOG_DIR_PATH", log_dir.display().to_string(),
"per-instance application logs under cache dir"),
("DATA_DIR_PATH", ldk_dir.display().to_string(),
"per-instance LDK data dir under cache dir"),
("SIGNER_SEED_PATH", format!("{}/signer_seed.hex", ldk_dir.display()),
"per-instance LDK signer seed under cache dir"),
("STATIC_DIR_PATH", static_dir.display().to_string(),
"absolute path to monorepo/system/ui/dist — the legacy system/ui bundle the backend always serves at /v1/"),
("CLIENT_NODE_PWA_STATIC_DIR_PATH", pwa_static_dir.display().to_string(),
"absolute path to monorepo/system/pwa/dist — the client-node PWA bundle the backend always serves at / (there is no enable flag any more; both bundles are always mounted, so this is set unconditionally, not just under --client-node)"),
("NODE_ALLOW_DEV_SOCKET_PATH", "1".to_string(),
"accept standalone socket paths outside /run/ (dev cache dirs aren't /run/-writable)"),
("AUTO_SELF_SIGNED_TLS", "false".to_string(),
"keep the dev daemon on plain HTTP — the daemon otherwise auto-provisions a self-signed cert and sets ssl_enabled=true once it has a routable LAN IP, which makes the API HTTPS-only and turns the HTTP port (that node-app dev's health check + Vite proxy target) into a redirect-only listener. force_dev_http_only clears any stale flag; this stops it re-enabling on boot"),
("OTA_HTTP_PORT", (api_port + 10000).to_string(),
"esp32-bridge's dedicated OTA HTTP listen port, per-instance (alice=13001, bob=13002). Its default (the node's main port, 3001) makes whichever node boots first squat the OTHER node's main port; the second node's pre-boot free_port(3001) then SIGTERMs the first node's whole daemon. Offset by 10000 so it never overlaps any instance's http/https/ui port (the ports free_port clears), so the two daemons can't kill each other"),
("ENABLE_TEST_HARNESS", "true".to_string(),
"register the loopback-only /api/v2/internal/test/* routes (notably seed-peer). The harness cross-seeds each node's IP pool with the other's 127.0.0.1:<port> via POST /api/v2/internal/test/seed-peer; without this the route falls through to the SPA handler (HTML, not JSON) and the seed silently fails, so peer discovery finds no active HTTP endpoint and every inter-node flow (friend/conversation/L402 proxy — and cross-node trace propagation) breaks in the local harness"),
("NODE_HTTP_DIRECT_TCP", "true".to_string(),
"node-server serves only over its Unix socket when fronted by node-provisioning (PR #1463); node-app dev runs the daemon alone, so bind TCP on SERVER_ADDRESS directly"),
];
if self.client_node {
pairs.push((
"DEVELOPMENT_MODE",
"true".to_string(),
"required for CLIENT_NODE_DEV_TRUST_SELF_SIGNED (below) to take effect — it is gated on is_development",
));
pairs.retain(|(key, _, _)| *key != "AUTO_SELF_SIGNED_TLS");
pairs.push((
"AUTO_SELF_SIGNED_TLS",
"true".to_string(),
"client-node lane only: provision a real self-signed certificate (its SAN list always covers localhost/127.0.0.1) so CLIENT_NODE_WAN_ORIGIN/CLIENT_NODE_LAN_ORIGINS below can be genuinely reachable over HTTPS — overrides the plain-HTTP-only default set above",
));
let time_trust_marker = env_dir.join("time-trust-marker");
if let Err(e) = fs::write(&time_trust_marker, b"") {
bail!(
"gap 0g: failed to create the time-trust marker at {} needed to unblock \
AUTO_SELF_SIGNED_TLS's wait_for_time_trust() on this platform: {e}. \
Without it, --client-node's HTTPS dev lane hangs forever waiting for a \
systemd marker that doesn't exist here, and the PWA mounts but its \
kernel never activates (empty page, ERR_SSL_PROTOCOL_ERROR in console).",
time_trust_marker.display()
);
}
pairs.push((
"NODE_RUNTIME_TIME_SYNC_MARKER",
time_trust_marker.display().to_string(),
"unblocks wait_for_time_trust() in background_tasks.rs (gap 0g) — its default \
path (/run/systemd/timesync/synchronized) never exists on macOS, so \
AUTO_SELF_SIGNED_TLS=true above would otherwise never actually issue a \
certificate and tls.json would stay `disabled` forever",
));
pairs.push((
"CLIENT_NODE_DEV_TRUST_SELF_SIGNED",
"true".to_string(),
"dev-only, is_development-gated bypass (core/foundation/src/config.rs) that treats the self-signed certificate above as eligible for the client-transport WAN/LAN origins — never takes effect outside DEVELOPMENT_MODE=true (#1867)",
));
pairs.push((
"NODE_HTTP_DIRECT_TLS",
"true".to_string(),
"bind real HTTPS on HTTPS_SERVER_ADDRESS using the materialized self-signed certificate (existing flag; see resolve_direct_tls_certificate in system/server/src/boot/phase_server.rs)",
));
pairs.push((
"NODE_HTTP_DIRECT_TLS_DUAL_LISTEN",
"true".to_string(),
"keep serving the full app on plain HTTP (SERVER_ADDRESS) too, instead of turning it into a redirect-only listener — preserves the plain-HTTP contract every existing dev/e2e helper (health checks, --agent/--operation-mode) depends on (#1867)",
));
pairs.push((
"CLIENT_NODE_WAN_ORIGIN",
format!("https://localhost:{https_port}"),
"canonical HTTPS WAN origin the browser navigates to and the PWA advertises — eligible via CLIENT_NODE_DEV_TRUST_SELF_SIGNED (#1867)",
));
pairs.push((
"CLIENT_NODE_LAN_ORIGINS",
format!("https://127.0.0.1:{https_port}"),
"a second real, distinct browser origin on the SAME HTTPS listener (0.0.0.0 bind) — gives the harness a genuine LAN-vs-WAN candidate pair (#1867)",
));
}
let mut overrides = String::from(
"# ============================================================\n\
# node-app dev overrides (auto-generated — do not edit)\n\
#\n\
# These keys are computed at orchestrator startup and MUST win\n\
# over the base env file below for per-instance isolation. The\n\
# base env file (alice.env / bob.env) is the source of truth\n\
# for everything else — edit it directly if you need to change\n\
# other settings.\n\
#\n\
# dotenvy is first-wins, so this block has to come first.\n\
# ============================================================\n",
);
for (key, _, reason) in &pairs {
overrides.push_str(&format!("# {key}: {reason}\n"));
}
overrides.push('\n');
for (key, value, _) in &pairs {
overrides.push_str(&format!("{key}={value}\n"));
}
overrides.push_str(&format!(
"\n# ============================================================\n\
# Base config (system/server/{env_file_name})\n\
# ============================================================\n",
env_file_name = self.profile.env_file_name,
));
fs::write(&env_file, overrides + &base_env)
.with_context(|| format!("write {}", env_file.display()))?;
*self.env_file.lock().unwrap() = Some(env_file.clone());
tui::update_daemon_env_file(
self.log_tx.as_ref(),
&self.profile.name,
env_file.clone(),
);
let _ = fs::remove_file(&socket_path);
ensure_yarn_install(&self.monorepo_path)?;
let pwa_build_handle = ensure_pwa_dist_built(&self.monorepo_path, self.log_tx.clone());
ensure_ui_dist_built(&self.monorepo_path, self.log_tx.clone());
ensure_builtin_apps_built(&self.monorepo_path, self.log_tx.clone());
let binary_path = daemon_binary_path(&self.monorepo_path);
if DAEMON_BUILD_ONCE.get().is_none() {
let has_make = Command::new("make")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if has_make {
self.syslog("→ building mandatory builtin apps: make builtin-apps CARGO_PROFILE=debug");
let t0 = Instant::now();
let (status, _) = self.run_build_cmd(
Command::new("make")
.args(["builtin-apps", "CARGO_PROFILE=debug"])
.current_dir(&self.monorepo_path)
.stdin(Stdio::null()),
LogSource::System,
)
.with_context(|| "spawn `make builtin-apps`")?;
let builtins_ms = t0.elapsed().as_millis() as u64;
if !status.success() {
self.syslog(format!(
"⚠ `make builtin-apps` failed (exit {}) — some platform apps may not load",
status
));
}
tui::update_status(
self.log_tx.as_ref(),
LogSource::Build,
ServiceStatus::Ready,
Some(format!("builtins:{}ms", builtins_ms)),
);
} else {
self.syslog("⚠ `make` not found — skipping builtin-apps build");
}
if binary_path.exists() {
let _ = fs::remove_file(&binary_path);
}
self.syslog(format!(
"→ building daemon: cargo build -p node-server (cwd={})",
self.monorepo_path.display()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Building,
Some(format!("cargo build (cwd={})", self.monorepo_path.display())),
);
let (build_status, build_errors) = self
.run_build_cmd(
Command::new("cargo")
.args(["build", "-p", "node-server", "--features", "agentic_payments"])
.current_dir(&self.monorepo_path)
.stdin(Stdio::null()),
LogSource::Daemon,
)
.with_context(|| "spawn `cargo build -p node-server`")?;
if !build_status.success() {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed("cargo build failed".into()),
None,
);
let detail = if build_errors.is_empty() {
String::new()
} else {
format!("\n\n{}", build_errors.join("\n"))
};
bail!(
"cargo build -p node-server failed (exit {}){detail}",
build_status
);
}
let _ = DAEMON_BUILD_ONCE.set(());
} else {
self.syslog(format!(
"→ reusing daemon binary built by a prior instance this run ({})",
binary_path.display()
));
if !binary_path.exists() {
bail!(
"daemon binary not found at {} ({}), even though a prior instance in \
this same run already reported `cargo build -p node-server` \
succeeding. The build and this lookup resolve to the same directory, \
so something removed the binary after that build finished (or \
CARGO_TARGET_DIR changed between the build and now) — this is not the \
build failing. Rerun `node-app dev`.",
binary_path.display(),
target_dir_diagnostic(&self.monorepo_path),
);
}
}
match pwa_build_handle.join() {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e).context("PWA dist build"),
Err(_) => bail!("PWA dist build thread panicked"),
}
self.syslog(format!(
"→ spawning daemon (env={})",
env_file.display()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Starting,
Some(format!("[{}] api=:{} ipc={}", self.profile.name, self.profile.http_port, socket_path.display())),
);
if !self.client_node {
self.force_dev_http_only(&db_path);
}
let child = self.spawn_daemon(&env_file)?;
let child_pid = child.id();
if let Err(e) = fs::write(&pid_file, child_pid.to_string()) {
self.syslog(format!("⚠ could not write PID file: {e}"));
}
*self.child.lock().unwrap() = Some(child);
let started = Instant::now();
loop {
if UnixStream::connect(&socket_path).is_ok() {
self.syslog(format!(
"✓ IPC socket up at {} ({}s) — waiting for apps…",
socket_path.display(),
started.elapsed().as_secs()
));
break;
}
if started.elapsed() >= HEALTH_TIMEOUT {
self.shutdown();
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed(format!(
"socket not connectable after {}s",
HEALTH_TIMEOUT.as_secs()
)),
None,
);
return Err(anyhow!(
"daemon did not come up within {}s (socket at {} not connectable).",
HEALTH_TIMEOUT.as_secs(),
socket_path.display()
));
}
if let Some(child) = self.child.lock().unwrap().as_mut() {
if let Ok(Some(status)) = child.try_wait() {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed(format!("exited {}", status)),
None,
);
return Err(anyhow!("daemon exited with {} before becoming ready", status));
}
}
std::thread::sleep(HEALTH_POLL);
}
self.wait_http_ready(self.profile.http_port)?;
if self.client_node {
match self.wait_tcp_ready(self.profile.https_port, "client-node HTTPS") {
Ok(()) => {
self.syslog(format!(
"✓ client-node PWA available at https://localhost:{} \
(backend-served from {}; self-signed dev certificate — \
browsers/harness must opt in to trust it)",
self.profile.https_port,
self.monorepo_path.join("system/pwa/dist").display()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Ready,
Some(format!(
"https://localhost:{} (backend-served, no Vite HMR)",
self.profile.https_port
)),
);
}
Err(e) => {
self.syslog(format!(
"⚠ client-node HTTPS listener on {} not reachable: {e:#} — \
CLIENT_NODE_WAN_ORIGIN will not resolve for a browser",
self.profile.https_port
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Failed("client-node HTTPS listener unreachable".into()),
None,
);
}
}
}
let ui_child = self.spawn_ui();
if let Some(ref c) = ui_child {
let _ = fs::write(&vite_pid_file, c.id().to_string());
if let Err(e) = self.verify_proxy(self.profile.ui_port, self.profile.http_port) {
self.syslog(format!("⚠ proxy verification failed: {e:#}"));
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Failed("proxy not forwarding /api".into()),
None,
);
} else if let Err(e) = self.verify_ws_proxy(self.profile.ui_port) {
self.syslog(format!("⚠ ws proxy verification failed: {e:#}"));
tui::update_status(
self.log_tx.as_ref(),
LogSource::UiServer,
ServiceStatus::Failed("ws proxy /api/ws not upgrading".into()),
None,
);
}
}
*self.ui_child.lock().unwrap() = ui_child;
let apps_started = Instant::now();
self.syslog("→ waiting for critical apps (device-registry, core-storage)…");
loop {
let all_ready = ipc_list_app_statuses(&socket_path)
.map(|statuses| {
["device-registry", "core-storage"].iter().all(|name| {
statuses
.get(*name)
.map(|s| s == "active" || s == "running" || s == "lazy")
.unwrap_or(false)
})
})
.unwrap_or(false);
if all_ready {
self.syslog(format!(
"✓ critical apps ready ({}s)",
apps_started.elapsed().as_secs()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Ready,
Some(format!(
"[{}] api=http://localhost:{} ui=http://localhost:{}",
self.profile.name, self.profile.http_port, self.profile.ui_port
)),
);
break;
}
if apps_started.elapsed() >= Duration::from_secs(60) {
self.syslog("⚠ critical apps not fully loaded after 60s — proceeding anyway");
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Ready,
Some("api ready (some apps slow)".into()),
);
break;
}
if let Some(child) = self.child.lock().unwrap().as_mut() {
if let Ok(Some(status)) = child.try_wait() {
return Err(anyhow!("daemon exited with {} while waiting for apps", status));
}
}
std::thread::sleep(Duration::from_secs(2));
}
Ok(DaemonHandle {
name: self.profile.name.clone(),
banner: if self.client_node {
format!(
"monorepo daemon [{}] (cargo run from {}, client-node PWA=http://localhost:{} \
(plain) or https://localhost:{} (self-signed, browser-navigable WAN origin; \
LAN candidate=https://127.0.0.1:{}) [backend-served, no Vite HMR; shared \
platform deps], socket={})",
self.profile.name,
self.monorepo_path.display(),
self.profile.http_port,
self.profile.https_port,
self.profile.https_port,
socket_path.display()
)
} else {
format!(
"monorepo daemon [{}] (cargo run from {}, api=http://localhost:{}, \
ui=http://localhost:{}, socket={})",
self.profile.name,
self.monorepo_path.display(),
self.profile.http_port,
self.profile.ui_port,
socket_path.display()
)
},
socket_path,
dev_dir,
api_base_url: Some(format!("http://127.0.0.1:{}", self.profile.http_port)),
})
}
fn tail_logs(&self, app_name: &str) {
let log_tx = match &self.log_tx {
Some(tx) => tx.clone(),
None => return,
};
let env_dir = match monorepo_env_dir(&self.monorepo_path, &self.profile.name) {
Ok(d) => d,
Err(_) => return,
};
let log_file = env_dir
.join("ldk_data")
.join("logs")
.join("apps")
.join(app_name)
.join("app.log");
let prefix = format!("[{node}][{app}] ", node = self.profile.name, app = app_name);
std::thread::spawn(move || {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
let deadline = std::time::Instant::now();
loop {
if log_file.exists() {
break;
}
if deadline.elapsed() > std::time::Duration::from_secs(5) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
let file = match fs::File::open(&log_file) {
Ok(f) => f,
Err(_) => return,
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::Start(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => {
std::thread::sleep(std::time::Duration::from_millis(100));
}
Ok(_) => {
let trimmed =
line.trim_end_matches('\n').trim_end_matches('\r');
if !trimmed.is_empty()
&& log_tx
.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::App,
line: format!("{prefix}{trimmed}"),
}))
.is_err()
{
return; }
}
Err(_) => return,
}
}
});
}
fn shutdown(&self) {
if let Ok(env_dir) = monorepo_env_dir(&self.monorepo_path, &self.profile.name) {
let _ = fs::remove_file(env_dir.join("daemon.pid"));
}
if let Some(mut child) = self.child.lock().unwrap().take() {
let pid = child.id() as i32;
self.syslog(format!("→ shutting down monorepo daemon (PID {pid})…"));
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
let t = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
self.syslog(format!(
"✓ daemon exited ({}ms)",
t.elapsed().as_millis()
));
break;
}
Ok(None) if t.elapsed() >= Duration::from_secs(10) => {
self.syslog("⚠ daemon did not exit after 10s, SIGKILL");
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
let _ = child.wait();
break;
}
Ok(None) => std::thread::sleep(Duration::from_millis(200)),
Err(e) => {
self.syslog(format!("⚠ daemon wait error: {e}"));
break;
}
}
}
}
if let Some(mut ui) = self.ui_child.lock().unwrap().take() {
let pid = ui.id() as i32;
self.syslog(format!("→ shutting down UI dev server (PID {pid})…"));
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
let t = Instant::now();
loop {
match ui.try_wait() {
Ok(Some(_)) => break,
Ok(None) if t.elapsed() >= Duration::from_secs(5) => {
#[cfg(unix)]
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
let _ = ui.wait();
break;
}
Ok(None) => std::thread::sleep(Duration::from_millis(200)),
Err(_) => break,
}
}
}
}
fn restart(&self) -> Result<()> {
let env_file = self
.env_file
.lock()
.unwrap()
.clone()
.ok_or_else(|| anyhow!("daemon was never started — cannot restart"))?;
self.syslog("→ restart: stopping daemon…");
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Starting,
Some("restarting…".into()),
);
self.shutdown_daemon_only();
self.free_port(self.profile.http_port, "backend")?;
let socket_path = self
.socket_override
.clone()
.unwrap_or_else(|| {
monorepo_env_dir(&self.monorepo_path, &self.profile.name)
.ok()
.map(|d| d.join("control.sock"))
.unwrap_or_else(|| PathBuf::from("/tmp/node-control.sock"))
});
let _ = fs::remove_file(&socket_path);
self.syslog("→ restart: spawning daemon…");
let child = self.spawn_daemon(&env_file)?;
let child_pid = child.id();
*self.child.lock().unwrap() = Some(child);
if let Ok(env_dir) = monorepo_env_dir(&self.monorepo_path, &self.profile.name) {
let _ = fs::write(env_dir.join("daemon.pid"), child_pid.to_string());
}
let t = Instant::now();
loop {
if UnixStream::connect(&socket_path).is_ok() {
if let Err(e) = self.wait_http_ready(self.profile.http_port) {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed("HTTP not ready after restart".into()),
None,
);
bail!("daemon restart: HTTP readiness failed: {e:#}");
}
self.syslog(format!(
"✓ daemon restarted ({}s)",
t.elapsed().as_secs()
));
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Ready,
Some("restarted".into()),
);
return Ok(());
}
if t.elapsed() > HEALTH_TIMEOUT {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed("socket not connectable after restart".into()),
None,
);
bail!("daemon did not come up after restart");
}
if let Some(child) = self.child.lock().unwrap().as_mut() {
if let Ok(Some(status)) = child.try_wait() {
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Failed(format!("exited {}", status)),
None,
);
bail!("daemon exited with {} during restart", status);
}
}
std::thread::sleep(HEALTH_POLL);
}
}
fn pre_start_dev_dir(&self) -> Option<PathBuf> {
monorepo_dev_dir(&self.monorepo_path, &self.profile.name, self.dev_dir_override.as_deref()).ok()
}
}
static DAEMON_BUILD_ONCE: OnceLock<()> = OnceLock::new();
static YARN_INSTALL_ONCE: OnceLock<()> = OnceLock::new();
fn ensure_yarn_install(monorepo_path: &Path) -> Result<()> {
if YARN_INSTALL_ONCE.set(()).is_err() {
return Ok(()); }
if monorepo_path.join("node_modules").is_dir() {
return Ok(());
}
if !which_bin("yarn") {
bail!(
"{} has no node_modules and `yarn` is not on PATH. This monorepo's root \
package.json declares yarn workspaces (system/pwa, system/ui, client/*, ...) \
that `npm install` cannot resolve the same way — install yarn \
(https://yarnpkg.com, this repo pins {}) and re-run, or run `yarn install` \
yourself from {} before retrying.",
monorepo_path.display(),
"packageManager: yarn@4.18.0 (see package.json)",
monorepo_path.display()
);
}
eprintln!(
"harness: {} has no node_modules yet — running `yarn install` once (installs every \
yarn workspace: system/pwa, system/ui, client/*, ...) before building the PWA/UI \
dist bundles. This is a one-time cost for a fresh checkout.",
monorepo_path.display()
);
let status = Command::new("yarn")
.arg("install")
.current_dir(monorepo_path)
.status()
.with_context(|| format!("failed to spawn `yarn install` in {}", monorepo_path.display()))?;
if !status.success() {
bail!(
"`yarn install` failed ({status}) in {}. Neither the PWA nor the legacy UI dist \
bundle can build without it, so the daemon would boot serving a 404/blank page at \
`/` and `/v1/`. Remedy: run `cd {} && yarn install` and inspect the output above \
for the actual failure.",
monorepo_path.display(),
monorepo_path.display()
);
}
eprintln!("harness: yarn install complete — building PWA/UI dist bundles next");
Ok(())
}
static UI_BUILD_ONCE: OnceLock<()> = OnceLock::new();
fn ensure_ui_dist_built(monorepo_path: &Path, log_tx: Option<LogTx>) {
if UI_BUILD_ONCE.set(()).is_err() {
return; }
let ui_dir = monorepo_path.join("system/ui");
if !ui_dir.join("package.json").exists() {
tui::sys_log(
log_tx.as_ref(),
format!(
"→ skip UI dist build: {} not found",
ui_dir.join("package.json").display()
),
);
return;
}
let pkg_mgr = if which_bin("yarn") { "yarn" } else { "npm" };
let args: &[&str] = if pkg_mgr == "yarn" {
&["build"]
} else {
&["run", "build"]
};
tui::sys_log(
log_tx.as_ref(),
format!(
"→ rebuilding UI dist in background ({} {}) — daemon's port will serve fresh bundle once done",
pkg_mgr,
args.join(" ")
),
);
tui::update_status(
log_tx.as_ref(),
LogSource::Build,
ServiceStatus::Building,
Some(format!("{} build (ui)", pkg_mgr)),
);
let ui_dir_clone = ui_dir.clone();
let log_tx_clone = log_tx.clone();
std::thread::spawn(move || {
let mut cmd = Command::new(pkg_mgr);
cmd.args(args).current_dir(&ui_dir_clone).stdin(Stdio::null());
if log_tx_clone.is_some() {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
}
let started = Instant::now();
match cmd.spawn() {
Ok(mut child) => {
if let Some(tx) = &log_tx_clone {
if let Some(stdout) = child.stdout.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[ui-build] {line}"),
}));
}
});
}
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[ui-build] {line}"),
}));
}
});
}
}
match child.wait() {
Ok(status) if status.success() => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✓ UI dist rebuilt in {:.1}s",
started.elapsed().as_secs_f32()
),
);
tui::update_status(
log_tx_clone.as_ref(),
LogSource::Build,
ServiceStatus::Ready,
Some("ui dist fresh".into()),
);
}
Ok(status) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✗ UI dist build failed (exit {}); daemon port will serve stale bundle. \
Use http://localhost:5173/5174 (Vite, HMR) until this is fixed.",
status
),
);
tui::update_status(
log_tx_clone.as_ref(),
LogSource::Build,
ServiceStatus::Failed("ui build failed".into()),
None,
);
}
Err(e) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!("✗ UI dist build wait error: {e}"),
);
}
}
}
Err(e) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✗ could not spawn {} build for UI dist: {e}",
pkg_mgr
),
);
}
}
});
}
static PWA_BUILD_ONCE: OnceLock<()> = OnceLock::new();
struct PwaBuildState {
outcome: Mutex<Option<Result<bool, String>>>,
done: Condvar,
}
static PWA_BUILD_STATE: OnceLock<PwaBuildState> = OnceLock::new();
fn pwa_build_state() -> &'static PwaBuildState {
PWA_BUILD_STATE.get_or_init(|| PwaBuildState {
outcome: Mutex::new(None),
done: Condvar::new(),
})
}
pub fn pwa_dist_build_outcome() -> Option<bool> {
PWA_BUILD_STATE.get()?.outcome.lock().unwrap().clone()?.ok()
}
fn pwa_package_json_exists(pwa_dir: &Path) -> bool {
pwa_dir.join("package.json").exists()
}
fn run_pwa_build_command(
pwa_dir: &Path,
program: &str,
args: &[&str],
log_tx: Option<&LogTx>,
) -> Result<()> {
let mut cmd = Command::new(program);
cmd.args(args).current_dir(pwa_dir).stdin(Stdio::null());
if log_tx.is_some() {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
}
let started = Instant::now();
let mut child = cmd
.spawn()
.with_context(|| format!("spawn PWA dist build: {program} {}", args.join(" ")))?;
if let Some(tx) = log_tx {
if let Some(stdout) = child.stdout.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[pwa-build] {line}"),
}));
}
});
}
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[pwa-build] {line}"),
}));
}
});
}
}
let status = child
.wait()
.with_context(|| format!("wait for PWA dist build: {program} {}", args.join(" ")))?;
if !status.success() {
tui::update_status(
log_tx,
LogSource::Build,
ServiceStatus::Failed("pwa build failed".into()),
None,
);
bail!(
"PWA dist build failed (exit {status}) — `{program} {}` in {} after {:.1}s. Run it \
directly to see the real error, fix it, then rerun `harness up` / `node-app dev`. \
The daemon is NOT started while its bundle is broken: `resolve_pwa_root` decides \
PWA-vs-legacy once at boot, so a stale/missing dist would otherwise silently \
downgrade `/` to the legacy UI instead of failing loudly.",
args.join(" "),
pwa_dir.display(),
started.elapsed().as_secs_f32(),
);
}
tui::sys_log(
log_tx,
format!("✓ PWA dist rebuilt in {:.1}s", started.elapsed().as_secs_f32()),
);
tui::update_status(
log_tx,
LogSource::Build,
ServiceStatus::Ready,
Some("pwa dist fresh".into()),
);
Ok(())
}
fn ensure_pwa_dist_built(
monorepo_path: &Path,
log_tx: Option<LogTx>,
) -> std::thread::JoinHandle<Result<()>> {
let is_leader = PWA_BUILD_ONCE.set(()).is_ok();
let monorepo_path = monorepo_path.to_path_buf();
std::thread::spawn(move || -> Result<()> {
let state = pwa_build_state();
if !is_leader {
let mut guard = state.outcome.lock().unwrap();
while guard.is_none() {
guard = state.done.wait(guard).unwrap();
}
return match guard.clone().unwrap() {
Ok(_) => Ok(()),
Err(msg) => Err(anyhow!(msg)),
};
}
let pwa_dir = monorepo_path.join("system/pwa");
if !pwa_package_json_exists(&pwa_dir) {
tui::sys_log(
log_tx.as_ref(),
format!(
"→ skip PWA dist build: {} not found",
pwa_dir.join("package.json").display()
),
);
*state.outcome.lock().unwrap() = Some(Ok(false));
state.done.notify_all();
return Ok(());
}
let pkg_mgr = if which_bin("yarn") { "yarn" } else { "npm" };
let args: &[&str] = if pkg_mgr == "yarn" {
&["build"]
} else {
&["run", "build"]
};
tui::sys_log(
log_tx.as_ref(),
format!(
"→ rebuilding client-node PWA dist ({pkg_mgr} {}) — bring-up waits for this to \
finish before starting the daemon",
args.join(" "),
),
);
tui::update_status(
log_tx.as_ref(),
LogSource::Build,
ServiceStatus::Building,
Some(format!("{pkg_mgr} build (pwa)")),
);
let result = run_pwa_build_command(&pwa_dir, pkg_mgr, args, log_tx.as_ref());
*state.outcome.lock().unwrap() = Some(match &result {
Ok(()) => Ok(true),
Err(e) => Err(format!("{e:#}")),
});
state.done.notify_all();
result
})
}
static BUILTIN_APPS_BUILD_ONCE: OnceLock<()> = OnceLock::new();
fn ensure_builtin_apps_built(monorepo_path: &Path, log_tx: Option<LogTx>) {
if BUILTIN_APPS_BUILD_ONCE.set(()).is_err() {
return; }
let makefile = monorepo_path.join("Makefile");
if !makefile.exists() {
tui::sys_log(
log_tx.as_ref(),
format!("→ skip builtin-apps build: {} not found", makefile.display()),
);
return;
}
if !which_bin("make") {
tui::sys_log(
log_tx.as_ref(),
"→ skip builtin-apps build: `make` not on PATH",
);
return;
}
tui::sys_log(
log_tx.as_ref(),
"→ rebuilding builtin apps in background (make builtin-apps) — cargo is incremental, only changed modules pay full cost",
);
tui::update_status(
log_tx.as_ref(),
LogSource::Build,
ServiceStatus::Building,
Some("make builtin-apps".into()),
);
let monorepo_clone = monorepo_path.to_path_buf();
let log_tx_clone = log_tx.clone();
std::thread::spawn(move || {
let mut cmd = Command::new("make");
cmd.arg("builtin-apps")
.current_dir(&monorepo_clone)
.stdin(Stdio::null());
if log_tx_clone.is_some() {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
} else {
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
}
let started = Instant::now();
match cmd.spawn() {
Ok(mut child) => {
if let Some(tx) = &log_tx_clone {
if let Some(stdout) = child.stdout.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[builtin-apps] {line}"),
}));
}
});
}
if let Some(stderr) = child.stderr.take() {
let tx = tx.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(TuiEvent::Log(crate::tui::LogEntry {
source: LogSource::Build,
line: format!("[builtin-apps] {line}"),
}));
}
});
}
}
match child.wait() {
Ok(status) if status.success() => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✓ builtin apps rebuilt in {:.1}s — daemon will pick up changes on next start/reload",
started.elapsed().as_secs_f32()
),
);
tui::update_status(
log_tx_clone.as_ref(),
LogSource::Build,
ServiceStatus::Ready,
Some("builtin apps fresh".into()),
);
}
Ok(status) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!(
"✗ builtin-apps build failed (exit {}); daemon will dlopen the previously-built .dylibs and may be missing capabilities you've added since.",
status
),
);
tui::update_status(
log_tx_clone.as_ref(),
LogSource::Build,
ServiceStatus::Failed("builtin-apps build failed".into()),
None,
);
}
Err(e) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!("✗ builtin-apps build wait error: {e}"),
);
}
}
}
Err(e) => {
tui::sys_log(
log_tx_clone.as_ref(),
format!("✗ could not spawn make builtin-apps: {e}"),
);
}
}
});
}
fn monorepo_env_dir(monorepo_path: &Path, instance_name: &str) -> Result<PathBuf> {
let cache_dir = cache_root()?;
let path_hash = {
let mut h = DefaultHasher::new();
monorepo_path
.canonicalize()
.unwrap_or_else(|_| monorepo_path.to_path_buf())
.hash(&mut h);
instance_name.hash(&mut h);
h.finish()
};
Ok(cache_dir.join(format!("monorepo-{:x}", path_hash)))
}
pub fn lane_instance_name(base: &str, client_node: bool) -> String {
if client_node {
format!("{base}-pwa")
} else {
base.to_string()
}
}
pub fn monorepo_dev_dir(monorepo_path: &Path, instance_name: &str, dev_dir_override: Option<&Path>) -> Result<PathBuf> {
if let Some(override_path) = dev_dir_override {
return Ok(override_path.to_path_buf());
}
Ok(monorepo_env_dir(monorepo_path, instance_name)?.join("dev-apps"))
}
fn ipc_list_app_statuses(
socket_path: &std::path::Path,
) -> Result<std::collections::HashMap<String, String>> {
use std::io::{BufRead, BufReader, Write};
let mut stream = UnixStream::connect(socket_path)
.with_context(|| "connect to IPC socket")?;
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
stream.set_write_timeout(Some(Duration::from_secs(5))).ok();
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "app.list",
"params": {}
});
let mut line = serde_json::to_string(&request).unwrap();
line.push('\n');
stream.write_all(line.as_bytes())?;
let reader = BufReader::new(&stream);
let response_line = reader
.lines()
.next()
.ok_or_else(|| anyhow!("no response"))??;
let v: serde_json::Value = serde_json::from_str(&response_line)?;
let mut map = std::collections::HashMap::new();
if let Some(apps) = v.pointer("/result/apps").and_then(|a| a.as_array()) {
for app in apps {
if let (Some(name), Some(status)) = (
app.get("name").and_then(|n| n.as_str()),
app.get("status").and_then(|s| s.as_str()),
) {
map.insert(name.to_string(), status.to_string());
}
}
}
Ok(map)
}
fn which_bin(bin: &str) -> bool {
std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).any(|dir| dir.join(bin).is_file()))
.unwrap_or(false)
}
fn daemon_binary_path(monorepo_path: &Path) -> PathBuf {
effective_target_dir(monorepo_path).join("debug/node-server")
}
fn effective_target_dir(monorepo_path: &Path) -> PathBuf {
if let Some(dir) = std::env::var_os("CARGO_TARGET_DIR").filter(|d| !d.is_empty()) {
let dir = PathBuf::from(dir);
return if dir.is_absolute() { dir } else { monorepo_path.join(dir) };
}
std::process::Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.args(["metadata", "--format-version", "1", "--no-deps"])
.current_dir(monorepo_path)
.output()
.ok()
.filter(|out| out.status.success())
.and_then(|out| serde_json::from_slice::<serde_json::Value>(&out.stdout).ok())
.and_then(|meta| {
meta.get("target_directory")
.and_then(|v| v.as_str())
.map(PathBuf::from)
})
.unwrap_or_else(|| monorepo_path.join("target"))
}
fn target_dir_diagnostic(monorepo_path: &Path) -> String {
match std::env::var("CARGO_TARGET_DIR") {
Ok(v) if !v.is_empty() => format!("CARGO_TARGET_DIR={v}, honored for both the build and this lookup"),
_ => format!(
"CARGO_TARGET_DIR is not set; cargo resolves the target dir to {} \
(a cargo config's `build.target-dir` can move it off <monorepo>/target)",
effective_target_dir(monorepo_path).display()
),
}
}
fn cache_root() -> Result<PathBuf> {
if let Ok(c) = std::env::var("XDG_CACHE_HOME") {
if !c.is_empty() {
return Ok(PathBuf::from(c).join("node-app"));
}
}
let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("$HOME not set"))?;
Ok(PathBuf::from(home).join(".cache/node-app"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_node_offsets_p2p_port_like_the_other_ports() {
let legacy = MonorepoHost::new(
PathBuf::from("/tmp/monorepo"),
InstanceProfile::alice(),
None,
None,
None,
false,
None,
);
let client_node = MonorepoHost::new(
PathBuf::from("/tmp/monorepo"),
InstanceProfile::alice(),
None,
None,
None,
true,
None,
);
assert_eq!(legacy.profile.p2p_port, InstanceProfile::alice().p2p_port);
assert_eq!(
client_node.profile.p2p_port,
InstanceProfile::alice().p2p_port + CLIENT_NODE_PORT_OFFSET
);
assert_ne!(
legacy.profile.p2p_port, client_node.profile.p2p_port,
"legacy and --client-node lanes for the same instance must derive \
distinct LDK P2P ports or they'll contend for LDK_LISTENING_ADDRESS"
);
assert_eq!(
client_node.profile.http_port,
legacy.profile.http_port + CLIENT_NODE_PORT_OFFSET
);
assert_eq!(
client_node.profile.https_port,
legacy.profile.https_port + CLIENT_NODE_PORT_OFFSET
);
assert_eq!(
client_node.profile.ui_port,
legacy.profile.ui_port + CLIENT_NODE_PORT_OFFSET
);
}
#[test]
fn client_node_alice_p2p_port_does_not_collide_with_plain_bob() {
let alice_client_node = MonorepoHost::new(
PathBuf::from("/tmp/monorepo"),
InstanceProfile::alice(),
None,
None,
None,
true,
None,
);
let bob_legacy = MonorepoHost::new(
PathBuf::from("/tmp/monorepo"),
InstanceProfile::bob(),
None,
None,
None,
false,
None,
);
assert_ne!(
alice_client_node.profile.p2p_port,
bob_legacy.profile.p2p_port
);
}
#[test]
fn materialize_base_env_from_example_substitutes_fresh_secrets() {
let dir = std::env::temp_dir().join(format!(
"harness-materialize-env-{}-{}",
std::process::id(),
fresh_hex_secret(4)
));
std::fs::create_dir_all(&dir).unwrap();
let example_path = dir.join("alice.env.example");
std::fs::write(
&example_path,
"JWT_SECRET=__GENERATE_FRESH_JWT_SECRET__\n\
REFRESH_TOKEN_SECRET=__GENERATE_FRESH_REFRESH_TOKEN_SECRET__\n\
LIBP2P_IDENTITY_SEED=__GENERATE_FRESH_LIBP2P_IDENTITY_SEED__\n\
LDK_NETWORK=\"regtest\"\n",
)
.unwrap();
let base_env_path = dir.join("alice.env");
materialize_base_env_from_example(&base_env_path).unwrap();
let first = std::fs::read_to_string(&base_env_path).unwrap();
assert!(
!first.contains("__GENERATE_FRESH_"),
"every placeholder must be substituted: {first}"
);
assert!(first.contains("LDK_NETWORK=\"regtest\""));
let base_env_path_2 = dir.join("alice-2.env");
std::fs::copy(&example_path, dir.join("alice-2.env.example")).unwrap();
materialize_base_env_from_example(&base_env_path_2).unwrap();
let second = std::fs::read_to_string(&base_env_path_2).unwrap();
assert_ne!(
first, second,
"two independent materializations must not produce identical secrets"
);
std::fs::remove_dir_all(&dir).ok();
}
static CARGO_TARGET_DIR_LOCK: Mutex<()> = Mutex::new(());
fn with_cargo_target_dir<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
let _guard = CARGO_TARGET_DIR_LOCK.lock().unwrap();
let previous = std::env::var_os("CARGO_TARGET_DIR");
match value {
Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
None => std::env::remove_var("CARGO_TARGET_DIR"),
}
let result = f();
match previous {
Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
None => std::env::remove_var("CARGO_TARGET_DIR"),
}
result
}
#[test]
fn daemon_binary_path_defaults_to_monorepo_target_when_unset() {
with_cargo_target_dir(None, || {
assert_eq!(
daemon_binary_path(Path::new("/tmp/monorepo")),
PathBuf::from("/tmp/monorepo/target/debug/node-server")
);
});
}
#[test]
fn daemon_binary_path_honors_an_absolute_cargo_target_dir() {
with_cargo_target_dir(Some("/var/tmp/shared-cargo-target"), || {
assert_eq!(
daemon_binary_path(Path::new("/tmp/monorepo")),
PathBuf::from("/var/tmp/shared-cargo-target/debug/node-server")
);
});
}
#[test]
fn daemon_binary_path_resolves_a_relative_cargo_target_dir_against_the_monorepo_path() {
with_cargo_target_dir(Some("build-out"), || {
assert_eq!(
daemon_binary_path(Path::new("/tmp/monorepo")),
PathBuf::from("/tmp/monorepo/build-out/debug/node-server")
);
});
}
#[test]
fn target_dir_diagnostic_names_the_variable_when_set() {
with_cargo_target_dir(Some("/var/tmp/shared-cargo-target"), || {
let msg = target_dir_diagnostic(Path::new("/tmp/monorepo"));
assert!(msg.contains("CARGO_TARGET_DIR"));
assert!(msg.contains("/var/tmp/shared-cargo-target"));
});
}
#[test]
fn target_dir_diagnostic_says_unset_when_absent() {
with_cargo_target_dir(None, || {
let msg = target_dir_diagnostic(Path::new("/tmp/monorepo"));
assert!(msg.contains("not set"));
});
}
#[test]
fn effective_target_dir_follows_a_cargo_config_build_target_dir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let out = root.join("configured-target");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"probe\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "").unwrap();
std::fs::create_dir_all(root.join(".cargo")).unwrap();
std::fs::write(
root.join(".cargo/config.toml"),
format!("[build]\ntarget-dir = \"{}\"\n", out.display()),
)
.unwrap();
with_cargo_target_dir(None, || {
let resolved = effective_target_dir(root);
assert_eq!(
resolved.canonicalize().ok(),
out.canonicalize().ok(),
"expected the config's target-dir ({}), got {}",
out.display(),
resolved.display()
);
});
}
#[test]
fn effective_target_dir_prefers_the_env_var_over_config() {
with_cargo_target_dir(Some("/var/tmp/env-wins"), || {
assert_eq!(
effective_target_dir(Path::new("/tmp/monorepo")),
PathBuf::from("/var/tmp/env-wins")
);
});
}
#[test]
#[cfg(unix)]
fn free_port_refuses_to_kill_a_holder_it_cannot_attribute_to_itself() {
use std::net::TcpListener;
let Ok(listener) = TcpListener::bind("127.0.0.1:0") else {
return; };
let port = listener.local_addr().unwrap().port();
let host = MonorepoHost::new(
PathBuf::from("/tmp/definitely-not-a-real-monorepo-for-free-port-test"),
InstanceProfile::alice(),
None,
None,
None,
false,
None,
);
if pids_listening_on(port).is_empty() {
drop(listener);
return;
}
let result = host.free_port(port, "test");
assert!(
result.is_err(),
"a holder this invocation cannot attribute to itself must never be killed silently"
);
assert!(
TcpListener::bind(("127.0.0.1", port)).is_err(),
"port should still be held after a refused free_port"
);
drop(listener);
}
#[test]
fn a_failed_pwa_build_aborts_bring_up() {
let dir = std::env::temp_dir();
let result = run_pwa_build_command(&dir, "false", &[], None);
assert!(result.is_err(), "a failed PWA build must abort, not warn");
let msg = format!("{:#}", result.unwrap_err());
assert!(msg.contains("PWA"), "error must name the PWA build: {msg}");
}
#[test]
fn a_successful_pwa_build_command_returns_ok() {
let dir = std::env::temp_dir();
run_pwa_build_command(&dir, "true", &[], None).expect("`true` always succeeds");
}
#[test]
fn pwa_package_json_presence_detects_absence() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(!pwa_package_json_exists(dir.path()));
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
assert!(pwa_package_json_exists(dir.path()));
}
}