#![allow(dead_code, clippy::missing_const_for_fn)]
use crate::paths::RuntimePaths;
use crate::process::{self, AcquireError};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
pub const MANAGED_PG_DATA_DIR_ENV: &str = "AUTUMN_MANAGED_PG_DATA_DIR";
pub const MANAGED_PG_ATTACH_URL_ENV: &str = "AUTUMN_MANAGED_PG_ATTACH_URL";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServeAction {
Stop,
Status,
Restart,
}
#[derive(Debug, Clone)]
pub struct ServeOptions {
pub package: Option<String>,
pub daemon: bool,
pub release: bool,
pub bundled_pg: bool,
pub profile: Option<String>,
pub role: Option<String>,
}
const READY_TIMEOUT: Duration = Duration::from_secs(90);
const READY_TIMEOUT_MANAGED_PG: Duration = Duration::from_secs(300);
const STOP_GRACE_BUFFER: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AddrFile {
pub pid: u32,
pub transport: String,
pub address: String,
pub started_at: u64,
pub managed_pg: bool,
#[serde(default)]
pub release: bool,
#[serde(default)]
pub stop_budget_secs: Option<u64>,
#[serde(default)]
pub profile: Option<String>,
}
impl AddrFile {
#[must_use]
pub fn to_toml(&self) -> String {
toml::to_string(self).expect("AddrFile serializes to TOML")
}
pub fn parse(s: &str) -> Result<Self, toml::de::Error> {
toml::from_str(s)
}
}
pub fn run(action: Option<ServeAction>, opts: &ServeOptions) {
#[cfg(unix)]
let code = run_unix(action, opts);
#[cfg(not(unix))]
let code = run_non_unix(action, opts);
std::process::exit(code);
}
#[cfg(not(unix))]
fn run_non_unix(action: Option<ServeAction>, opts: &ServeOptions) -> i32 {
if action.is_some() || opts.daemon {
eprintln!(
"autumn serve: daemon mode (--daemon, stop, status, restart) is \
currently supported on Unix only (Linux/macOS). Plain `autumn serve` \
runs the app in the foreground on this platform."
);
return 1;
}
start_foreground(opts)
}
#[cfg(unix)]
fn run_unix(action: Option<ServeAction>, opts: &ServeOptions) -> i32 {
match action {
None => start(opts),
Some(ServeAction::Stop) => stop(opts),
Some(ServeAction::Status) => status(opts),
Some(ServeAction::Restart) => {
let running = running_daemon_addr(opts.package.as_deref());
let keep_managed = opts.bundled_pg
|| running.as_ref().map_or_else(
|| managed_cluster_present(opts.package.as_deref()),
|a| a.managed_pg,
);
let recorded_mode = running_daemon_mode(opts.package.as_deref());
let keep_release = opts.release
|| running.as_ref().map_or_else(
|| recorded_mode.as_ref().is_some_and(|m| m.release),
|a| a.release,
);
let keep_profile = env_profile().or_else(|| {
running.as_ref().map_or_else(
|| recorded_mode.as_ref().and_then(|m| m.profile.clone()),
|a| a.profile.clone(),
)
});
let keep_role = opts
.role
.clone()
.or_else(|| recorded_mode.as_ref().and_then(|m| m.role.clone()));
let daemon_opts = ServeOptions {
daemon: true,
bundled_pg: keep_managed,
release: keep_release,
profile: keep_profile,
role: keep_role,
..opts.clone()
};
let _ = stop(&daemon_opts);
start(&daemon_opts)
}
}
}
fn read_addr_file(paths: &RuntimePaths) -> Option<AddrFile> {
std::fs::read_to_string(paths.addr_file())
.ok()
.and_then(|s| AddrFile::parse(&s).ok())
}
fn running_daemon_addr(package: Option<&str>) -> Option<AddrFile> {
let paths = RuntimePaths::resolve(&project_identity(package)).ok()?;
read_addr_file(&paths)
}
fn managed_cluster_present(package: Option<&str>) -> bool {
RuntimePaths::resolve(&project_identity(package))
.is_ok_and(|p| p.pg_data_dir().join("PG_VERSION").exists())
}
#[cfg(unix)]
fn socket_is_live(path: &Path) -> bool {
std::os::unix::net::UnixStream::connect(path).is_ok()
}
#[cfg(not(unix))]
fn socket_is_live(_path: &Path) -> bool {
false
}
#[cfg(target_os = "linux")]
fn socket_owner_pid(path: &Path) -> Option<u32> {
let stream = std::os::unix::net::UnixStream::connect(path).ok()?;
let cred =
nix::sys::socket::getsockopt(&stream, nix::sys::socket::sockopt::PeerCredentials).ok()?;
u32::try_from(cred.pid()).ok()
}
#[cfg(not(target_os = "linux"))]
fn socket_owner_pid(_path: &Path) -> Option<u32> {
None
}
fn socket_identity_matches(socket: &Path, pid: u32) -> bool {
socket_owner_pid(socket).map_or_else(|| socket_is_live(socket), |owner| owner == pid)
}
fn confirmed_running(rec: &process::PidRecord, socket: &Path, startup_in_progress: bool) -> bool {
if !process::is_process_alive(rec.pid) {
return false;
}
match (rec.start_time, process::process_start_time(rec.pid)) {
(Some(recorded), Some(current)) => recorded == current,
_ => startup_in_progress || socket_identity_matches(socket, rec.pid),
}
}
fn lifecycle_target(paths: &RuntimePaths, socket: &Path) -> Option<process::PidRecord> {
let pidfile_rec = process::read_pidfile(&paths.pid_file());
if let Some(owner) = socket_owner_pid(socket) {
if let Some(rec) = pidfile_rec
&& rec.pid == owner
{
return Some(rec);
}
if pidfile_rec.is_some() || read_addr_file(paths).is_some() {
return Some(process::PidRecord {
pid: owner,
start_time: process::process_start_time(owner),
});
}
return None;
}
if let Some(rec) = pidfile_rec {
return Some(rec);
}
let addr = read_addr_file(paths)?;
(socket_owner_pid(socket) == Some(addr.pid)).then_some(process::PidRecord {
pid: addr.pid,
start_time: None,
})
}
fn resolve_paths(package: Option<&str>) -> RuntimePaths {
let project = project_identity(package);
RuntimePaths::resolve(&project).unwrap_or_else(|e| {
eprintln!("autumn serve: {e}");
std::process::exit(1);
})
}
fn start_lock_path(paths: &RuntimePaths) -> PathBuf {
paths.pid_file().with_file_name("serve.startlock")
}
fn startup_in_progress(paths: &RuntimePaths) -> bool {
process::read_pidfile(&start_lock_path(paths)).is_some_and(|rec| process::is_record_alive(&rec))
}
fn project_identity(package: Option<&str>) -> String {
let name = package.map_or_else(
|| {
canonical_cwd().file_name().map_or_else(
|| "autumn-app".to_owned(),
|n| n.to_string_lossy().into_owned(),
)
},
ToOwned::to_owned,
);
format!("{name}-{}", project_dir_hash(package))
}
fn project_dir_hash(package: Option<&str>) -> String {
use sha2::{Digest, Sha256};
let key = package.map_or_else(
|| canonical_cwd().display().to_string(),
|pkg| format!("{}\0{pkg}", identity_root().display()),
);
hex::encode(&Sha256::digest(key.as_bytes())[..4])
}
fn canonical_cwd() -> PathBuf {
std::env::current_dir()
.ok()
.and_then(|d| std::fs::canonicalize(&d).ok().or(Some(d)))
.unwrap_or_else(|| PathBuf::from("."))
}
fn identity_root() -> PathBuf {
let cwd = canonical_cwd();
workspace_anchor_from(&cwd).unwrap_or(cwd)
}
fn workspace_anchor_from(start: &Path) -> Option<PathBuf> {
let mut nearest_manifest: Option<PathBuf> = None;
let mut broken_workspace_dir: Option<PathBuf> = None;
for dir in start.ancestors() {
let Ok(contents) = std::fs::read_to_string(dir.join("Cargo.toml")) else {
continue;
};
if nearest_manifest.is_none() {
nearest_manifest = Some(dir.to_path_buf());
}
match toml::from_str::<toml::Table>(&contents) {
Ok(table) if table.contains_key("workspace") => {
return broken_workspace_dir.or_else(|| Some(dir.to_path_buf()));
}
Err(_) if contents.contains("[workspace]") => {
if broken_workspace_dir.is_none() {
broken_workspace_dir = Some(dir.to_path_buf());
}
}
Ok(_) | Err(_) => {}
}
}
broken_workspace_dir.or(nearest_manifest)
}
fn start(opts: &ServeOptions) -> i32 {
if opts.daemon {
start_daemon(opts)
} else {
start_foreground(opts)
}
}
fn start_daemon(opts: &ServeOptions) -> i32 {
let paths = resolve_paths(opts.package.as_deref());
if let Err(e) = paths.ensure_dirs() {
eprintln!("autumn serve: cannot create runtime dirs: {e}");
return 1;
}
if let Some(rec) = process::read_pidfile(&paths.pid_file())
&& confirmed_running(&rec, &paths.socket_file(), startup_in_progress(&paths))
{
eprintln!(
"autumn serve: already running (pid {}). \
Use `autumn serve stop` or `autumn serve restart`.",
rec.pid
);
return 1;
}
if opts.bundled_pg && !startup_in_progress(&paths) && !socket_is_live(&paths.socket_file()) {
reap_managed_postgres(&paths);
}
eprintln!("\u{1F342} autumn serve\n");
if !crate::dev::cargo_build(opts.package.as_deref(), opts.release) {
eprintln!("\u{2717} Build failed. Fix the errors above and retry.");
return 1;
}
let binary = crate::dev::find_binary(opts.package.as_deref(), opts.release);
spawn_daemon(&binary, &paths, opts)
}
fn start_foreground(opts: &ServeOptions) -> i32 {
eprintln!("\u{1F342} autumn serve\n");
if !crate::dev::cargo_build(opts.package.as_deref(), opts.release) {
eprintln!("\u{2717} Build failed. Fix the errors above and retry.");
return 1;
}
let binary = crate::dev::find_binary(opts.package.as_deref(), opts.release);
let paths = if opts.bundled_pg {
let paths = resolve_paths(opts.package.as_deref());
if let Err(e) = paths.ensure_dirs() {
eprintln!("autumn serve: cannot create runtime dirs: {e}");
return 1;
}
Some(paths)
} else {
None
};
run_foreground(&binary, paths.as_ref(), opts)
}
fn base_command(binary: &Path, paths: Option<&RuntimePaths>, opts: &ServeOptions) -> Command {
let mut cmd = Command::new(binary);
cmd.env_remove(MANAGED_PG_ATTACH_URL_ENV);
if let Some(pkg) = opts.package.as_deref()
&& let Some(dir) = crate::dev::find_manifest_dir(pkg)
{
cmd.current_dir(&dir);
cmd.env("AUTUMN_MANIFEST_DIR", &dir);
}
#[cfg(unix)]
if opts.daemon
&& let Some(paths) = paths
{
cmd.env("AUTUMN_SERVER__UNIX_SOCKET", paths.socket_file());
cmd.env("AUTUMN_SERVE_FORCE_UNIX_SOCKET", paths.socket_file());
cmd.env("AUTUMN_SERVE_READY_FILE", paths.ready_file());
}
if opts.bundled_pg
&& let Some(paths) = paths
{
cmd.env(MANAGED_PG_DATA_DIR_ENV, paths.pg_data_dir());
}
if let Some(role) = &opts.role {
cmd.env("AUTUMN_ROLE", role);
}
if let Some(profile) = &opts.profile {
cmd.env("AUTUMN_ENV", profile);
} else if opts.release && env_profile().is_none() {
cmd.env("AUTUMN_ENV", "prod");
}
cmd
}
fn run_foreground(binary: &Path, paths: Option<&RuntimePaths>, opts: &ServeOptions) -> i32 {
let mut cmd = base_command(binary, paths, opts);
eprintln!(" Running {} in the foreground", binary.display());
eprintln!(" Press Ctrl+C to stop\n");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = cmd.exec();
eprintln!("\u{2717} Failed to start {}: {err}", binary.display());
1
}
#[cfg(not(unix))]
{
match cmd.status() {
Ok(status) => status.code().unwrap_or(1),
Err(e) => {
eprintln!("\u{2717} Failed to start {}: {e}", binary.display());
1
}
}
}
}
fn create_private_log(path: &Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
Ok(file)
}
fn launch_daemon_child(
binary: &Path,
paths: &RuntimePaths,
opts: &ServeOptions,
socket: &Path,
) -> Result<(std::process::Child, PathBuf), i32> {
#[cfg(unix)]
if std::os::unix::net::UnixStream::connect(socket).is_ok() {
eprintln!(
"autumn serve: already running (a live server owns {}). \
Use `autumn serve stop` or `autumn serve restart`.",
socket.display()
);
return Err(1);
}
let start_lock = start_lock_path(paths);
match process::acquire_pidfile(&start_lock, std::process::id()) {
Ok(()) => {}
Err(AcquireError::AlreadyRunning(existing)) => {
eprintln!("autumn serve: another `autumn serve` is starting (pid {existing}).");
return Err(1);
}
Err(AcquireError::Io(e)) => {
eprintln!("autumn serve: cannot take startup lock: {e}");
return Err(1);
}
}
let log = match create_private_log(&paths.log_file()) {
Ok(f) => f,
Err(e) => {
eprintln!(
"autumn serve: cannot open log file {}: {e}",
paths.log_file().display()
);
let _ = std::fs::remove_file(&start_lock);
cleanup(paths, socket);
return Err(1);
}
};
let Ok(log_err) = log.try_clone() else {
eprintln!("autumn serve: cannot duplicate log handle");
let _ = std::fs::remove_file(&start_lock);
cleanup(paths, socket);
return Err(1);
};
let _ = std::fs::remove_file(paths.ready_file());
let mut cmd = base_command(binary, Some(paths), opts);
cmd.stdin(std::process::Stdio::null())
.stdout(log)
.stderr(log_err);
detach(&mut cmd);
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => {
eprintln!("\u{2717} Failed to start daemon: {e}");
let _ = std::fs::remove_file(&start_lock);
cleanup(paths, socket);
return Err(1);
}
};
let pid = child.id();
#[cfg(unix)]
if process::read_pidfile(&paths.pid_file()).is_some_and(|r| r.start_time.is_none()) {
let _ = std::fs::remove_file(paths.pid_file());
}
if let Err(e) = process::acquire_pidfile(&paths.pid_file(), pid) {
match e {
AcquireError::AlreadyRunning(existing) => {
eprintln!("autumn serve: already running (pid {existing}).");
}
AcquireError::Io(e) => eprintln!("autumn serve: cannot write pidfile: {e}"),
}
let _ = child.kill();
process::force_kill_group(pid);
let _ = child.wait();
let _ = std::fs::remove_file(&start_lock);
cleanup(paths, socket);
return Err(1);
}
Ok((child, start_lock))
}
fn spawn_daemon(binary: &Path, paths: &RuntimePaths, opts: &ServeOptions) -> i32 {
let socket = paths.socket_file();
let (mut child, start_lock) = match launch_daemon_child(binary, paths, opts, &socket) {
Ok(launched) => launched,
Err(code) => return code,
};
let pid = child.id();
let log_path = paths.log_file();
let release_lock = || {
let _ = std::fs::remove_file(&start_lock);
};
let ready_timeout = if opts.bundled_pg {
READY_TIMEOUT_MANAGED_PG
} else {
READY_TIMEOUT
};
if wait_for_ready(&paths.ready_file(), &mut child, ready_timeout) {
if let Err(e) = write_addr_file(paths, pid, &socket, opts) {
eprintln!(
"autumn serve: cannot write address file {}: {e}",
paths.addr_file().display()
);
let _ = child.kill();
process::force_kill_group(pid);
let _ = child.wait();
if opts.bundled_pg {
reap_managed_postgres(paths);
}
release_lock();
cleanup(paths, &socket);
return 1;
}
release_lock();
println!(
"autumn serve: started (pid {pid}) on unix:{}",
socket.display()
);
println!(" address file: {}", paths.addr_file().display());
println!(" logs: {}", log_path.display());
drop(child);
0
} else {
eprintln!(
"autumn serve: daemon did not become ready within {}s; see {}",
ready_timeout.as_secs(),
log_path.display()
);
let _ = child.kill();
process::force_kill_group(pid);
let _ = child.wait();
if opts.bundled_pg {
reap_managed_postgres(paths);
}
release_lock();
cleanup(paths, &socket);
1
}
}
#[cfg(unix)]
fn detach(cmd: &mut Command) {
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
fn detach(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW);
}
#[cfg(not(any(unix, windows)))]
fn detach(_cmd: &mut Command) {}
fn wait_for_ready(ready_file: &Path, child: &mut std::process::Child, timeout: Duration) -> bool {
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) | Err(_) => return false,
Ok(None) => {}
}
if ready_file.exists() {
return true;
}
if start.elapsed() >= timeout {
return false;
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn child_reported_budget(paths: &RuntimePaths) -> Option<u64> {
std::fs::read_to_string(paths.ready_file())
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
}
fn write_addr_file(
paths: &RuntimePaths,
pid: u32,
socket: &Path,
opts: &ServeOptions,
) -> std::io::Result<()> {
let addr = AddrFile {
pid,
transport: "unix".to_owned(),
address: socket.display().to_string(),
started_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
managed_pg: opts.bundled_pg,
release: opts.release,
stop_budget_secs: Some(
child_reported_budget(paths).unwrap_or_else(|| resolved_stop_budget_secs(opts)),
),
profile: opts.profile.clone().or_else(env_profile),
};
let path = paths.addr_file();
std::fs::write(&path, addr.to_toml())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
write_mode_file(paths, opts);
Ok(())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ModeFile {
#[serde(default)]
release: bool,
#[serde(default)]
profile: Option<String>,
#[serde(default)]
role: Option<String>,
}
fn write_mode_file(paths: &RuntimePaths, opts: &ServeOptions) {
let mode = ModeFile {
release: opts.release,
profile: opts.profile.clone().or_else(env_profile),
role: effective_role_from(opts.role.clone(), std::env::var("AUTUMN_ROLE").ok()),
};
let Ok(toml) = toml::to_string(&mode) else {
return;
};
let path = paths.mode_file();
if std::fs::write(&path, toml).is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
}
}
fn running_daemon_mode(package: Option<&str>) -> Option<ModeFile> {
let paths = RuntimePaths::resolve(&project_identity(package)).ok()?;
let contents = std::fs::read_to_string(paths.mode_file()).ok()?;
toml::from_str(&contents).ok()
}
fn stop(opts: &ServeOptions) -> i32 {
let paths = resolve_paths(opts.package.as_deref());
let socket = paths.socket_file();
let Some(rec) = lifecycle_target(&paths, &socket) else {
println!("autumn serve: not running");
return 0;
};
let addr = read_addr_file(&paths);
if !confirmed_running(&rec, &socket, startup_in_progress(&paths)) {
if process::read_pidfile(&paths.pid_file()).is_some_and(|r| r.pid != rec.pid)
|| startup_in_progress(&paths)
|| socket_is_live(&socket)
{
println!("autumn serve: not running (a newer daemon has since started)");
return 0;
}
if addr.as_ref().is_none_or(|a| a.managed_pg) {
reap_managed_postgres(&paths);
}
println!("autumn serve: not running (removed stale files)");
cleanup(&paths, &socket);
return 0;
}
let recorded_release = addr.as_ref().is_some_and(|a| a.release);
let recorded_budget = addr.as_ref().and_then(|a| a.stop_budget_secs);
if !process::stop_record(
&rec,
stop_timeout(opts, recorded_release, recorded_budget),
&socket,
) {
eprintln!(
"autumn serve: could not stop the daemon (pid {}); it may be owned by \
another user or unresponsive. Leaving its state in place.",
rec.pid
);
return 1;
}
if process::read_pidfile(&paths.pid_file()).is_some_and(|r| r.pid != rec.pid) {
println!("autumn serve: stopped (a newer daemon has since started)");
return 0;
}
if addr.as_ref().is_none_or(|a| a.managed_pg) {
reap_managed_postgres(&paths);
}
cleanup(&paths, &socket);
println!("autumn serve: stopped");
0
}
fn live_postmaster_pid(data_dir: &Path) -> Option<u32> {
let pidfile = data_dir.join("postmaster.pid");
let pid = std::fs::read_to_string(&pidfile)
.ok()
.and_then(|s| s.lines().next()?.trim().parse::<u32>().ok())?;
if !process::is_process_alive(pid) {
return None;
}
if process::process_command_name(pid).is_some_and(|name| name != "postgres") {
return None;
}
if let Some(cwd) = process::process_cwd(pid) {
let same_dir = std::fs::canonicalize(&cwd)
.ok()
.zip(std::fs::canonicalize(data_dir).ok())
.is_some_and(|(live, ours)| live == ours);
if !same_dir {
return None;
}
}
Some(pid)
}
fn reap_managed_postgres(paths: &RuntimePaths) {
let data_dir = paths.pg_data_dir();
let url_file = data_dir
.parent()
.unwrap_or(&data_dir)
.join(".autumn-pg-url");
let _ = std::fs::remove_file(&url_file);
let Some(pid) = live_postmaster_pid(&data_dir) else {
return;
};
process::stop_postmaster(pid, Duration::from_secs(10));
}
pub struct ManagedPgEnv {
pub data_dir: PathBuf,
pub attach_url: Option<String>,
}
#[must_use]
pub fn managed_pg_env(package: Option<&str>) -> Option<ManagedPgEnv> {
if std::env::var_os(MANAGED_PG_DATA_DIR_ENV).is_some() {
return None;
}
let paths = RuntimePaths::resolve(&project_identity(package)).ok()?;
let data_dir = paths.pg_data_dir();
if live_postmaster_pid(&data_dir).is_some() {
return reachable_published_url(&data_dir).map(|url| ManagedPgEnv {
data_dir,
attach_url: Some(url),
});
}
if startup_in_progress(&paths) {
return None;
}
Some(ManagedPgEnv {
data_dir,
attach_url: None,
})
}
fn reachable_published_url(data_dir: &Path) -> Option<String> {
let url_file = data_dir.parent().unwrap_or(data_dir).join(".autumn-pg-url");
let contents = std::fs::read_to_string(url_file).ok()?;
let url = contents.trim();
if url.is_empty() || !published_url_reachable(url) {
return None;
}
Some(url.to_owned())
}
fn published_url_reachable(url: &str) -> bool {
use std::net::ToSocketAddrs;
let Some(hostport) = pg_url_host_port(url) else {
return false;
};
let Ok(addrs) = hostport.to_socket_addrs() else {
return false;
};
addrs
.take(4)
.any(|sa| std::net::TcpStream::connect_timeout(&sa, Duration::from_millis(500)).is_ok())
}
fn pg_url_host_port(url: &str) -> Option<String> {
let after_scheme = url.split_once("://")?.1;
let authority = after_scheme.split(['/', '?']).next()?;
let hostport = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
let (_, port) = hostport.rsplit_once(':')?;
if port.parse::<u16>().is_err() {
return None;
}
Some(hostport.to_owned())
}
fn stop_timeout(
opts: &ServeOptions,
recorded_release: bool,
recorded_budget: Option<u64>,
) -> Duration {
if let Some(secs) = recorded_budget {
return Duration::from_secs(secs) + STOP_GRACE_BUFFER;
}
let base_dir = opts
.package
.as_deref()
.and_then(crate::dev::find_manifest_dir)
.unwrap_or_else(|| std::path::PathBuf::from("."));
let profile = effective_profile(None, recorded_release);
let (prestop, shutdown) = resolve_shutdown_budget(&base_dir, Some(&profile));
Duration::from_secs(prestop + shutdown) + STOP_GRACE_BUFFER
}
fn resolved_stop_budget_secs(opts: &ServeOptions) -> u64 {
let base_dir = opts
.package
.as_deref()
.and_then(crate::dev::find_manifest_dir)
.unwrap_or_else(|| std::path::PathBuf::from("."));
let profile = effective_profile(opts.profile.as_deref(), opts.release);
let (prestop, shutdown) = resolve_shutdown_budget(&base_dir, Some(&profile));
prestop + shutdown
}
fn effective_profile(profile_override: Option<&str>, release: bool) -> String {
profile_override
.map(ToOwned::to_owned)
.or_else(env_profile)
.unwrap_or_else(|| {
if release {
"prod".to_owned()
} else {
"dev".to_owned()
}
})
}
fn env_profile() -> Option<String> {
std::env::var("AUTUMN_ENV")
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
std::env::var("AUTUMN_PROFILE")
.ok()
.filter(|s| !s.trim().is_empty())
})
}
fn effective_role_from(explicit: Option<String>, env_value: Option<String>) -> Option<String> {
explicit.or_else(|| {
env_value
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty())
})
}
fn resolve_shutdown_budget(base_dir: &Path, profile: Option<&str>) -> (u64, u64) {
let mut prestop = 5u64;
let mut shutdown = 30u64;
if let Ok(contents) = std::fs::read_to_string(base_dir.join("autumn.toml"))
&& let Ok(table) = toml::from_str::<toml::Table>(&contents)
{
apply_server_slice(table.get("server"), &mut prestop, &mut shutdown);
if let Some(prof) = profile {
for name in profile_aliases(prof) {
let section = table
.get("profile")
.and_then(|p| p.get(name.as_str()))
.and_then(|p| p.get("server"));
apply_server_slice(section, &mut prestop, &mut shutdown);
}
}
}
if let Some(prof) = profile {
for name in profile_file_lookup(prof) {
if let Ok(contents) =
std::fs::read_to_string(base_dir.join(format!("autumn-{name}.toml")))
&& let Ok(table) = toml::from_str::<toml::Table>(&contents)
{
apply_server_slice(table.get("server"), &mut prestop, &mut shutdown);
break;
}
}
}
if let Some(v) = env_u64("AUTUMN_SERVER__PRESTOP_GRACE_SECS") {
prestop = v;
}
if let Some(v) = env_u64("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS") {
shutdown = v;
}
(prestop, shutdown)
}
fn apply_server_slice(server: Option<&toml::Value>, prestop: &mut u64, shutdown: &mut u64) {
let Some(server) = server else { return };
if let Some(v) = server
.get("prestop_grace_secs")
.and_then(toml::Value::as_integer)
.and_then(|v| u64::try_from(v).ok())
{
*prestop = v;
}
if let Some(v) = server
.get("shutdown_timeout_secs")
.and_then(toml::Value::as_integer)
.and_then(|v| u64::try_from(v).ok())
{
*shutdown = v;
}
}
fn profile_aliases(profile: &str) -> Vec<String> {
match profile.trim().to_ascii_lowercase().as_str() {
"prod" | "production" => vec!["production".to_owned(), "prod".to_owned()],
"dev" | "development" => vec!["development".to_owned(), "dev".to_owned()],
_ => vec![profile.trim().to_owned()],
}
}
fn profile_file_lookup(raw_profile: &str) -> Vec<String> {
let raw = raw_profile.trim();
match raw.to_ascii_lowercase().as_str() {
"production" => vec!["production".to_owned(), "prod".to_owned()],
"prod" => vec!["prod".to_owned(), "production".to_owned()],
"development" => vec!["development".to_owned(), "dev".to_owned()],
"dev" => vec!["dev".to_owned(), "development".to_owned()],
_ => vec![raw.to_owned()],
}
}
fn env_u64(key: &str) -> Option<u64> {
std::env::var(key).ok()?.trim().parse::<u64>().ok()
}
fn status(opts: &ServeOptions) -> i32 {
let paths = resolve_paths(opts.package.as_deref());
let socket = paths.socket_file();
let Some(rec) = lifecycle_target(&paths, &socket) else {
println!("autumn serve: stopped");
return 3;
};
if confirmed_running(&rec, &socket, startup_in_progress(&paths)) {
let address =
read_addr_file(&paths).map_or_else(|| socket.display().to_string(), |a| a.address);
println!("autumn serve: running (pid {}) on unix:{address}", rec.pid);
0
} else {
println!(
"autumn serve: stopped (stale pidfile at {})",
paths.pid_file().display()
);
3
}
}
fn cleanup(paths: &RuntimePaths, socket: &Path) {
let _ = std::fs::remove_file(paths.pid_file());
let _ = std::fs::remove_file(paths.addr_file());
let _ = std::fs::remove_file(paths.mode_file());
let _ = std::fs::remove_file(paths.ready_file());
remove_socket_if_not_live(socket);
}
#[cfg(unix)]
fn remove_socket_if_not_live(socket: &Path) {
use std::os::unix::fs::FileTypeExt;
let Ok(meta) = std::fs::symlink_metadata(socket) else {
return;
};
if !meta.file_type().is_socket() {
return;
}
if std::os::unix::net::UnixStream::connect(socket).is_err_and(|e| {
matches!(
e.kind(),
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
)
}) {
let _ = std::fs::remove_file(socket);
}
}
#[cfg(not(unix))]
fn remove_socket_if_not_live(_socket: &Path) {}
#[cfg(test)]
mod tests {
use super::*;
fn sample(transport: &str, address: &str, managed_pg: bool) -> AddrFile {
AddrFile {
pid: 4242,
transport: transport.to_owned(),
address: address.to_owned(),
started_at: 1_700_000_000,
managed_pg,
release: false,
stop_budget_secs: Some(35),
profile: Some("dev".to_owned()),
}
}
#[test]
fn addr_file_release_defaults_false_for_legacy_files() {
let legacy = "pid = 7\ntransport = \"unix\"\naddress = \"/tmp/s.sock\"\n\
started_at = 1700000000\nmanaged_pg = false\n";
let parsed = AddrFile::parse(legacy).expect("parse legacy addr file");
assert!(!parsed.release);
}
#[test]
fn addr_file_release_roundtrips() {
let mut a = sample("unix", "/tmp/s.sock", true);
a.release = true;
let parsed = AddrFile::parse(&a.to_toml()).expect("parse");
assert!(parsed.release);
}
#[test]
fn serialize_addr_file_unix_roundtrips() {
let a = sample("unix", "/run/user/1000/autumn/demo/serve.sock", false);
let parsed = AddrFile::parse(&a.to_toml()).expect("parse");
assert_eq!(a, parsed);
}
#[test]
fn serialize_addr_file_tcp_roundtrips() {
let a = sample("tcp", "127.0.0.1:3000", true);
let parsed = AddrFile::parse(&a.to_toml()).expect("parse");
assert_eq!(a, parsed);
}
#[test]
fn addr_file_is_valid_toml() {
let a = sample("unix", "/tmp/x.sock", false);
let table: toml::Table = toml::from_str(&a.to_toml()).expect("valid toml");
for key in [
"pid",
"transport",
"address",
"started_at",
"managed_pg",
"release",
"stop_budget_secs",
"profile",
] {
assert!(table.contains_key(key), "missing key {key}");
}
}
#[test]
fn addr_file_stop_budget_defaults_none_for_legacy_files() {
let legacy = "pid = 7\ntransport = \"unix\"\naddress = \"/tmp/s.sock\"\n\
started_at = 1700000000\nmanaged_pg = false\nrelease = false\n";
let parsed = AddrFile::parse(legacy).expect("parse legacy addr file");
assert_eq!(parsed.stop_budget_secs, None);
}
#[test]
fn project_identity_prefers_explicit_package() {
let id = project_identity(Some("my-svc"));
assert!(id.starts_with("my-svc-"), "got {id}");
assert!(id.len() > "my-svc-".len());
}
#[test]
fn workspace_anchor_is_stable_from_root_and_member() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(tmp.path()).expect("canonicalize root");
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/api\"]\n",
)
.expect("write workspace manifest");
let member = root.join("crates/api");
std::fs::create_dir_all(&member).expect("create member dir");
std::fs::write(
member.join("Cargo.toml"),
"[package]\nname = \"api\"\nversion = \"0.1.0\"\n",
)
.expect("write member manifest");
assert_eq!(workspace_anchor_from(&root), Some(root.clone()));
assert_eq!(workspace_anchor_from(&member), Some(root));
}
#[test]
fn workspace_anchor_falls_back_to_nearest_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(tmp.path()).expect("canonicalize root");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"solo\"\nversion = \"0.1.0\"\n",
)
.expect("write manifest");
let nested = root.join("src");
std::fs::create_dir_all(&nested).expect("create nested dir");
assert_eq!(workspace_anchor_from(&nested), Some(root));
}
#[test]
fn pg_url_host_port_extracts_authority() {
assert_eq!(
pg_url_host_port("postgresql://postgres:secret@localhost:54213/autumn"),
Some("localhost:54213".to_owned())
);
assert_eq!(
pg_url_host_port("postgres://127.0.0.1:5432/db"),
Some("127.0.0.1:5432".to_owned())
);
assert_eq!(pg_url_host_port("postgresql://localhost/autumn"), None);
assert_eq!(pg_url_host_port("postgresql://localhost:notaport/db"), None);
assert_eq!(pg_url_host_port("/var/run/pg.sock"), None);
}
#[test]
fn workspace_anchor_ignores_unrelated_broken_parent_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(tmp.path()).expect("canonicalize root");
std::fs::write(root.join("Cargo.toml"), "this is = not [ valid toml")
.expect("write broken parent manifest");
let project = root.join("proj");
std::fs::create_dir_all(&project).expect("create project dir");
std::fs::write(
project.join("Cargo.toml"),
"[package]\nname = \"proj\"\nversion = \"0.1.0\"\n",
)
.expect("write project manifest");
assert_eq!(workspace_anchor_from(&project), Some(project));
}
#[test]
fn workspace_anchor_stable_when_root_manifest_unparsable() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(tmp.path()).expect("canonicalize root");
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\n \"crates/api\"",
)
.expect("write broken root manifest");
let member = root.join("crates/api");
std::fs::create_dir_all(&member).expect("create member dir");
std::fs::write(
member.join("Cargo.toml"),
"[package]\nname = \"api\"\nversion = \"0.1.0\"\n",
)
.expect("write member manifest");
assert_eq!(workspace_anchor_from(&member), Some(root));
}
#[test]
fn workspace_anchor_broken_root_not_pulled_up_to_outer_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
let outer = std::fs::canonicalize(tmp.path()).expect("canonicalize");
std::fs::write(
outer.join("Cargo.toml"),
"[package]\nname = \"outer\"\nversion = \"0.1.0\"\n",
)
.expect("write outer manifest");
let ws = outer.join("ws");
let member = ws.join("crates/api");
std::fs::create_dir_all(&member).expect("create dirs");
std::fs::write(
ws.join("Cargo.toml"),
"[workspace]\nmembers = [\n \"crates/api\"",
)
.expect("write broken ws manifest");
std::fs::write(
member.join("Cargo.toml"),
"[package]\nname = \"api\"\nversion = \"0.1.0\"\n",
)
.expect("write member manifest");
assert_eq!(workspace_anchor_from(&member), Some(ws));
}
#[test]
fn workspace_anchor_inner_broken_root_wins_over_outer_valid_workspace() {
let tmp = tempfile::tempdir().expect("tempdir");
let outer = std::fs::canonicalize(tmp.path()).expect("canonicalize");
std::fs::write(
outer.join("Cargo.toml"),
"[workspace]\nmembers = [\"inner\"]\n",
)
.expect("write outer ws manifest");
let inner = outer.join("inner");
let member = inner.join("crates/api");
std::fs::create_dir_all(&member).expect("create dirs");
std::fs::write(
inner.join("Cargo.toml"),
"[workspace]\nmembers = [\n \"crates/api\"",
)
.expect("write broken inner ws manifest");
std::fs::write(
member.join("Cargo.toml"),
"[package]\nname = \"api\"\nversion = \"0.1.0\"\n",
)
.expect("write member manifest");
assert_eq!(workspace_anchor_from(&member), Some(inner));
}
const SHUTDOWN_ENV: [&str; 4] = [
"AUTUMN_ENV",
"AUTUMN_PROFILE",
"AUTUMN_SERVER__PRESTOP_GRACE_SECS",
"AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS",
];
fn clear_shutdown_env() -> Vec<(&'static str, Option<&'static str>)> {
SHUTDOWN_ENV.iter().map(|k| (*k, None)).collect()
}
#[test]
fn shutdown_budget_defaults_when_no_config() {
let dir = tempfile::tempdir().expect("tempdir");
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(resolve_shutdown_budget(dir.path(), None), (5, 30));
});
}
#[test]
fn shutdown_budget_reads_base_server_section() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("autumn.toml"),
"[server]\nprestop_grace_secs = 10\nshutdown_timeout_secs = 100\n",
)
.expect("write");
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(resolve_shutdown_budget(dir.path(), None), (10, 100));
});
}
#[test]
fn shutdown_budget_env_override_wins() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("autumn.toml"),
"[server]\nprestop_grace_secs = 10\nshutdown_timeout_secs = 100\n",
)
.expect("write");
let mut vars = clear_shutdown_env();
vars.push(("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", Some("7")));
temp_env::with_vars(vars, || {
assert_eq!(resolve_shutdown_budget(dir.path(), None), (10, 7));
});
}
#[test]
fn shutdown_budget_layers_profile_file_and_inline_section() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("autumn.toml"),
"[server]\nprestop_grace_secs = 10\nshutdown_timeout_secs = 100\n\
[profile.prod.server]\nprestop_grace_secs = 15\n",
)
.expect("write");
std::fs::write(
dir.path().join("autumn-prod.toml"),
"[server]\nshutdown_timeout_secs = 200\n",
)
.expect("write");
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(resolve_shutdown_budget(dir.path(), Some("prod")), (15, 200));
});
}
#[test]
fn profile_aliases_cover_canonical_and_custom() {
assert_eq!(profile_aliases("production"), vec!["production", "prod"]);
assert_eq!(profile_aliases("DEV"), vec!["development", "dev"]);
assert_eq!(profile_aliases("staging"), vec!["staging"]);
}
#[test]
fn profile_file_lookup_prefers_selected_spelling() {
assert_eq!(profile_file_lookup("prod"), vec!["prod", "production"]);
assert_eq!(
profile_file_lookup("production"),
vec!["production", "prod"]
);
assert_eq!(profile_file_lookup("dev"), vec!["dev", "development"]);
assert_eq!(profile_file_lookup("staging"), vec!["staging"]);
}
#[test]
fn shutdown_budget_profile_file_prefers_prod_over_production() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("autumn.toml"),
"[server]\nshutdown_timeout_secs = 30\n",
)
.expect("write");
std::fs::write(
dir.path().join("autumn-prod.toml"),
"[server]\nshutdown_timeout_secs = 111\n",
)
.expect("write");
std::fs::write(
dir.path().join("autumn-production.toml"),
"[server]\nshutdown_timeout_secs = 222\n",
)
.expect("write");
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(resolve_shutdown_budget(dir.path(), Some("prod")).1, 111);
});
}
#[test]
fn effective_profile_prefers_override_then_env_then_build_mode() {
temp_env::with_vars(
[("AUTUMN_ENV", None::<&str>), ("AUTUMN_PROFILE", None)],
|| {
assert_eq!(effective_profile(Some("staging"), false), "staging");
assert_eq!(effective_profile(None, true), "prod");
assert_eq!(effective_profile(None, false), "dev");
},
);
temp_env::with_vars(
[("AUTUMN_ENV", Some("qa")), ("AUTUMN_PROFILE", None::<&str>)],
|| {
assert_eq!(effective_profile(None, true), "qa");
assert_eq!(effective_profile(Some("explicit"), true), "explicit");
},
);
}
#[cfg(unix)]
#[test]
fn confirmed_running_false_for_dead_pid() {
let rec = process::PidRecord {
pid: 2_147_483_640,
start_time: None,
};
assert!(!confirmed_running(
&rec,
std::path::Path::new("/nonexistent/serve.sock"),
false,
));
}
#[cfg(unix)]
#[test]
fn confirmed_running_true_while_startup_in_progress() {
let rec = process::PidRecord {
pid: std::process::id(),
start_time: None,
};
assert!(confirmed_running(
&rec,
std::path::Path::new("/nonexistent/serve.sock"),
true,
));
}
#[cfg(target_os = "linux")]
#[test]
fn confirmed_running_true_for_self_via_start_time() {
let pid = std::process::id();
let rec = process::PidRecord {
pid,
start_time: process::process_start_time(pid),
};
assert!(confirmed_running(
&rec,
std::path::Path::new("/nonexistent/serve.sock"),
false,
));
}
#[test]
fn lifecycle_target_prefers_pidfile() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "p");
paths.ensure_dirs().expect("dirs");
std::fs::write(paths.pid_file(), "4242 0\n").expect("write pid");
let rec = lifecycle_target(&paths, &paths.socket_file()).expect("target");
assert_eq!(rec.pid, 4242);
}
#[test]
fn lifecycle_target_addr_fallback_requires_live_socket() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "p");
paths.ensure_dirs().expect("dirs");
let addr = sample("unix", &paths.socket_file().display().to_string(), false);
std::fs::write(paths.addr_file(), addr.to_toml()).expect("write addr");
assert!(lifecycle_target(&paths, &paths.socket_file()).is_none());
}
#[test]
fn child_reported_budget_reads_ready_file() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "p");
paths.ensure_dirs().expect("dirs");
assert_eq!(child_reported_budget(&paths), None);
std::fs::write(paths.ready_file(), "42").expect("write ready");
assert_eq!(child_reported_budget(&paths), Some(42));
std::fs::write(paths.ready_file(), "not-a-number").expect("write ready");
assert_eq!(child_reported_budget(&paths), None);
}
fn serve_opts_with_role(role: Option<&str>) -> ServeOptions {
ServeOptions {
package: None,
daemon: false,
release: false,
bundled_pg: false,
profile: None,
role: role.map(str::to_owned),
}
}
fn env_value(cmd: &Command, key: &str) -> Option<String> {
cmd.get_envs().find_map(|(k, v)| {
(k == std::ffi::OsStr::new(key))
.then(|| v.map(|v| v.to_string_lossy().into_owned()))
.flatten()
})
}
#[test]
fn base_command_forwards_role_env() {
for role in ["web", "worker", "combined"] {
let opts = serve_opts_with_role(Some(role));
let cmd = base_command(Path::new("/bin/true"), None, &opts);
assert_eq!(
env_value(&cmd, "AUTUMN_ROLE").as_deref(),
Some(role),
"serve --role {role} must forward AUTUMN_ROLE={role} to the app binary",
);
}
}
#[test]
fn base_command_omits_role_env_by_default() {
let opts = serve_opts_with_role(None);
let cmd = base_command(Path::new("/bin/true"), None, &opts);
assert!(
cmd.get_envs()
.all(|(k, _)| k != std::ffi::OsStr::new("AUTUMN_ROLE")),
"no --role must leave AUTUMN_ROLE unset for the app binary",
);
}
#[test]
fn mode_file_role_defaults_none_for_legacy_files() {
let legacy = "release = true\nprofile = \"prod\"\n";
let parsed: ModeFile = toml::from_str(legacy).expect("parse legacy mode file");
assert_eq!(parsed.role, None);
assert!(parsed.release);
assert_eq!(parsed.profile.as_deref(), Some("prod"));
}
#[test]
fn write_mode_file_persists_role_for_restart_recovery() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "p");
paths.ensure_dirs().expect("dirs");
let opts = serve_opts_with_role(Some("worker"));
write_mode_file(&paths, &opts);
let contents = std::fs::read_to_string(paths.mode_file()).expect("read mode file");
let recovered: ModeFile = toml::from_str(&contents).expect("parse mode file");
assert_eq!(
recovered.role.as_deref(),
Some("worker"),
"restart must recover the persisted --role",
);
}
#[test]
fn effective_role_prefers_flag_then_env() {
assert_eq!(
effective_role_from(Some("web".to_owned()), Some("worker".to_owned())).as_deref(),
Some("web"),
);
assert_eq!(
effective_role_from(None, Some("worker".to_owned())).as_deref(),
Some("worker"),
);
assert_eq!(
effective_role_from(None, Some(" worker ".to_owned())).as_deref(),
Some("worker"),
);
assert_eq!(effective_role_from(None, Some(" ".to_owned())), None);
assert_eq!(effective_role_from(None, None), None);
}
}