use std::io::{BufRead, BufReader, ErrorKind, Read};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use anyhow::{Context, Result, bail};
use crate::apps::{self, Apps, Env};
use crate::cli::ServeArgs;
use crate::commands::build::{self as build_cmd, LocalApp};
use crate::{project, runtime};
const DEFAULT_HOST: &str = "127.0.0.1";
const DEV_LOGIN_ADDR: &str = "127.0.0.1:9000";
const DEV_CLIENT_ID: &str = "slave-test";
const DEFAULT_PORT: u16 = 3000;
const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
struct Process {
label: String,
binary: String,
envs: Vec<(String, String)>,
}
pub fn run(args: &ServeArgs) -> Result<()> {
let root = project::find_root()?;
runtime::require(&root)?;
let env = Env::load(&root)?;
let (host, port) = resolve_address(args, &env)?;
let mcp_address = apps::connect_address(&host, port);
let Some(routing) = Apps::load(&root, &env, &mcp_address)? else {
bail!("{} is missing; the runtime routes by it", apps::CONFIG);
};
let local = build_cmd::local_apps(&root, &routing.config)?;
let profile = if args.release { "release" } else { "debug" };
let bin_dir = build_cmd::target_dir(&root)?.join(profile);
let build_hint = if args.release {
"slave build --release"
} else {
"slave build"
};
let mut processes = vec![Process {
label: runtime::DIR.into(),
binary: runtime::DIR.into(),
envs: vec![
("APP_HOST".to_owned(), host.clone()),
("APP_PORT".to_owned(), port.to_string()),
("GATEWAY_LISTEN".to_owned(), routing.listen.clone()),
],
}];
for app in local.iter().filter(|app| !app.embedded) {
processes.push(app_process(app, &mcp_address));
}
if args.dev {
let apps: Vec<&str> = routing
.config
.domains
.iter()
.filter(|d| !d.is_mcp())
.map(|d| d.app.as_str())
.collect();
let sign_in = dev_sign_in(&apps);
for process in &mut processes {
process.envs.extend(sign_in.iter().cloned());
}
processes[0]
.envs
.push(("SLAVE_DEV_LOGIN".to_owned(), "1".to_owned()));
}
let missing: Vec<&str> = processes
.iter()
.filter(|p| !binary_path(&bin_dir, &p.binary).is_file())
.map(|p| p.label.as_str())
.collect();
if !missing.is_empty() {
bail!(
"not built yet: {}; run `{build_hint} {}` first",
missing.join(", "),
missing.join(" ")
);
}
warn_if_stale(&root, &bin_dir, &processes, build_hint);
ensure_port_available(&host, port, "the MCP backend")?;
if args.dev {
let (dev_host, dev_port) = split_address(DEV_LOGIN_ADDR)?;
ensure_port_available(&dev_host, dev_port, "the dev login")?;
}
let (listen_host, listen_port) = split_address(&routing.listen)?;
ensure_port_available(&listen_host, listen_port, "the runtime")?;
for app in &local {
if app.direct || !app.embedded {
ensure_port_available(DEFAULT_HOST, app.port, &format!("app `{}`", app.name))?;
}
}
println!();
println!(" MCP backend http://{mcp_address}/mcp");
println!(" Runtime http://{}", routing.listen);
for (_, route) in routing.routes.in_matching_order() {
let target = if route.is_mcp {
"MCP backend".to_owned()
} else if local
.iter()
.any(|app| app.name == route.app && !app.embedded)
{
format!("{} (separate, {})", route.app, route.upstream)
} else {
route.app.clone()
};
println!(
" {:<36} → {target}",
routing.url(route.host.as_deref(), route.path.as_deref())
);
}
for app in local.iter().filter(|app| app.direct) {
let mode = if app.embedded {
"direct"
} else {
"direct, separate"
};
println!(
" {:<13} http://{DEFAULT_HOST}:{} ({mode})",
app.name, app.port
);
}
if args.dev {
println!(
" Dev login http://{DEV_LOGIN_ADDR} (anyone signs in as any email; development only)"
);
}
println!("\nPress Ctrl+C to stop.\n");
supervise(&root, &bin_dir, processes)
}
fn dev_sign_in(apps: &[&str]) -> Vec<(String, String)> {
let mut envs = vec![
(
"OIDC_ISSUER_URL".to_owned(),
format!("http://{DEV_LOGIN_ADDR}"),
),
("OIDC_AUDIENCES".to_owned(), DEV_CLIENT_ID.to_owned()),
];
for app in apps {
let env = crate::commands::make_app::env_prefix(app);
envs.push((format!("{env}_OIDC_CLIENT_ID"), DEV_CLIENT_ID.to_owned()));
envs.push((format!("{env}_OIDC_CLIENT_SECRET"), String::new()));
envs.push((format!("{env}_PUBLIC_URL"), String::new()));
}
envs
}
fn app_process(app: &LocalApp, mcp_address: &str) -> Process {
let envs = vec![
("PORT".to_owned(), app.port.to_string()),
(
"SLAVE_MCP_URL".to_owned(),
format!("http://{mcp_address}/mcp"),
),
(
"SLAVE_BASE_PATH".to_owned(),
app.base_path.clone().unwrap_or_default(),
),
];
Process {
label: app.name.clone(),
binary: app.name.clone(),
envs,
}
}
fn binary_path(bin_dir: &Path, name: &str) -> PathBuf {
bin_dir.join(format!("{name}{}", std::env::consts::EXE_SUFFIX))
}
fn warn_if_stale(root: &Path, bin_dir: &Path, processes: &[Process], build_hint: &str) {
let built = processes
.iter()
.filter_map(|p| modified(&binary_path(bin_dir, &p.binary)))
.min();
let mut newest = None;
for dir in ["src", "apps", "database/migrations", "Cargo.toml"] {
newest_modified(&root.join(dir), &mut newest);
}
if let (Some(built), Some(newest)) = (built, newest)
&& newest > built
{
println!(
" warning: sources changed since the last build; run `{build_hint}` to include them"
);
}
}
fn modified(path: &Path) -> Option<SystemTime> {
path.metadata().and_then(|m| m.modified()).ok()
}
fn newest_modified(path: &Path, newest: &mut Option<SystemTime>) {
let Ok(metadata) = path.metadata() else {
return;
};
if metadata.is_dir() {
if path.file_name().is_some_and(|name| name == "target") {
return;
}
if let Ok(entries) = path.read_dir() {
for entry in entries.flatten() {
newest_modified(&entry.path(), newest);
}
}
} else if let Ok(time) = metadata.modified()
&& newest.is_none_or(|current| time > current)
{
*newest = Some(time);
}
}
fn supervise(root: &Path, bin_dir: &Path, processes: Vec<Process>) -> Result<()> {
let stop = Arc::new(AtomicBool::new(false));
{
let stop = Arc::clone(&stop);
ctrlc::set_handler(move || stop.store(true, Ordering::SeqCst))
.context("failed to install the Ctrl+C handler")?;
}
let width = processes.iter().map(|p| p.label.len()).max().unwrap_or(0);
let mut children: Vec<(String, Child)> = Vec::new();
for process in processes {
let binary = bin_dir.join(format!(
"{}{}",
process.binary,
std::env::consts::EXE_SUFFIX
));
let spawned = Command::new(&binary)
.current_dir(root)
.envs(process.envs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match spawned {
Ok(child) => child,
Err(err) => {
stop_all(&mut children, Duration::ZERO);
return Err(err).with_context(|| format!("failed to start {}", binary.display()));
}
};
let prefix = format!("[{:<width$}]", process.label);
forward(child.stdout.take(), prefix.clone(), false);
forward(child.stderr.take(), prefix, true);
children.push((process.label, child));
}
loop {
if stop.load(Ordering::SeqCst) {
stop_all(&mut children, SHUTDOWN_GRACE);
return Ok(());
}
for (label, child) in &mut children {
if let Some(status) = child.try_wait()? {
let label = label.clone();
stop_all(&mut children, SHUTDOWN_GRACE);
bail!("`{label}` exited ({status}); stopped the other processes");
}
}
thread::sleep(Duration::from_millis(100));
}
}
fn forward(stream: Option<impl Read + Send + 'static>, prefix: String, to_stderr: bool) {
let Some(stream) = stream else { return };
thread::spawn(move || {
for line in BufReader::new(stream).lines().map_while(Result::ok) {
if to_stderr {
eprintln!("{prefix} {line}");
} else {
println!("{prefix} {line}");
}
}
});
}
fn stop_all(children: &mut [(String, Child)], grace: Duration) {
#[cfg(unix)]
for (_, child) in children.iter_mut() {
if matches!(child.try_wait(), Ok(None)) {
let _ = Command::new("kill")
.args(["-INT", &child.id().to_string()])
.status();
}
}
let deadline = Instant::now() + grace;
for (_, child) in children.iter_mut() {
while matches!(child.try_wait(), Ok(None)) && Instant::now() < deadline {
thread::sleep(Duration::from_millis(50));
}
let _ = child.kill();
let _ = child.wait();
}
}
fn resolve_address(args: &ServeArgs, env: &Env) -> Result<(String, u16)> {
let host = args
.host
.clone()
.or_else(|| env.get("APP_HOST"))
.unwrap_or_else(|| DEFAULT_HOST.to_owned());
let port = match (args.port, env.get("APP_PORT")) {
(Some(port), _) => port,
(None, Some(value)) => value
.trim()
.parse()
.with_context(|| format!("APP_PORT={value:?} is not a valid port"))?,
(None, None) => DEFAULT_PORT,
};
Ok((host, port))
}
fn split_address(address: &str) -> Result<(String, u16)> {
let (host, port) = address
.rsplit_once(':')
.with_context(|| format!("`{address}` must be host:port"))?;
let port = port
.parse()
.with_context(|| format!("`{address}` has an invalid port"))?;
Ok((host.trim_matches(['[', ']']).to_owned(), port))
}
fn ensure_port_available(host: &str, port: u16, what: &str) -> Result<()> {
match TcpListener::bind((host, port)) {
Ok(_) => Ok(()),
Err(err) if err.kind() == ErrorKind::AddrInUse => {
bail!("port {port} on {host} (for {what}) is already in use")
}
Err(err) => Err(err).with_context(|| format!("cannot bind to {host}:{port} for {what}")),
}
}