#![allow(dead_code, clippy::missing_const_for_fn)]
use crate::paths::RuntimeParts;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceAction {
Install,
Uninstall,
Run,
}
const SERVICE_NAME_PREFIX: &str = "autumn-";
const MAX_SERVICE_NAME: usize = 256;
const RESTART_DELAYS_SECS: [u64; 3] = [5, 15, 60];
const FAILURE_RESET_PERIOD: Duration = Duration::from_hours(24);
const STOP_HOOK_HEADROOM: Duration = Duration::from_secs(60);
pub const MAX_WAIT_HINT: Duration = Duration::from_hours(24);
const SUPERVISE_POLL: Duration = Duration::from_millis(100);
#[must_use]
pub fn service_name(project_identity: &str) -> String {
let sanitized: String = project_identity
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'_'
}
})
.collect();
let name = format!("{SERVICE_NAME_PREFIX}{sanitized}");
if name.chars().count() <= MAX_SERVICE_NAME {
return name;
}
let (readable, hash) = sanitized
.rsplit_once('-')
.map_or((sanitized.as_str(), ""), |(head, tail)| (head, tail));
let suffix = if hash.is_empty() {
String::new()
} else {
format!("-{hash}")
};
let room = MAX_SERVICE_NAME
.saturating_sub(SERVICE_NAME_PREFIX.chars().count())
.saturating_sub(suffix.chars().count());
let readable: String = readable.chars().take(room).collect();
format!("{SERVICE_NAME_PREFIX}{readable}{suffix}")
}
#[must_use]
pub fn service_display_name(project_identity: &str) -> String {
const DISPLAY_PREFIX: &str = "Autumn app: ";
let display = format!("{DISPLAY_PREFIX}{project_identity}");
if display.chars().count() <= MAX_SERVICE_NAME {
return display;
}
let (readable, hash) = project_identity
.rsplit_once('-')
.map_or((project_identity, ""), |(head, tail)| (head, tail));
let suffix = if hash.is_empty() {
String::new()
} else {
format!("-{hash}")
};
let room = MAX_SERVICE_NAME
.saturating_sub(DISPLAY_PREFIX.chars().count())
.saturating_sub(suffix.chars().count());
let readable: String = readable.chars().take(room).collect();
format!("{DISPLAY_PREFIX}{readable}{suffix}")
}
#[must_use]
pub fn service_description(working_dir: &std::path::Path) -> String {
format!(
"Autumn application server for {}. Managed with `autumn serve \
install-service` / `uninstall-service`; stop it gracefully with \
`autumn serve stop` or the Service Control Manager.",
working_dir.display()
)
}
#[must_use]
pub fn stop_wait_hint(drain_budget_secs: u64) -> Duration {
Duration::from_secs(drain_budget_secs)
.saturating_add(STOP_HOOK_HEADROOM)
.min(MAX_WAIT_HINT)
}
pub const ERROR_SERVICE_DOES_NOT_EXIST: i32 = 1060;
#[must_use]
pub fn open_failure_means_absent(raw_os_error: Option<i32>) -> bool {
raw_os_error == Some(ERROR_SERVICE_DOES_NOT_EXIST)
}
#[cfg_attr(
not(windows),
allow(dead_code, reason = "used by the Windows arm, tested everywhere")
)]
pub fn query_failure(
raw_os_error: Option<i32>,
name: &str,
detail: &str,
) -> Result<Option<(String, String)>, String> {
if open_failure_means_absent(raw_os_error) {
Ok(None)
} else {
Err(format!("cannot query `{name}`: {detail}"))
}
}
#[must_use]
pub fn open_failure_is_access_denied(raw_os_error: Option<i32>) -> bool {
const ACCESS_DENIED: i32 = 5;
raw_os_error == Some(ACCESS_DENIED)
}
#[must_use]
pub fn stop_was_requested(
already_seen: bool,
control_asked: bool,
request_file_exists: bool,
) -> bool {
already_seen || control_asked || request_file_exists
}
#[must_use]
pub fn exit_is_intentional(stop_requested: bool, exit_code: Option<i32>) -> bool {
stop_requested || exit_code == Some(0)
}
pub const FALLBACK_DRAIN_BUDGET_SECS: u64 = 60;
#[cfg_attr(
not(windows),
allow(dead_code, reason = "used by the Windows arm, tested everywhere")
)]
fn preshutdown_for_reported(reported_budget_secs: Option<u64>) -> Duration {
stop_wait_hint(reported_budget_secs.unwrap_or(FALLBACK_DRAIN_BUDGET_SECS))
}
#[cfg_attr(
not(windows),
allow(dead_code, reason = "used by the Windows arm, tested everywhere")
)]
fn record_path_from_command_line(line: &str) -> Option<PathBuf> {
let mut tokens = Vec::new();
let mut cur = String::new();
let (mut quoted, mut any) = (false, false);
for c in line.chars() {
match c {
'"' => {
quoted = !quoted;
any = true;
}
c if c.is_whitespace() && !quoted => {
if any {
tokens.push(std::mem::take(&mut cur));
any = false;
}
}
c => {
cur.push(c);
any = true;
}
}
}
if any {
tokens.push(cur);
}
let at = tokens.iter().position(|t| t == "--service-record")?;
tokens.get(at + 1).map(PathBuf::from)
}
#[cfg_attr(
not(windows),
allow(dead_code, reason = "used by the Windows arm, tested everywhere")
)]
pub fn write_fresh(path: &Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut tmp = path.as_os_str().to_os_string();
tmp.push(".new");
let tmp = PathBuf::from(tmp);
match std::fs::remove_file(&tmp) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
file.write_all(contents)?;
drop(file);
std::fs::rename(&tmp, path)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceRecord {
pub name: String,
pub binary: PathBuf,
pub working_dir: PathBuf,
pub paths: RuntimeParts,
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub release: bool,
#[serde(default)]
pub bundled_pg: bool,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub pin: Option<Vec<String>>,
}
impl ServiceRecord {
pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
toml::to_string(self)
}
pub fn parse(contents: &str) -> Result<Self, toml::de::Error> {
toml::from_str(contents)
}
}
#[must_use]
pub fn restart_delays() -> Vec<Duration> {
RESTART_DELAYS_SECS
.iter()
.copied()
.map(Duration::from_secs)
.collect()
}
#[must_use]
pub fn failure_reset_period() -> Duration {
FAILURE_RESET_PERIOD
}
#[cfg(windows)]
#[must_use]
pub fn missing_prerequisites() -> Vec<String> {
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
if ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
)
.is_ok()
{
return Vec::new();
}
vec![
"administrator rights to register or remove a Windows service \
(`autumn serve install-service` / `uninstall-service`)"
.to_owned(),
]
}
#[cfg(windows)]
pub fn registered_service_state(
project_identity: &str,
) -> Result<Option<(String, String)>, String> {
use windows_service::service::ServiceAccess;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
fn raw(e: &windows_service::Error) -> Option<i32> {
match e {
windows_service::Error::Winapi(io) => io.raw_os_error(),
_ => None,
}
}
let name = service_name(project_identity);
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| format!("cannot reach the Service Control Manager ({e})"))?;
let service = match manager.open_service(&name, ServiceAccess::QUERY_STATUS) {
Ok(service) => service,
Err(e) => return query_failure(raw(&e), &name, &e.to_string()),
};
let state = service
.query_status()
.map_err(|e| format!("cannot query `{name}`: {e}"))?
.current_state;
Ok(Some((name, format!("{state:?}"))))
}
#[must_use]
pub fn unsupported_message(command: &str) -> String {
format!(
"autumn serve {command}: Windows services exist only on Windows. On \
Linux use a systemd unit and on macOS a launchd plist; \
`autumn serve --daemon` runs the same app under either."
)
}
#[cfg(not(windows))]
pub fn run(action: ServiceAction, _opts: &crate::serve::ServeOptions) -> i32 {
let command = match action {
ServiceAction::Install => "install-service",
ServiceAction::Uninstall => "uninstall-service",
ServiceAction::Run => "run-service",
};
eprintln!("{}", unsupported_message(command));
1
}
#[cfg(windows)]
pub use windows_impl::{restart_registered, run};
#[cfg(not(windows))]
#[must_use]
pub fn restart_registered(_project_identity: &str) -> Option<Result<(), String>> {
None
}
#[cfg(windows)]
mod windows_impl {
use super::{
ServiceAction, ServiceRecord, exit_is_intentional, failure_reset_period, restart_delays,
service_description, service_display_name, service_name, stop_wait_hint,
};
use crate::paths::RuntimePaths;
use crate::process;
use crate::serve::{self, ServeOptions};
use std::ffi::OsString;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use windows_service::service::{
ServiceAccess, ServiceAction as ScAction, ServiceActionType, ServiceControl,
ServiceControlAccept, ServiceErrorControl, ServiceExitCode, ServiceFailureActions,
ServiceFailureResetPeriod, ServiceInfo, ServiceStartType, ServiceState, ServiceStatus,
ServiceType,
};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
use windows_service::{service_control_handler, service_dispatcher};
const SCM_TRANSITION_FLOOR: Duration = Duration::from_secs(60);
fn scm_stop_timeout(paths: Option<&RuntimePaths>) -> Duration {
let budget = paths
.and_then(serve::recorded_stop_budget)
.unwrap_or(super::FALLBACK_DRAIN_BUDGET_SECS);
stop_wait_hint(budget).max(SCM_TRANSITION_FLOOR)
}
static RECORD_PATH: OnceLock<std::path::PathBuf> = OnceLock::new();
pub fn run(action: ServiceAction, opts: &ServeOptions) -> i32 {
match action {
ServiceAction::Install => install(opts),
ServiceAction::Uninstall => uninstall(opts),
ServiceAction::Run => host(opts),
}
}
fn install(opts: &ServeOptions) -> i32 {
let identity = serve::project_identity_for(opts.package.as_deref());
let paths = match RuntimePaths::resolve(&identity) {
Ok(paths) => paths,
Err(e) => {
eprintln!("autumn serve install-service: {e}");
return 1;
}
};
if let Err(e) = paths.ensure_dirs() {
eprintln!("autumn serve install-service: cannot create runtime dirs: {e}");
return 1;
}
if let Some((pid, endpoint)) = serve::running_daemon_summary(opts.package.as_deref()) {
eprintln!(
"autumn serve install-service: a daemon is already running for this \
project (pid {pid}) on {endpoint}. Stop it with `autumn serve stop` \
first — the service will start its own."
);
return 1;
}
eprintln!("\u{1F342} autumn serve install-service\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 binary = std::fs::canonicalize(&binary).unwrap_or(binary);
let working_dir = opts
.package
.as_deref()
.and_then(crate::dev::find_manifest_dir)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| ".".into()));
let name = service_name(&identity);
let record = ServiceRecord {
name: name.clone(),
binary,
working_dir: working_dir.clone(),
paths: paths.parts(),
profile: opts.profile.clone().or_else(serve::env_profile_for_record),
release: opts.release,
bundled_pg: opts.bundled_pg,
role: serve::effective_role_for_record(opts.role.clone()),
pin: serve::effective_pin_for_record(opts.pin.clone()),
};
let record_path = paths.service_record_file();
match record.to_toml() {
Ok(toml) => {
if let Err(e) = super::write_fresh(&record_path, toml.as_bytes()) {
eprintln!(
"autumn serve install-service: cannot write {}: {e}",
record_path.display()
);
return 1;
}
}
Err(e) => {
eprintln!("autumn serve install-service: cannot record the service config: {e}");
return 1;
}
}
if let Err(e) = register(
&name,
&identity,
&working_dir,
&record_path,
serve::start_ready_timeout(opts.bundled_pg),
&paths,
) {
eprintln!("autumn serve install-service: {e}");
return 1;
}
println!("autumn serve: registered the Windows service `{name}`");
println!(" starts at boot, restarts after a crash");
println!(" logs: {}", paths.log_file().display());
println!(" address file: {}", paths.addr_file().display());
println!(" remove it with `autumn serve uninstall-service`");
0
}
fn register(
name: &str,
identity: &str,
working_dir: &std::path::Path,
record_path: &std::path::Path,
start_timeout: Duration,
paths: &RuntimePaths,
) -> Result<(), String> {
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
)
.map_err(|e| {
format!(
"cannot reach the Service Control Manager ({e}). Registering a \
service needs an elevated (Administrator) shell."
)
})?;
let info = ServiceInfo {
name: OsString::from(name),
display_name: OsString::from(service_display_name(identity)),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: std::env::current_exe()
.map_err(|e| format!("cannot locate the autumn executable: {e}"))?,
launch_arguments: vec![
OsString::from("serve"),
OsString::from("run-service"),
OsString::from("--service-record"),
record_path.as_os_str().to_os_string(),
],
dependencies: vec![],
account_name: None,
account_password: None,
};
let access = ServiceAccess::CHANGE_CONFIG
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::QUERY_STATUS;
let service = manager
.create_service(&info, access)
.map_err(|e| format!("cannot register the service `{name}`: {e}"))?;
service
.set_description(service_description(working_dir))
.map_err(|e| format!("cannot set the service description: {e}"))?;
service
.update_failure_actions(ServiceFailureActions {
reset_period: ServiceFailureResetPeriod::After(failure_reset_period()),
reboot_msg: None,
command: None,
actions: Some(
restart_delays()
.into_iter()
.map(|delay| ScAction {
action_type: ServiceActionType::Restart,
delay,
})
.collect(),
),
})
.map_err(|e| format!("cannot set the service restart policy: {e}"))?;
service
.set_failure_actions_on_non_crash_failures(true)
.map_err(|e| format!("cannot arm the service restart policy: {e}"))?;
let _ = service.set_preshutdown_timeout(super::preshutdown_for_reported(None));
service
.start::<&std::ffi::OsStr>(&[])
.map_err(|e| format!("registered the service but could not start it: {e}"))?;
let deadline = Instant::now() + start_timeout;
loop {
match service.query_status().map(|s| s.current_state) {
Ok(ServiceState::Running) => {
let until = Instant::now() + Duration::from_secs(10);
let reported = loop {
let budget = serve::recorded_stop_budget(paths);
if budget.is_some() || Instant::now() >= until {
break budget;
}
std::thread::sleep(Duration::from_millis(250));
};
if reported.is_none() {
eprintln!(
"autumn serve install-service: the app did not report its \
drain budget; using the {}s fallback \
for the preshutdown timeout",
super::FALLBACK_DRAIN_BUDGET_SECS
);
} else if let Err(e) =
service.set_preshutdown_timeout(super::preshutdown_for_reported(reported))
{
eprintln!("autumn serve install-service: preshutdown timeout not set: {e}");
}
return Ok(());
}
Ok(ServiceState::Stopped) => {
return Err(format!(
"`{name}` is registered but its app did not start. See the \
daemon log, then `autumn serve uninstall-service` to \
remove it."
));
}
Ok(_) => {}
Err(e) => return Err(format!("cannot query `{name}`: {e}")),
}
if Instant::now() >= deadline {
return Err(format!(
"`{name}` is registered but did not reach Running within {}s. \
See the daemon log.",
start_timeout.as_secs()
));
}
std::thread::sleep(Duration::from_millis(500));
}
}
pub fn restart_registered(project_identity: &str) -> Option<Result<(), String>> {
let name = service_name(project_identity);
match super::registered_service_state(project_identity) {
Ok(None) => return None,
Ok(Some(_)) => {}
Err(e) => return Some(Err(e)),
}
Some(restart_service(&name, project_identity))
}
fn restart_service(name: &str, project_identity: &str) -> Result<(), String> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| {
format!(
"cannot reach the Service Control Manager ({e}). Restarting a \
service needs an elevated (Administrator) shell."
)
})?;
let service = manager
.open_service(
name,
ServiceAccess::STOP | ServiceAccess::START | ServiceAccess::QUERY_STATUS,
)
.map_err(|e| {
let raw = match &e {
windows_service::Error::Winapi(io) => io.raw_os_error(),
_ => None,
};
if super::open_failure_is_access_denied(raw) {
format!(
"cannot open `{name}`: access denied. Restarting a service \
needs an elevated (Administrator) shell; nothing was \
changed."
)
} else {
format!("cannot open `{name}`: {e}")
}
})?;
let paths = crate::paths::RuntimePaths::resolve(project_identity).ok();
let stop_timeout = scm_stop_timeout(paths.as_ref());
if service
.query_status()
.map_err(|e| format!("cannot query `{name}`: {e}"))?
.current_state
!= ServiceState::Stopped
{
service
.stop()
.map_err(|e| format!("cannot stop `{name}`: {e}"))?;
wait_for_state(&service, name, ServiceState::Stopped, stop_timeout)?;
}
service
.start::<&std::ffi::OsStr>(&[])
.map_err(|e| format!("cannot start `{name}`: {e}"))?;
wait_for_state(
&service,
name,
ServiceState::Running,
serve::start_ready_timeout(true),
)?;
println!("autumn serve: restarted the Windows service `{name}`");
Ok(())
}
fn wait_for_state(
service: &windows_service::service::Service,
name: &str,
wanted: ServiceState,
timeout: Duration,
) -> Result<(), String> {
let deadline = Instant::now() + timeout;
loop {
let state = service
.query_status()
.map_err(|e| format!("cannot query `{name}`: {e}"))?
.current_state;
if state == wanted {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"`{name}` did not reach {wanted:?} within {}s; it is {state:?}",
timeout.as_secs()
));
}
std::thread::sleep(Duration::from_millis(250));
}
}
fn installed_runtime_paths(name: &str) -> Option<RuntimePaths> {
let manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
let service = manager
.open_service(name, ServiceAccess::QUERY_CONFIG)
.ok()?;
let config = service.query_config().ok()?;
let path = super::record_path_from_command_line(&config.executable_path.to_string_lossy())?;
let record = ServiceRecord::parse(&std::fs::read_to_string(path).ok()?).ok()?;
(record.name == name).then(|| RuntimePaths::from_parts(record.paths))
}
fn uninstall(opts: &ServeOptions) -> i32 {
let identity = serve::project_identity_for(opts.package.as_deref());
let name = service_name(&identity);
let paths =
installed_runtime_paths(&name).or_else(|| RuntimePaths::resolve(&identity).ok());
let deregistered = match deregister(&name, scm_stop_timeout(paths.as_ref())) {
Ok(Deregistered::Removed | Deregistered::WasNotRegistered) => true,
Err(e) => {
eprintln!("autumn serve uninstall-service: {e}");
false
}
};
if !deregistered {
eprintln!(
"autumn serve uninstall-service: `{name}` is still registered, so \
its state was left in place. Stop it (`sc.exe stop {name}`) and \
retry."
);
return 1;
}
if let Some(paths) = paths.as_ref() {
serve::reap_managed_postgres_for(paths);
serve::cleanup_daemon_state(paths);
let _ = std::fs::remove_file(paths.service_record_file());
}
println!("autumn serve: removed the Windows service `{name}`");
if let Some(paths) = paths.as_ref() {
println!(
" the managed-Postgres data directory was kept: {}",
paths.pg_data_dir().display()
);
}
0
}
enum Deregistered {
Removed,
WasNotRegistered,
}
fn deregister(name: &str, stop_timeout: Duration) -> Result<Deregistered, String> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.map_err(|e| {
format!(
"cannot reach the Service Control Manager ({e}). Removing a \
service needs an elevated (Administrator) shell."
)
})?;
let service = match manager.open_service(
name,
ServiceAccess::STOP | ServiceAccess::DELETE | ServiceAccess::QUERY_STATUS,
) {
Ok(service) => service,
Err(e) => {
let raw = match &e {
windows_service::Error::Winapi(io) => io.raw_os_error(),
_ => None,
};
if super::open_failure_means_absent(raw) {
return Ok(Deregistered::WasNotRegistered);
}
if super::open_failure_is_access_denied(raw) {
return Err(format!(
"cannot open `{name}`: access denied. Removing a service \
needs an elevated (Administrator) shell; nothing was \
changed."
));
}
return Err(format!("cannot open `{name}`: {e}"));
}
};
let state = |service: &windows_service::service::Service| {
service.query_status().map(|s| s.current_state)
};
if state(&service).map_err(|e| format!("cannot query `{name}`: {e}"))?
!= ServiceState::Stopped
{
service
.stop()
.map_err(|e| format!("cannot stop `{name}`: {e}"))?;
let deadline = Instant::now() + stop_timeout;
while state(&service).map_err(|e| format!("cannot query `{name}`: {e}"))?
!= ServiceState::Stopped
{
if Instant::now() >= deadline {
return Err(format!(
"`{name}` did not stop within {}s; it was left registered \
so its state is not removed under a running service",
stop_timeout.as_secs()
));
}
std::thread::sleep(Duration::from_millis(250));
}
}
service
.delete()
.map(|()| Deregistered::Removed)
.map_err(|e| format!("cannot deregister `{name}`: {e}"))
}
extern "system" fn ffi_service_main(_argc: u32, _argv: *mut *mut u16) {
service_main();
}
fn service_main() {
if let Err(e) = supervise() {
log_host_failure(&e);
}
}
fn log_host_failure(message: &str) {
let Some(record) = read_record() else {
return;
};
let paths = RuntimePaths::from_parts(record.paths);
if let Ok(mut log) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(paths.log_file())
{
use std::io::Write as _;
let _ = writeln!(log, "autumn serve run-service: {message}");
}
}
fn read_record() -> Option<ServiceRecord> {
let path = RECORD_PATH.get()?;
ServiceRecord::parse(&std::fs::read_to_string(path).ok()?).ok()
}
fn host(opts: &ServeOptions) -> i32 {
let Some(path) = record_path_from_argv() else {
eprintln!(
"autumn serve run-service: this command is run by the Windows \
Service Control Manager, not by hand. Use `autumn serve \
install-service`."
);
return 1;
};
let _ = opts;
let Ok(record) = ServiceRecord::parse(&std::fs::read_to_string(&path).unwrap_or_default())
else {
eprintln!(
"autumn serve run-service: cannot read the service record at {}. \
Re-run `autumn serve install-service`.",
path.display()
);
return 1;
};
let _ = RECORD_PATH.set(path);
match service_dispatcher::start(&record.name, ffi_service_main) {
Ok(()) => 0,
Err(e) => {
eprintln!("autumn serve run-service: {e}");
1
}
}
}
fn record_path_from_argv() -> Option<std::path::PathBuf> {
let mut args = std::env::args_os();
while let Some(arg) = args.next() {
if arg == "--service-record" {
return args.next().map(std::path::PathBuf::from);
}
}
None
}
fn watch_app(
child: &mut std::process::Child,
paths: &RuntimePaths,
stop_file: &std::path::Path,
stop_requested: &AtomicBool,
budget: u64,
report: &impl Fn(ServiceState, ServiceExitCode, Duration) -> Result<(), String>,
) -> Result<(Option<i32>, bool), String> {
let mut announced_stop = false;
let mut saw_stop_request = false;
loop {
saw_stop_request = super::stop_was_requested(
saw_stop_request,
stop_requested.load(Ordering::SeqCst),
stop_file.exists(),
);
if saw_stop_request && !announced_stop {
report(
ServiceState::StopPending,
ServiceExitCode::Win32(0),
stop_wait_hint(budget),
)?;
announced_stop = true;
}
match child.try_wait() {
Ok(Some(status)) => return Ok((status.code(), saw_stop_request)),
Err(_) => return Ok((None, saw_stop_request)),
Ok(None) => {}
}
if saw_stop_request {
return Ok((serve::stop_child(child, paths, budget), true));
}
std::thread::sleep(super::SUPERVISE_POLL);
}
}
fn register_control_handler(
name: &str,
stop_requested: std::sync::Arc<AtomicBool>,
stop_file: std::path::PathBuf,
) -> Result<service_control_handler::ServiceStatusHandle, String> {
let handler = move |control| match control {
ServiceControl::Interrogate => {
service_control_handler::ServiceControlHandlerResult::NoError
}
ServiceControl::Stop | ServiceControl::Preshutdown | ServiceControl::Shutdown => {
let _ = process::create_stop_request(&stop_file);
stop_requested.store(true, Ordering::SeqCst);
service_control_handler::ServiceControlHandlerResult::NoError
}
_ => service_control_handler::ServiceControlHandlerResult::NotImplemented,
};
service_control_handler::register(name, handler)
.map_err(|e| format!("cannot register the service control handler: {e}"))
}
fn supervise() -> Result<(), String> {
let Some(record) = read_record() else {
return Err("the service record could not be read".to_owned());
};
let paths = RuntimePaths::from_parts(record.paths.clone());
let stop_file = paths.stop_file();
let stop_requested = std::sync::Arc::new(AtomicBool::new(false));
let status_handle = register_control_handler(
&record.name,
std::sync::Arc::clone(&stop_requested),
stop_file.clone(),
)?;
let opts = ServeOptions {
package: None,
daemon: true,
release: record.release,
bundled_pg: record.bundled_pg,
profile: record.profile.clone(),
role: record.role.clone(),
pin: record.pin.clone(),
};
let report = |state: ServiceState, exit: ServiceExitCode, wait_hint: Duration| {
status_handle
.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: state,
controls_accepted: if state == ServiceState::Running {
ServiceControlAccept::STOP | ServiceControlAccept::PRESHUTDOWN
} else {
ServiceControlAccept::empty()
},
exit_code: exit,
checkpoint: 0,
wait_hint,
process_id: None,
})
.map_err(|e| format!("cannot report service status: {e}"))
};
report(
ServiceState::StartPending,
ServiceExitCode::Win32(0),
serve::start_ready_timeout(record.bundled_pg),
)?;
let mut child =
match serve::start_supervised(&record.binary, &paths, &opts, Some(&record.working_dir))
{
Ok(child) => child,
Err(message) => {
log_host_failure(&message);
report(
ServiceState::Stopped,
ServiceExitCode::ServiceSpecific(1),
Duration::default(),
)?;
return Ok(());
}
};
report(
ServiceState::Running,
ServiceExitCode::Win32(0),
Duration::default(),
)?;
let budget =
serve::recorded_stop_budget(&paths).unwrap_or(super::FALLBACK_DRAIN_BUDGET_SECS);
let (app_exit_code, saw_stop_request) = watch_app(
&mut child,
&paths,
&stop_file,
&stop_requested,
budget,
&report,
)?;
if record.bundled_pg {
serve::reap_managed_postgres_for(&paths);
}
serve::cleanup_daemon_state(&paths);
let intentional = exit_is_intentional(saw_stop_request, app_exit_code);
report(
ServiceState::Stopped,
if intentional {
ServiceExitCode::Win32(0)
} else {
ServiceExitCode::ServiceSpecific(
app_exit_code
.and_then(|code| u32::try_from(code).ok())
.filter(|&code| code != 0)
.unwrap_or(1),
)
},
Duration::default(),
)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
#[test]
fn record_path_handles_empty_and_unterminated_quotes() {
assert_eq!(
record_path_from_command_line(r#"a --service-record """#),
Some(PathBuf::from(""))
);
assert_eq!(
record_path_from_command_line(r#"a --service-record "C:\a b"#),
Some(PathBuf::from(r"C:\a b"))
);
}
#[test]
fn preshutdown_uses_the_fallback_until_the_app_reports() {
assert_eq!(
preshutdown_for_reported(None),
stop_wait_hint(FALLBACK_DRAIN_BUDGET_SECS)
);
}
#[test]
fn preshutdown_follows_the_reported_budget() {
assert_eq!(preshutdown_for_reported(Some(300)), stop_wait_hint(300));
}
#[test]
fn record_path_is_read_from_a_quoted_command_line() {
let line = r#""C:\Program Files\autumn.exe" serve run-service --service-record "C:\Users\a b\serve.service.toml""#;
assert_eq!(
record_path_from_command_line(line),
Some(PathBuf::from(r"C:\Users\a b\serve.service.toml"))
);
}
#[test]
fn record_path_is_read_from_a_bare_command_line() {
let line = r"autumn.exe serve run-service --service-record C:\s\r.toml";
assert_eq!(
record_path_from_command_line(line),
Some(PathBuf::from(r"C:\s\r.toml"))
);
}
#[test]
fn command_line_without_a_record_has_no_path() {
assert_eq!(record_path_from_command_line("autumn.exe serve"), None);
assert_eq!(
record_path_from_command_line("autumn.exe --service-record"),
None
);
}
#[test]
fn fresh_write_replaces_an_existing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("serve.service.toml");
std::fs::write(&path, "old").expect("seed");
write_fresh(&path, b"new").expect("write");
assert_eq!(std::fs::read(&path).expect("read"), b"new");
}
#[cfg(unix)]
#[test]
fn fresh_write_does_not_reuse_the_old_file() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("serve.service.toml");
std::fs::write(&path, "old").expect("seed");
let before = std::fs::metadata(&path).expect("meta").ino();
let _held = std::fs::File::open(&path).expect("hold");
write_fresh(&path, b"new").expect("write");
assert_ne!(std::fs::metadata(&path).expect("meta").ino(), before);
}
#[test]
fn missing_service_is_absent_not_an_error() {
assert_eq!(
query_failure(Some(ERROR_SERVICE_DOES_NOT_EXIST), "svc", "gone"),
Ok(None)
);
}
#[test]
fn denied_or_transient_query_is_an_error() {
for raw in [Some(5), Some(6), Some(1722), None] {
let err = query_failure(raw, "svc", "boom").expect_err("must not read as absent");
assert!(err.contains("svc") && err.contains("boom"), "{err}");
}
}
use super::*;
#[test]
fn service_name_is_prefixed_and_derived_from_the_project() {
assert_eq!(service_name("mytool-a1b2c3d4"), "autumn-mytool-a1b2c3d4");
}
#[test]
fn service_name_replaces_characters_the_scm_forbids() {
let name = service_name(r"my app/v2\x-a1b2c3d4");
assert_eq!(name, "autumn-my_app_v2_x-a1b2c3d4");
assert!(!name.contains('/') && !name.contains('\\') && !name.contains(' '));
}
#[test]
fn service_name_stays_within_the_scm_length_limit() {
let name = service_name(&"x".repeat(500));
assert!(name.chars().count() <= MAX_SERVICE_NAME, "{}", name.len());
}
#[test]
fn a_truncated_service_name_keeps_the_hash_that_distinguishes_checkouts() {
let one = service_name(&format!("{}-a1b2c3d4", "x".repeat(500)));
let two = service_name(&format!("{}-9f8e7d6c", "x".repeat(500)));
assert!(one.ends_with("-a1b2c3d4"), "{one}");
assert!(two.ends_with("-9f8e7d6c"), "{two}");
assert_ne!(one, two, "two checkouts must not share a service name");
assert!(one.chars().count() <= MAX_SERVICE_NAME);
}
#[test]
fn an_identity_with_no_hash_still_truncates_safely() {
let name = service_name(&"x".repeat(500));
assert!(name.starts_with(SERVICE_NAME_PREFIX));
assert!(name.chars().count() <= MAX_SERVICE_NAME);
}
#[test]
fn the_display_name_is_bounded_and_keeps_its_identifying_suffix() {
let display = service_display_name(&format!("{}-a1b2c3d4", "x".repeat(500)));
assert!(
display.chars().count() <= MAX_SERVICE_NAME,
"{}",
display.len()
);
assert!(display.ends_with("-a1b2c3d4"), "{display}");
assert!(display.starts_with("Autumn app: "), "{display}");
let other = service_display_name(&format!("{}-9f8e7d6c", "x".repeat(500)));
assert_ne!(display, other);
}
#[test]
fn a_short_display_name_is_left_alone() {
assert_eq!(
service_display_name("mytool-a1b2c3d4"),
"Autumn app: mytool-a1b2c3d4"
);
}
#[test]
fn stop_wait_hint_always_exceeds_the_apps_own_budget() {
for budget in [0, 1, 35, 600] {
assert!(
stop_wait_hint(budget) > Duration::from_secs(budget),
"budget {budget}"
);
}
}
#[test]
fn stop_wait_hint_is_clamped_to_what_the_scm_can_carry() {
let hint = stop_wait_hint(u64::MAX);
assert_eq!(hint, MAX_WAIT_HINT);
assert!(
u32::try_from(hint.as_millis()).is_ok(),
"the SCM carries the hint as u32 milliseconds"
);
assert!(stop_wait_hint(35) < MAX_WAIT_HINT);
}
#[test]
fn an_scm_stop_waits_at_least_as_long_as_the_host_will_drain() {
for budget in [0, 35, 60, 600] {
assert!(
stop_wait_hint(budget) >= Duration::from_secs(budget),
"budget {budget}"
);
}
}
#[test]
fn the_fallback_drain_budget_is_never_zero() {
assert_ne!(FALLBACK_DRAIN_BUDGET_SECS, 0);
assert!(stop_wait_hint(FALLBACK_DRAIN_BUDGET_SECS) > STOP_HOOK_HEADROOM);
}
#[test]
fn an_operator_stop_is_intentional_whatever_the_exit_code() {
assert!(exit_is_intentional(true, Some(1)));
assert!(exit_is_intentional(true, None));
}
#[test]
fn a_clean_exit_is_intentional_even_with_no_stop_request() {
assert!(exit_is_intentional(false, Some(0)));
}
#[test]
fn only_a_missing_service_counts_as_absent() {
assert!(open_failure_means_absent(Some(
ERROR_SERVICE_DOES_NOT_EXIST
)));
assert!(!open_failure_means_absent(Some(5)));
assert!(!open_failure_means_absent(Some(6)));
assert!(!open_failure_means_absent(None));
}
#[test]
fn an_access_denial_is_named_so_the_message_can_say_what_to_do() {
assert!(open_failure_is_access_denied(Some(5)));
assert!(!open_failure_is_access_denied(Some(
ERROR_SERVICE_DOES_NOT_EXIST
)));
assert!(!open_failure_is_access_denied(None));
}
#[test]
fn a_stop_request_latches_once_seen() {
assert!(stop_was_requested(true, false, false));
}
#[test]
fn either_channel_alone_requests_a_stop() {
assert!(stop_was_requested(false, true, false));
assert!(stop_was_requested(false, false, true));
}
#[test]
fn no_signal_at_all_is_not_a_stop() {
assert!(!stop_was_requested(false, false, false));
}
#[test]
fn a_latched_stop_survives_an_exit_code_that_looks_like_a_crash() {
let saw = stop_was_requested(false, true, false);
assert!(exit_is_intentional(saw, Some(1)));
}
#[test]
fn a_crash_is_not_intentional_so_the_scm_restarts_it() {
assert!(!exit_is_intentional(false, Some(101)));
assert!(!exit_is_intentional(false, None));
}
#[test]
fn restart_delays_back_off_rather_than_hammering() {
let delays = restart_delays();
assert!(delays.len() >= 3, "{delays:?}");
assert!(
delays.windows(2).all(|w| w[1] > w[0]),
"delays must increase: {delays:?}"
);
assert!(delays[0] > Duration::ZERO);
}
#[test]
fn failure_count_resets_after_a_quiet_day() {
assert_eq!(failure_reset_period(), Duration::from_hours(24));
}
fn sample_record() -> ServiceRecord {
ServiceRecord {
name: "autumn-demo-a1b2c3d4".to_owned(),
binary: PathBuf::from(r"C:\proj\target\debug\demo.exe"),
working_dir: PathBuf::from(r"C:\proj"),
paths: RuntimeParts {
runtime: PathBuf::from(r"C:\state\run"),
data: PathBuf::from(r"C:\state"),
logs: PathBuf::from(r"C:\state\logs"),
socket: PathBuf::from(r"C:\state\run\serve.sock"),
},
profile: Some("prod".to_owned()),
release: true,
bundled_pg: true,
role: Some("web".to_owned()),
pin: Some(vec!["critical".to_owned()]),
}
}
#[test]
fn service_record_round_trips_through_toml() {
let record = sample_record();
let parsed = ServiceRecord::parse(&record.to_toml().expect("serialize")).expect("parse");
assert_eq!(parsed, record);
}
#[test]
fn service_record_tolerates_a_minimal_file() {
let minimal = r#"
name = "autumn-demo"
binary = "C:\\proj\\demo.exe"
working_dir = "C:\\proj"
[paths]
runtime = "C:\\state\\run"
data = "C:\\state"
logs = "C:\\state\\logs"
socket = "C:\\state\\run\\serve.sock"
"#;
let parsed = ServiceRecord::parse(minimal).expect("parse minimal record");
assert_eq!(parsed.profile, None);
assert!(!parsed.release);
assert_eq!(parsed.pin, None);
}
#[test]
fn the_off_windows_message_names_the_platform_equivalent() {
let message = unsupported_message("install-service");
assert!(message.contains("systemd"), "{message}");
assert!(message.contains("launchd"), "{message}");
assert!(message.contains("--daemon"), "{message}");
}
}