#![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 SERVE_READY_FILE_ENV: &str = "AUTUMN_SERVE_READY_FILE";
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>,
pub pin: Option<Vec<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(any(unix, windows))]
let code = run_lifecycle(action, opts);
#[cfg(not(any(unix, windows)))]
let code = run_without_lifecycle(action, opts);
std::process::exit(code);
}
fn daemon_unsupported_message() -> String {
format!(
"autumn serve: the background daemon lifecycle (--daemon / stop / status \
/ restart) is not supported on {}.\n Plain `autumn serve` (foreground) \
runs natively here.",
std::env::consts::OS
)
}
#[cfg(not(any(unix, windows)))]
fn run_without_lifecycle(action: Option<ServeAction>, opts: &ServeOptions) -> i32 {
if action.is_some() || opts.daemon {
eprintln!("{}", daemon_unsupported_message());
return 1;
}
start_foreground(opts)
}
#[cfg(any(unix, windows))]
fn run_lifecycle(action: Option<ServeAction>, opts: &ServeOptions) -> i32 {
match action {
None => start(opts),
Some(ServeAction::Stop) => stop(opts),
Some(ServeAction::Status) => status(opts),
Some(ServeAction::Restart) => {
if let Some(outcome) =
crate::service::restart_registered(&project_identity(opts.package.as_deref()))
{
return match outcome {
Ok(()) => 0,
Err(message) => {
eprintln!("autumn serve restart: {message}");
1
}
};
}
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 keep_pin = resolve_restart_pin(
opts.pin.clone(),
env_pin(),
recorded_mode.as_ref().and_then(|m| m.pin.clone()),
);
let daemon_opts = ServeOptions {
daemon: true,
bundled_pg: keep_managed,
release: keep_release,
profile: keep_profile,
role: keep_role,
pin: keep_pin,
..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())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DaemonEndpoint {
transport: String,
address: String,
}
const KNOWN_TRANSPORTS: [&str; 2] = ["unix", "tcp"];
const ENDPOINT_PROBE_TIMEOUT: Duration = Duration::from_millis(750);
impl DaemonEndpoint {
fn parse(line: &str) -> Option<Self> {
let (transport, address) = line.trim().split_once(' ')?;
let address = address.trim();
if address.is_empty() || !KNOWN_TRANSPORTS.contains(&transport) {
return None;
}
Some(Self {
transport: transport.to_owned(),
address: address.to_owned(),
})
}
fn is_live(&self) -> bool {
match self.transport.as_str() {
"unix" => socket_is_live(Path::new(&self.address)),
"tcp" => tcp_is_live(&self.address),
_ => false,
}
}
}
#[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
}
fn tcp_is_live(address: &str) -> bool {
use std::net::ToSocketAddrs as _;
address.to_socket_addrs().is_ok_and(|addrs| {
addrs
.into_iter()
.any(|addr| std::net::TcpStream::connect_timeout(&addr, ENDPOINT_PROBE_TIMEOUT).is_ok())
})
}
#[cfg_attr(
unix,
allow(
clippy::unnecessary_wraps,
reason = "infallible on Unix, `None` on Windows — one signature, two answers"
)
)]
fn forced_endpoint(paths: &RuntimePaths) -> Option<DaemonEndpoint> {
#[cfg(unix)]
{
Some(DaemonEndpoint {
transport: "unix".to_owned(),
address: paths.socket_file().display().to_string(),
})
}
#[cfg(not(unix))]
{
let _ = paths;
None
}
}
fn daemon_endpoint(paths: &RuntimePaths) -> Option<DaemonEndpoint> {
forced_endpoint(paths).or_else(|| {
read_addr_file(paths)
.and_then(|addr| DaemonEndpoint::parse(&format!("{} {}", addr.transport, addr.address)))
})
}
fn forced_endpoint_is_live(paths: &RuntimePaths) -> bool {
forced_endpoint(paths).is_some_and(|endpoint| endpoint.is_live())
}
#[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 endpoint_identity_matches(paths: &RuntimePaths, pid: u32) -> bool {
endpoint_owner_pid(paths).map_or_else(|| forced_endpoint_is_live(paths), |owner| owner == pid)
}
fn endpoint_owner_pid(paths: &RuntimePaths) -> Option<u32> {
let endpoint = daemon_endpoint(paths)?;
(endpoint.transport == "unix")
.then(|| socket_owner_pid(Path::new(&endpoint.address)))
.flatten()
}
fn confirmed_running(
rec: &process::PidRecord,
paths: &RuntimePaths,
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
|| endpoint_identity_matches(paths, rec.pid)
|| process::process_is_out_of_reach(rec.pid)
}
}
}
fn lifecycle_target(paths: &RuntimePaths) -> Option<process::PidRecord> {
let pidfile_rec = process::read_pidfile(&paths.pid_file());
if let Some(owner) = endpoint_owner_pid(paths) {
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)?;
(endpoint_owner_pid(paths) == 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, 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) && !forced_endpoint_is_live(&paths) {
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);
cmd.env_remove(crate::data_flow::DUMP_ENV);
cmd.env_remove(crate::agents::DUMP_ENV);
cmd.env_remove(crate::graph::DUMP_ENV);
crate::db::retention::clear_inherited_one_shot_env(&mut cmd);
crate::data_capsule::clear_inherited_one_shot_env(&mut cmd);
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);
}
if opts.daemon
&& let Some(paths) = paths
{
cmd.env(SERVE_READY_FILE_ENV, paths.ready_file());
#[cfg(unix)]
{
cmd.env("AUTUMN_SERVER__UNIX_SOCKET", paths.socket_file());
cmd.env("AUTUMN_SERVE_FORCE_UNIX_SOCKET", paths.socket_file());
}
#[cfg(not(unix))]
cmd.env(autumn_web::app::SHUTDOWN_SIGNAL_FILE_ENV, paths.stop_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(pin) = &opts.pin {
cmd.env("AUTUMN_JOBS__PIN", pin.join(","));
}
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,
working_dir: Option<&Path>,
) -> Result<(std::process::Child, PathBuf), String> {
if let Some(endpoint) = forced_endpoint(paths)
&& endpoint.is_live()
{
return Err(format!(
"autumn serve: already running (a live server owns {}:{}). \
Use `autumn serve stop` or `autumn serve restart`.",
endpoint.transport, endpoint.address
));
}
let start_lock = start_lock_path(paths);
match process::acquire_pidfile(&start_lock, std::process::id()) {
Ok(()) => {}
Err(AcquireError::AlreadyRunning(existing)) => {
return Err(format!(
"autumn serve: another `autumn serve` is starting (pid {existing})."
));
}
Err(AcquireError::Io(e)) => {
return Err(format!("autumn serve: cannot take startup lock: {e}"));
}
}
let log = match create_private_log(&paths.log_file()) {
Ok(f) => f,
Err(e) => {
let message = format!(
"autumn serve: cannot open log file {}: {e}",
paths.log_file().display()
);
let _ = std::fs::remove_file(&start_lock);
cleanup(paths, socket);
return Err(message);
}
};
let Ok(log_err) = log.try_clone() else {
let _ = std::fs::remove_file(&start_lock);
cleanup(paths, socket);
return Err("autumn serve: cannot duplicate log handle".to_owned());
};
let _ = std::fs::remove_file(paths.ready_file());
process::clear_stop_request(&paths.stop_file());
let mut cmd = base_command(binary, Some(paths), opts);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
cmd.env("AUTUMN_MANIFEST_DIR", dir);
}
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) => {
let message = format!("\u{2717} Failed to start daemon: {e}");
let _ = std::fs::remove_file(&start_lock);
cleanup(paths, socket);
return Err(message);
}
};
let pid = child.id();
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) {
let message = match e {
AcquireError::AlreadyRunning(existing) => {
format!("autumn serve: already running (pid {existing}).")
}
AcquireError::Io(e) => format!("autumn serve: cannot write pidfile: {e}"),
};
process::force_kill_group(pid);
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(&start_lock);
cleanup(paths, socket);
return Err(message);
}
Ok((child, start_lock))
}
fn spawn_daemon(binary: &Path, paths: &RuntimePaths, opts: &ServeOptions) -> i32 {
match start_supervised(binary, paths, opts, None) {
Ok(child) => {
let pid = child.id();
let where_it_serves = read_addr_file(paths).map_or_else(
|| paths.socket_file().display().to_string(),
|addr| format!("{}:{}", addr.transport, addr.address),
);
println!("autumn serve: started (pid {pid}) on {where_it_serves}");
println!(" address file: {}", paths.addr_file().display());
println!(" logs: {}", paths.log_file().display());
drop(child);
0
}
Err(message) => {
eprintln!("{message}");
1
}
}
}
#[must_use]
pub fn start_ready_timeout(bundled_pg: bool) -> Duration {
if bundled_pg {
READY_TIMEOUT_MANAGED_PG
} else {
READY_TIMEOUT
}
}
pub fn start_supervised(
binary: &Path,
paths: &RuntimePaths,
opts: &ServeOptions,
working_dir: Option<&Path>,
) -> Result<std::process::Child, String> {
let socket = paths.socket_file();
let (mut child, start_lock) = launch_daemon_child(binary, paths, opts, &socket, working_dir)?;
let pid = child.id();
let log_path = paths.log_file();
let release_lock = || {
let _ = std::fs::remove_file(&start_lock);
};
let abandon = |child: &mut std::process::Child| {
process::force_kill_group(pid);
let _ = child.kill();
let _ = child.wait();
if opts.bundled_pg {
reap_managed_postgres(paths);
}
release_lock();
cleanup(paths, &socket);
};
let ready_timeout = start_ready_timeout(opts.bundled_pg);
if !wait_for_ready(&paths.ready_file(), &mut child, ready_timeout) {
abandon(&mut child);
return Err(format!(
"autumn serve: daemon did not become ready within {}s; see {}",
ready_timeout.as_secs(),
log_path.display()
));
}
if let Err(e) = write_addr_file(paths, pid, opts) {
let message = format!(
"autumn serve: cannot write address file {}: {e}",
paths.addr_file().display()
);
abandon(&mut child);
return Err(message);
}
release_lock();
Ok(child)
}
fn stop_deadline(budget_secs: u64) -> Duration {
Duration::from_secs(budget_secs)
.saturating_add(STOP_GRACE_BUFFER)
.min(crate::service::MAX_WAIT_HINT)
}
fn drain_budget_secs(prestop: u64, shutdown: u64) -> u64 {
prestop.saturating_add(shutdown)
}
pub fn stop_child(
child: &mut std::process::Child,
paths: &RuntimePaths,
budget_secs: u64,
) -> Option<i32> {
let _ = process::create_stop_request(&paths.stop_file());
let budget = stop_deadline(budget_secs);
if process::wait_with_timeout(child, budget).is_err() {
process::force_kill_group(child.id());
let _ = child.kill();
}
child.wait().ok().and_then(|status| status.code())
}
#[must_use]
pub fn recorded_stop_budget(paths: &RuntimePaths) -> Option<u64> {
read_addr_file(paths).and_then(|addr| addr.stop_budget_secs)
}
pub fn reap_managed_postgres_for(paths: &RuntimePaths) {
reap_managed_postgres(paths);
}
pub fn cleanup_daemon_state(paths: &RuntimePaths) {
cleanup(paths, &paths.socket_file());
}
#[must_use]
pub fn project_identity_for(package: Option<&str>) -> String {
project_identity(package)
}
#[must_use]
pub fn running_daemon_summary(package: Option<&str>) -> Option<(u32, String)> {
let paths = RuntimePaths::resolve(&project_identity(package)).ok()?;
let rec = lifecycle_target(&paths)?;
if !confirmed_running(&rec, &paths, startup_in_progress(&paths)) {
return None;
}
let where_it_serves = read_addr_file(&paths)
.map(|a| format!("{}:{}", a.transport, a.address))
.or_else(|| daemon_endpoint(&paths).map(|e| format!("{}:{}", e.transport, e.address)))
.unwrap_or_else(|| "unknown".to_owned());
Some((rec.pid, where_it_serves))
}
#[must_use]
pub fn effective_role_for_record(explicit: Option<String>) -> Option<String> {
effective_role_from(explicit, std::env::var("AUTUMN_ROLE").ok())
}
#[must_use]
pub fn effective_pin_for_record(explicit: Option<Vec<String>>) -> Option<Vec<String>> {
effective_pin_from(explicit, std::env::var("AUTUMN_JOBS__PIN").ok())
}
#[must_use]
pub fn env_profile_for_record() -> Option<String> {
env_profile()
}
#[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> {
parse_ready_payload(&std::fs::read_to_string(paths.ready_file()).ok()?).0
}
fn child_reported_endpoint(paths: &RuntimePaths) -> Option<DaemonEndpoint> {
parse_ready_payload(&std::fs::read_to_string(paths.ready_file()).ok()?).1
}
fn parse_ready_payload(contents: &str) -> (Option<u64>, Option<DaemonEndpoint>) {
let mut lines = contents.lines();
let budget = lines.next().and_then(|l| l.trim().parse::<u64>().ok());
let endpoint = lines.next().and_then(DaemonEndpoint::parse);
(budget, endpoint)
}
fn write_addr_file(paths: &RuntimePaths, pid: u32, opts: &ServeOptions) -> std::io::Result<()> {
let endpoint = child_reported_endpoint(paths)
.or_else(|| forced_endpoint(paths))
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"the app did not report the address it bound; rebuild it against \
this version of autumn-web",
)
})?;
let addr = AddrFile {
pid,
transport: endpoint.transport,
address: endpoint.address,
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>,
#[serde(default)]
pin: Option<Vec<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()),
pin: effective_pin_from(opts.pin.clone(), std::env::var("AUTUMN_JOBS__PIN").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) else {
println!("autumn serve: not running");
return 0;
};
let addr = read_addr_file(&paths);
if !confirmed_running(&rec, &paths, startup_in_progress(&paths)) {
if process::read_pidfile(&paths.pid_file()).is_some_and(|r| r.pid != rec.pid)
|| startup_in_progress(&paths)
|| forced_endpoint_is_live(&paths)
{
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);
let stop_file = paths.stop_file();
#[cfg(unix)]
let request = {
let _ = &stop_file;
process::StopRequest::Signal
};
#[cfg(not(unix))]
let request = process::StopRequest::File(&stop_file);
let outcome = process::stop_record(
&rec,
stop_timeout(opts, recorded_release, recorded_budget),
&request,
);
match &outcome {
process::StopOutcome::Drained => {}
process::StopOutcome::Escalated => eprintln!(
"autumn serve: the daemon (pid {}) did not finish draining within its \
budget and was force-stopped. Its shutdown hooks may not have run.",
rec.pid
),
process::StopOutcome::Unreachable(why) => eprintln!(
"autumn serve: could not ask the daemon (pid {}) to drain ({why}), so \
it was force-stopped without draining. In-flight requests were cut \
and its shutdown hooks did not run.",
rec.pid
),
process::StopOutcome::Failed => {
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 contents = std::fs::read_to_string(data_dir.join("postmaster.pid")).ok()?;
let lock = PostmasterLock::parse(&contents)?;
if !lock.describes(data_dir) {
return None;
}
if !process::is_process_alive(lock.pid) {
return None;
}
if process::process_command_name(lock.pid).is_some_and(|name| name != "postgres") {
return None;
}
if let Some(recorded) = lock.start_epoch_secs
&& let Some(live) = process::process_start_epoch_secs(lock.pid)
&& live.abs_diff(recorded) > POSTMASTER_START_SKEW_SECS
{
return None;
}
if let Some(cwd) = process::process_cwd(lock.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(lock.pid)
}
const POSTMASTER_START_SKEW_SECS: u64 = 5;
#[derive(Debug, Clone, PartialEq, Eq)]
struct PostmasterLock {
pid: u32,
data_dir: PathBuf,
start_epoch_secs: Option<u64>,
}
impl PostmasterLock {
fn parse(contents: &str) -> Option<Self> {
let mut lines = contents.lines();
let pid = lines.next()?.trim().parse::<u32>().ok()?;
let data_dir = PathBuf::from(lines.next().unwrap_or_default().trim());
let start_epoch_secs = lines.next().and_then(|l| l.trim().parse::<u64>().ok());
Some(Self {
pid,
data_dir,
start_epoch_secs,
})
}
fn describes(&self, data_dir: &Path) -> bool {
if self.data_dir.as_os_str().is_empty() {
return true;
}
std::fs::canonicalize(&self.data_dir)
.ok()
.zip(std::fs::canonicalize(data_dir).ok())
.map_or_else(
|| self.data_dir == data_dir,
|(theirs, ours)| theirs == ours,
)
}
}
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 stop_deadline(secs);
}
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));
stop_deadline(drain_budget_secs(prestop, shutdown))
}
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));
drain_budget_secs(prestop, shutdown)
}
pub 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 parse_pin_value(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
}
fn env_pin() -> Option<Vec<String>> {
std::env::var("AUTUMN_JOBS__PIN")
.ok()
.map(|v| parse_pin_value(&v))
}
fn resolve_restart_pin(
explicit: Option<Vec<String>>,
env: Option<Vec<String>>,
recorded: Option<Vec<String>>,
) -> Option<Vec<String>> {
explicit.or(env).or(recorded)
}
fn effective_pin_from(
explicit: Option<Vec<String>>,
env_value: Option<String>,
) -> Option<Vec<String>> {
explicit.or_else(|| env_value.map(|v| parse_pin_value(&v)))
}
pub fn resolve_shutdown_budget(base_dir: &Path, profile: Option<&str>) -> (u64, u64) {
resolve_shutdown_budget_from(base_dir, profile, &|key| std::env::var(key).ok())
}
pub fn resolve_shutdown_budget_from(
base_dir: &Path,
profile: Option<&str>,
env: &dyn Fn(&str) -> Option<String>,
) -> (u64, u64) {
let (mut prestop, mut shutdown) =
autumn_web::config::profile_shutdown_defaults(profile.unwrap_or_default());
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_from(env, "AUTUMN_SERVER__PRESTOP_GRACE_SECS") {
prestop = v;
}
if let Some(v) = env_u64_from(env, "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> {
env_u64_from(&|k| std::env::var(k).ok(), key)
}
fn env_u64_from(env: &dyn Fn(&str) -> Option<String>, key: &str) -> Option<u64> {
env(key)?.trim().parse::<u64>().ok()
}
fn status(opts: &ServeOptions) -> i32 {
let paths = resolve_paths(opts.package.as_deref());
let Some(rec) = lifecycle_target(&paths) else {
println!("autumn serve: stopped");
return 3;
};
if confirmed_running(&rec, &paths, startup_in_progress(&paths)) {
let where_it_serves = read_addr_file(&paths)
.map(|a| format!("{}:{}", a.transport, a.address))
.or_else(|| daemon_endpoint(&paths).map(|e| format!("{}:{}", e.transport, e.address)))
.unwrap_or_else(|| "unknown".to_owned());
println!(
"autumn serve: running (pid {}) on {where_it_serves}",
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());
process::clear_stop_request(&paths.stop_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 {
#[test]
fn stop_deadline_adds_the_grace_buffer() {
assert_eq!(stop_deadline(30), Duration::from_secs(35));
}
#[test]
fn stop_deadline_does_not_panic_on_a_huge_budget() {
assert_eq!(stop_deadline(u64::MAX), crate::service::MAX_WAIT_HINT);
}
#[test]
fn drain_budget_saturates_instead_of_overflowing() {
assert_eq!(drain_budget_secs(5, 30), 35);
assert_eq!(drain_budget_secs(u64::MAX, 1), u64::MAX);
}
#[test]
fn ready_payload_round_trips_the_runtime_writer() {
let written = autumn_web::app::serve_ready_payload(35, "tcp 127.0.0.1:3000");
let (budget, endpoint) = parse_ready_payload(&written);
assert_eq!(budget, Some(35));
assert_eq!(
endpoint,
Some(DaemonEndpoint {
transport: "tcp".to_owned(),
address: "127.0.0.1:3000".to_owned(),
})
);
}
#[test]
fn ready_payload_with_no_endpoint_still_yields_the_budget_and_no_endpoint() {
let written = autumn_web::app::serve_ready_payload(35, "");
let (budget, endpoint) = parse_ready_payload(&written);
assert_eq!(budget, Some(35));
assert_eq!(endpoint, None);
}
#[test]
fn ready_payload_reads_a_budget_only_file_from_an_older_app() {
let (budget, endpoint) = parse_ready_payload("35\n");
assert_eq!(budget, Some(35));
assert_eq!(endpoint, None);
}
#[test]
fn ready_payload_keeps_a_socket_path_containing_spaces_intact() {
let written = autumn_web::app::serve_ready_payload(1, "unix /home/a b/serve.sock");
let (_, endpoint) = parse_ready_payload(&written);
assert_eq!(
endpoint.map(|e| e.address),
Some("/home/a b/serve.sock".to_owned())
);
}
#[test]
fn ready_payload_rejects_an_unknown_transport() {
let (_, endpoint) = parse_ready_payload("5\nquic 127.0.0.1:3000");
assert_eq!(endpoint, None);
}
#[test]
fn ready_payload_rejects_a_transport_with_no_address() {
let (_, endpoint) = parse_ready_payload("5\ntcp");
assert_eq!(endpoint, None);
let (_, endpoint) = parse_ready_payload("5\ntcp ");
assert_eq!(endpoint, None);
}
#[test]
fn ready_payload_survives_a_corrupt_budget_without_losing_the_address() {
let (budget, endpoint) = parse_ready_payload("not-a-number\ntcp 127.0.0.1:9\n");
assert_eq!(budget, None);
assert_eq!(endpoint.map(|e| e.address), Some("127.0.0.1:9".to_owned()));
}
#[test]
fn child_reported_endpoint_reads_the_ready_file() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "proj");
std::fs::create_dir_all(paths.ready_file().parent().unwrap()).expect("mkdir");
std::fs::write(
paths.ready_file(),
autumn_web::app::serve_ready_payload(12, "tcp 127.0.0.1:4321"),
)
.expect("write ready file");
assert_eq!(
child_reported_endpoint(&paths).map(|e| e.address),
Some("127.0.0.1:4321".to_owned())
);
assert_eq!(child_reported_budget(&paths), Some(12));
}
#[test]
fn a_bound_tcp_port_reads_as_live_and_a_closed_one_does_not() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr").to_string();
assert!(tcp_is_live(&addr), "a bound port must read as live");
drop(listener);
assert!(!tcp_is_live(&addr), "a closed port must not read as live");
}
#[test]
fn an_unresolvable_address_is_not_live_rather_than_hanging() {
assert!(!tcp_is_live("not a host:port"));
assert!(!tcp_is_live(""));
}
#[test]
fn an_endpoint_probes_the_transport_it_names() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr").to_string();
assert!(
DaemonEndpoint {
transport: "tcp".to_owned(),
address: addr.clone(),
}
.is_live()
);
assert!(
!DaemonEndpoint {
transport: "unix".to_owned(),
address: addr,
}
.is_live()
);
}
#[test]
fn a_postmaster_lock_reads_pid_data_dir_and_start_time() {
let lock = PostmasterLock::parse("4242\n/var/lib/pg\n1750000000\n5432\n").expect("parse");
assert_eq!(lock.pid, 4242);
assert_eq!(lock.data_dir, Path::new("/var/lib/pg"));
assert_eq!(lock.start_epoch_secs, Some(1_750_000_000));
}
#[test]
fn a_postmaster_lock_without_a_pid_is_unusable() {
assert_eq!(PostmasterLock::parse(""), None);
assert_eq!(PostmasterLock::parse("not-a-pid\n/var/lib/pg\n"), None);
}
#[test]
fn a_truncated_postmaster_lock_still_yields_its_pid() {
let lock = PostmasterLock::parse("4242\n").expect("parse");
assert_eq!(lock.pid, 4242);
assert_eq!(lock.start_epoch_secs, None);
assert!(
lock.describes(Path::new("/anywhere")),
"no data dir recorded"
);
}
#[test]
fn a_postmaster_lock_for_another_cluster_is_rejected() {
let lock = PostmasterLock::parse("4242\n/some/other/cluster\n1750000000\n").expect("parse");
assert!(!lock.describes(Path::new("/var/lib/pg")));
}
#[test]
fn a_postmaster_lock_matches_its_own_dir_through_a_symlink() {
let dir = tempfile::tempdir().expect("tempdir");
let real = dir.path().join("pg");
std::fs::create_dir(&real).expect("mkdir");
let recorded = std::fs::canonicalize(&real).expect("canonicalize");
let lock = PostmasterLock::parse(&format!("4242\n{}\n1750000000\n", recorded.display()))
.expect("parse");
assert!(lock.describes(&real));
}
#[test]
fn a_daemon_start_hands_the_child_its_readiness_file() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "proj");
let opts = ServeOptions {
daemon: true,
..serve_opts_with_role(None)
};
let cmd = base_command(Path::new("/bin/true"), Some(&paths), &opts);
assert_eq!(
env_value(&cmd, SERVE_READY_FILE_ENV),
Some(paths.ready_file().display().to_string())
);
}
#[test]
fn a_foreground_start_hands_the_child_no_daemon_wiring() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "proj");
let opts = serve_opts_with_role(None);
let cmd = base_command(Path::new("/bin/true"), Some(&paths), &opts);
assert_eq!(env_value(&cmd, SERVE_READY_FILE_ENV), None);
assert_eq!(
env_value(&cmd, autumn_web::app::SHUTDOWN_SIGNAL_FILE_ENV),
None
);
}
#[cfg(not(unix))]
#[test]
fn a_daemon_start_hands_the_child_its_cooperative_stop_file() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "proj");
let opts = ServeOptions {
daemon: true,
..serve_opts_with_role(None)
};
let cmd = base_command(Path::new("app.exe"), Some(&paths), &opts);
assert_eq!(
env_value(&cmd, autumn_web::app::SHUTDOWN_SIGNAL_FILE_ENV),
Some(paths.stop_file().display().to_string())
);
}
#[test]
fn cleanup_removes_the_stop_request() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "proj");
std::fs::create_dir_all(paths.stop_file().parent().unwrap()).expect("mkdir");
std::fs::write(paths.stop_file(), "").expect("seed stop request");
cleanup(&paths, &paths.socket_file());
assert!(!paths.stop_file().exists());
}
#[test]
fn daemon_refusal_names_the_platform_and_the_native_alternative() {
let message = daemon_unsupported_message();
assert!(message.contains(std::env::consts::OS), "{message}");
assert!(
message.contains("autumn serve") && message.contains("foreground"),
"{message}"
);
}
#[test]
fn daemon_refusal_does_not_send_a_windows_developer_to_wsl2() {
let message = daemon_unsupported_message();
assert!(!message.contains("WSL2"), "{message}");
assert!(!message.contains("Tier 2"), "{message}");
}
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_dev_profile_uses_dev_smart_defaults() {
let dir = tempfile::tempdir().expect("tempdir");
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(resolve_shutdown_budget(dir.path(), Some("dev")), (0, 1));
assert_eq!(
resolve_shutdown_budget(dir.path(), Some("development")),
(0, 1)
);
});
}
#[test]
fn shutdown_budget_prod_profile_matches_runtime_smart_defaults() {
let dir = tempfile::tempdir().expect("tempdir");
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(resolve_shutdown_budget(dir.path(), Some("prod")), (5, 35));
});
}
#[test]
fn shutdown_budget_custom_profile_keeps_struct_defaults() {
let dir = tempfile::tempdir().expect("tempdir");
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(
resolve_shutdown_budget(dir.path(), Some("staging")),
(5, 30)
);
});
}
#[test]
fn shutdown_budget_dev_toml_override_beats_smart_default() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("autumn.toml"),
"[server]\nshutdown_timeout_secs = 20\n",
)
.expect("write");
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(resolve_shutdown_budget(dir.path(), Some("dev")), (0, 20));
});
}
#[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_consults_an_injected_env_source() {
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 injected = |key: &str| match key {
"AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS" => Some("250".to_owned()),
_ => None,
};
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(
resolve_shutdown_budget_from(dir.path(), None, &injected),
(10, 250),
"the injected env must override the TOML value"
);
});
}
#[test]
fn shutdown_budget_from_falls_back_to_toml_when_the_env_source_is_empty() {
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 empty = |_: &str| None;
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(
resolve_shutdown_budget_from(dir.path(), None, &empty),
(10, 100)
);
});
}
#[test]
fn shutdown_budget_from_ignores_a_malformed_injected_value() {
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 junk = |key: &str| match key {
"AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS" => Some("soon".to_owned()),
"AUTUMN_SERVER__PRESTOP_GRACE_SECS" => Some(String::new()),
_ => None,
};
temp_env::with_vars(clear_shutdown_env(), || {
assert_eq!(
resolve_shutdown_budget_from(dir.path(), None, &junk),
(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 dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "p");
let rec = process::PidRecord {
pid: 2_147_483_640,
start_time: None,
};
assert!(!confirmed_running(&rec, &paths, false));
}
#[cfg(unix)]
#[test]
fn confirmed_running_true_while_startup_in_progress() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "p");
let rec = process::PidRecord {
pid: std::process::id(),
start_time: None,
};
assert!(confirmed_running(&rec, &paths, true));
}
#[cfg(target_os = "linux")]
#[test]
fn confirmed_running_true_for_self_via_start_time() {
let dir = tempfile::tempdir().expect("tempdir");
let paths = RuntimePaths::from_base(dir.path(), "p");
let pid = std::process::id();
let rec = process::PidRecord {
pid,
start_time: process::process_start_time(pid),
};
assert!(confirmed_running(&rec, &paths, 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).expect("target");
assert_eq!(rec.pid, 4242);
}
#[test]
fn lifecycle_target_addr_fallback_requires_a_live_endpoint() {
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).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),
pin: None,
}
}
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_clears_an_inherited_data_flow_dump_flag() {
let opts = serve_opts_with_role(None);
let cmd = base_command(Path::new("/bin/true"), None, &opts);
let entry = cmd
.get_envs()
.find(|(k, _)| *k == std::ffi::OsStr::new(crate::data_flow::DUMP_ENV));
assert_eq!(
entry,
Some((std::ffi::OsStr::new(crate::data_flow::DUMP_ENV), None)),
"the flag must be explicitly removed (present with a None value), not \
merely absent from the overrides -- absent means inherited: {entry:?}"
);
}
#[test]
fn base_command_clears_an_inherited_data_capsule_mode() {
let opts = serve_opts_with_role(None);
let cmd = base_command(Path::new("/bin/true"), None, &opts);
for var in [
"AUTUMN_DATA_CAPSULE",
"AUTUMN_DATA_CAPSULE_SUBJECT",
"AUTUMN_DATA_CAPSULE_PATH",
] {
let entry = cmd
.get_envs()
.find(|(k, _)| *k == std::ffi::OsStr::new(var));
assert_eq!(entry, Some((std::ffi::OsStr::new(var), None)), "{var}");
}
}
#[test]
fn base_command_clears_an_inherited_retention_mode() {
let opts = serve_opts_with_role(None);
let cmd = base_command(Path::new("/bin/true"), None, &opts);
for var in [
"AUTUMN_DB_RETENTION",
"AUTUMN_DB_RETENTION_DATASET",
"AUTUMN_RETENTION_DRY_RUN",
] {
let entry = cmd
.get_envs()
.find(|(k, _)| *k == std::ffi::OsStr::new(var));
assert_eq!(
entry,
Some((std::ffi::OsStr::new(var), None)),
"{var} must be explicitly removed (present with a None value), not \
merely absent from the overrides -- absent means inherited",
);
}
}
#[test]
fn base_command_forwards_role_env() {
for role in ["web", "worker", "combined", "critical"] {
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",
);
}
fn serve_opts_with_pin(pin: &[&str]) -> ServeOptions {
ServeOptions {
pin: Some(pin.iter().map(|s| (*s).to_owned()).collect()),
..serve_opts_with_role(Some("worker"))
}
}
#[test]
fn base_command_forwards_pin_env() {
let opts = serve_opts_with_pin(&["critical", "default"]);
let cmd = base_command(Path::new("/bin/true"), None, &opts);
assert_eq!(
env_value(&cmd, "AUTUMN_JOBS__PIN").as_deref(),
Some("critical,default"),
"serve --pin must forward AUTUMN_JOBS__PIN as a comma-separated list",
);
}
#[test]
fn base_command_omits_pin_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_JOBS__PIN")),
"no --pin must leave AUTUMN_JOBS__PIN unset for the app binary",
);
}
#[test]
fn write_mode_file_persists_pin_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_pin(&["critical"]);
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.pin.as_deref(),
Some(["critical".to_owned()].as_slice()),
"restart must recover the persisted --pin",
);
}
#[test]
fn restart_precedence_prefers_the_flag_then_the_live_env_then_the_recording() {
let recorded = Some(vec!["critical".to_owned()]);
let live_env = Some(vec!["bulk".to_owned(), "default".to_owned()]);
let flag = Some(vec!["reports".to_owned()]);
assert_eq!(
resolve_restart_pin(flag.clone(), live_env.clone(), recorded.clone()),
flag,
"an explicit --pin on this restart wins",
);
assert_eq!(
resolve_restart_pin(None, live_env.clone(), recorded.clone()),
live_env,
"otherwise the restart shell's own AUTUMN_JOBS__PIN wins over the recording — \
without this, re-pointing a tier by exporting a new pin is overridden by the \
one captured at the daemon's original start, and then re-recorded",
);
assert_eq!(
resolve_restart_pin(None, None, recorded.clone()),
recorded,
"and only with neither does the recorded pin come back, so a bare restart never \
turns a pinned tier into an unpinned one",
);
assert_eq!(
resolve_restart_pin(None, None, None),
None,
"nothing anywhere leaves AUTUMN_JOBS__PIN untouched for the child",
);
assert_eq!(
resolve_restart_pin(None, Some(Vec::new()), recorded),
Some(Vec::new()),
"an explicitly-empty live pin must not fall through to the recording",
);
}
#[test]
fn mode_file_pin_defaults_none_for_legacy_files() {
let legacy = "release = true\nprofile = \"prod\"\nrole = \"worker\"\n";
let parsed: ModeFile = toml::from_str(legacy).expect("parse legacy mode file");
assert_eq!(parsed.pin, None);
}
#[test]
fn effective_pin_prefers_flag_then_env() {
assert_eq!(
effective_pin_from(Some(vec!["critical".to_owned()]), Some("bulk".to_owned())),
Some(vec!["critical".to_owned()]),
"an explicit --pin must win over an inherited AUTUMN_JOBS__PIN",
);
assert_eq!(
effective_pin_from(None, Some(" critical , , default ".to_owned())),
Some(vec!["critical".to_owned(), "default".to_owned()]),
"an inherited AUTUMN_JOBS__PIN is split/trimmed like the app parses it",
);
assert_eq!(
effective_pin_from(None, None),
None,
"no flag and no env records no pin",
);
}
#[test]
fn an_explicitly_empty_pin_is_recorded_as_unpinned_not_as_absent() {
assert_eq!(
effective_pin_from(None, Some(String::new())),
Some(Vec::new()),
"a present-but-empty AUTUMN_JOBS__PIN means explicitly unpinned",
);
assert_eq!(
effective_pin_from(None, Some(" , ".to_owned())),
Some(Vec::new()),
"a present all-blank AUTUMN_JOBS__PIN is likewise an explicit unpin",
);
let opts = ServeOptions {
pin: Some(Vec::new()),
..serve_opts_with_role(Some("worker"))
};
let cmd = base_command(Path::new("/bin/true"), None, &opts);
assert_eq!(
env_value(&cmd, "AUTUMN_JOBS__PIN").as_deref(),
Some(""),
"an explicit unpin must set AUTUMN_JOBS__PIN to the empty string, not leave it unset",
);
}
#[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);
}
}