#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub const LISTEN_FD_ENV: &str = "AUTUMN_UPGRADE_LISTEN_FD";
pub const STATE_FILE_ENV: &str = "AUTUMN_UPGRADE_STATE_FILE";
pub const READY_FILE_ENV: &str = "AUTUMN_UPGRADE_READY_FILE";
pub const GENERATION_ENV: &str = "AUTUMN_UPGRADE_GENERATION";
pub const PREDECESSOR_PID_ENV: &str = "AUTUMN_UPGRADE_PREDECESSOR_PID";
pub const BINARY_ENV: &str = "AUTUMN_UPGRADE_BINARY";
pub const DIR_ENV: &str = "AUTUMN_UPGRADE_DIR";
pub const INHERITED_LISTENER_FD: i32 = 0;
pub trait LiveState: Serialize + DeserializeOwned + Send + Sync + 'static {
const VERSION: u32;
}
pub trait MigrateFrom<Old: LiveState>: LiveState + Sized {
fn migrate_from(old: Old) -> Self;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error(
"live state is frozen: an in-place upgrade is in flight, so this process is not the one \
that would keep the write"
)]
pub struct LiveStateFrozen;
#[derive(Debug)]
pub struct LiveStateHandle<T> {
inner: Arc<std::sync::RwLock<T>>,
frozen: Arc<AtomicBool>,
}
impl<T> Clone for LiveStateHandle<T> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
frozen: Arc::clone(&self.frozen),
}
}
}
impl<T> LiveStateHandle<T> {
#[must_use]
pub fn new(value: T) -> Self {
Self::with_frozen(value, false)
}
#[must_use]
pub(crate) fn new_frozen(value: T) -> Self {
Self::with_frozen(value, true)
}
fn with_frozen(value: T, frozen: bool) -> Self {
Self {
inner: Arc::new(std::sync::RwLock::new(value)),
frozen: Arc::new(AtomicBool::new(frozen)),
}
}
pub fn read<R>(&self, f: impl FnOnce(&T) -> R) -> R {
let guard = self
.inner
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
f(&guard)
}
pub fn write<R>(&self, f: impl FnOnce(&mut T) -> R) -> Result<R, LiveStateFrozen> {
let mut guard = self
.inner
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if self.frozen.load(Ordering::Acquire) {
return Err(LiveStateFrozen);
}
Ok(f(&mut guard))
}
#[must_use]
pub fn is_frozen(&self) -> bool {
self.frozen.load(Ordering::Acquire)
}
fn freeze_and_serialize(&self) -> Result<serde_json::Value, serde_json::Error>
where
T: Serialize,
{
let guard = self
.inner
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.frozen.store(true, Ordering::Release);
serde_json::to_value(&*guard).inspect_err(|_| self.frozen.store(false, Ordering::Release))
}
pub fn freeze_for_test(&self) {
self.frozen.store(true, Ordering::Release);
}
pub fn unfreeze_for_test(&self) {
self.unfreeze();
}
fn unfreeze(&self) {
self.frozen.store(false, Ordering::Release);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct StateEnvelope {
pub generation: u64,
pub version: u32,
pub type_name: String,
pub payload: serde_json::Value,
}
#[derive(Debug, thiserror::Error)]
pub enum AdoptError {
#[error("could not read the carried live-state snapshot at {path}: {source}")]
Read {
path: String,
source: std::io::Error,
},
#[error("the carried live-state snapshot is not a valid envelope: {0}")]
Envelope(String),
#[error(
"the carried live-state snapshot is version {found}, but this build understands version \
{expected}{migratable}; refusing to start with state this build cannot account for"
)]
VersionMismatch {
found: u32,
expected: u32,
migratable: String,
},
#[error("the carried live-state snapshot (version {version}) could not be decoded: {source}")]
Decode {
version: u32,
source: serde_json::Error,
},
}
#[derive(Debug, thiserror::Error)]
pub enum UpgradeError {
#[error(
"this listener cannot be handed to a successor ({0}); in-place upgrade is supported for \
plain TCP listeners only in this release"
)]
UnsupportedListener(&'static str),
#[error("this process cannot be upgraded in place: {0}")]
Unsupported(&'static str),
#[error("could not resolve the binary to upgrade to: {0}")]
Binary(String),
#[error("could not snapshot the designated live state: {0}")]
Snapshot(String),
#[error("in-place upgrade handoff failed: {0}")]
Io(#[from] std::io::Error),
#[error("the successor exited before signalling readiness ({0}); keeping the old build")]
SuccessorExited(String),
#[error("the successor did not signal readiness within {0:?}; keeping the old build")]
ReadyTimeout(std::time::Duration),
}
pub(crate) struct LiveStateRegistry {
freeze_and_snapshot: Box<dyn Fn(u64) -> Result<StateEnvelope, UpgradeError> + Send + Sync>,
unfreeze: Box<dyn Fn() + Send + Sync>,
type_name: &'static str,
}
impl std::fmt::Debug for LiveStateRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LiveStateRegistry")
.field("type_name", &self.type_name)
.finish_non_exhaustive()
}
}
impl LiveStateRegistry {
#[must_use]
pub(crate) fn new<T: LiveState>(handle: &LiveStateHandle<T>) -> Self {
let for_snapshot = handle.clone();
let for_unfreeze = handle.clone();
Self {
freeze_and_snapshot: Box::new(move |generation| {
for_snapshot
.freeze_and_serialize()
.map(|payload| StateEnvelope {
generation,
version: T::VERSION,
type_name: std::any::type_name::<T>().to_owned(),
payload,
})
.map_err(|error| UpgradeError::Snapshot(error.to_string()))
}),
unfreeze: Box::new(move || for_unfreeze.unfreeze()),
type_name: std::any::type_name::<T>(),
}
}
pub(crate) fn freeze_and_snapshot(
&self,
generation: u64,
) -> Result<StateEnvelope, UpgradeError> {
(self.freeze_and_snapshot)(generation)
}
pub(crate) fn unfreeze(&self) {
(self.unfreeze)();
}
#[must_use]
pub(crate) const fn type_name(&self) -> &'static str {
self.type_name
}
}
#[must_use]
pub fn generation() -> u64 {
std::env::var(GENERATION_ENV)
.ok()
.and_then(|raw| raw.parse().ok())
.unwrap_or(0)
}
#[must_use]
pub(crate) fn carried_snapshot_path() -> Option<std::path::PathBuf> {
let raw = std::env::var_os(STATE_FILE_ENV)?;
if raw.is_empty() {
return None;
}
Some(std::path::PathBuf::from(raw))
}
pub(crate) fn read_snapshot(path: &std::path::Path) -> Result<StateEnvelope, AdoptError> {
let raw = std::fs::read(path).map_err(|source| AdoptError::Read {
path: path.display().to_string(),
source,
})?;
let _ = std::fs::remove_file(path);
serde_json::from_slice(&raw).map_err(|error| AdoptError::Envelope(error.to_string()))
}
pub(crate) fn write_snapshot(
path: &std::path::Path,
envelope: &StateEnvelope,
) -> Result<(), std::io::Error> {
let bytes = serde_json::to_vec(envelope).map_err(std::io::Error::other)?;
write_owner_only(path, &bytes)
}
fn write_owner_only(path: &std::path::Path, bytes: &[u8]) -> Result<(), std::io::Error> {
use std::io::Write as _;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(bytes)?;
file.flush()
}
pub(crate) fn decode<T: LiveState>(envelope: &StateEnvelope) -> Result<T, AdoptError> {
decode_as::<T>(envelope, String::new())
}
fn decode_as<T: LiveState>(envelope: &StateEnvelope, migratable: String) -> Result<T, AdoptError> {
if envelope.version != T::VERSION {
return Err(AdoptError::VersionMismatch {
found: envelope.version,
expected: T::VERSION,
migratable,
});
}
decode_payload::<T>(envelope)
}
fn decode_payload<T: DeserializeOwned>(envelope: &StateEnvelope) -> Result<T, AdoptError> {
T::deserialize(&envelope.payload).map_err(|source| AdoptError::Decode {
version: envelope.version,
source,
})
}
pub(crate) fn decode_migrating<Old, New>(envelope: &StateEnvelope) -> Result<New, AdoptError>
where
Old: LiveState,
New: MigrateFrom<Old>,
{
const {
assert!(
Old::VERSION != New::VERSION,
"a live-state migration needs two different LiveState::VERSIONs: bump the new \
shape's VERSION in the same commit that changes its fields"
);
}
if envelope.version == Old::VERSION {
return decode_payload::<Old>(envelope).map(New::migrate_from);
}
decode_as::<New>(
envelope,
format!(
" (or version {}, via the registered migration)",
Old::VERSION
),
)
}
#[cfg(unix)]
#[derive(Debug)]
pub(crate) struct HandoffSocket(std::os::fd::OwnedFd);
#[cfg(unix)]
impl HandoffSocket {
pub(crate) fn from_listener(listener: &tokio::net::TcpListener) -> std::io::Result<Self> {
use std::os::fd::AsFd as _;
listener.as_fd().try_clone_to_owned().map(Self)
}
fn stdio(&self) -> std::io::Result<std::process::Stdio> {
self.0.try_clone().map(std::process::Stdio::from)
}
}
#[cfg(unix)]
#[must_use]
pub(crate) fn adopt_inherited_listener() -> Option<std::net::TcpListener> {
use std::os::fd::AsFd as _;
if !handoff_requested() {
return None;
}
if ADOPTION_ATTEMPTED.set(()).is_err() {
tracing::warn!("the inherited listening socket has already been adopted");
return None;
}
let raw = std::env::var(LISTEN_FD_ENV).unwrap_or_default();
if raw.trim() != INHERITED_LISTENER_FD.to_string() {
tracing::error!(
requested_fd = raw,
"ignoring an inherited listening socket on an unsupported descriptor: this release \
receives the socket as the successor's stdin (fd {INHERITED_LISTENER_FD}), so \
{LISTEN_FD_ENV} must be {INHERITED_LISTENER_FD}"
);
return None;
}
let fd = match std::io::stdin().as_fd().try_clone_to_owned() {
Ok(fd) => fd,
Err(error) => {
tracing::error!(error = %error, "could not adopt the inherited listening socket");
return None;
}
};
let listener = std::net::TcpListener::from(fd);
let Ok(addr) = listener.local_addr() else {
tracing::error!("{LISTEN_FD_ENV} is set but stdin is not a socket; refusing to adopt it");
return None;
};
let connected_peer = listener
.as_fd()
.try_clone_to_owned()
.ok()
.and_then(|fd| std::net::TcpStream::from(fd).peer_addr().ok());
if let Some(peer) = connected_peer {
tracing::error!(
%peer,
"{LISTEN_FD_ENV} is set but stdin is a connected socket, not a listener; \
refusing to adopt it"
);
return None;
}
if let Err(error) = listener.set_nonblocking(true) {
tracing::error!(error = %error, "inherited listening socket is unusable");
return None;
}
match std::fs::File::open("/dev/null")
.and_then(|null| nix::unistd::dup2_stdin(&null).map_err(std::io::Error::from))
{
Ok(()) => {}
Err(error) => {
tracing::error!(
error = %error,
"adopted the inherited listening socket but could not re-point stdin at \
/dev/null; refusing to serve rather than leaking the listener into every \
subprocess this app spawns"
);
return None;
}
}
ADOPTED.set(addr).ok();
tracing::info!(
%addr,
generation = generation(),
predecessor = std::env::var(PREDECESSOR_PID_ENV).unwrap_or_default(),
"adopted the listening socket handed over by the previous build"
);
Some(listener)
}
#[must_use]
pub(crate) fn handoff_requested() -> bool {
std::env::var_os(LISTEN_FD_ENV).is_some_and(|value| !value.is_empty())
}
#[must_use]
pub(crate) fn adopted_addr() -> Option<std::net::SocketAddr> {
ADOPTED.get().copied()
}
static ADOPTION_ATTEMPTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
static ADOPTED: std::sync::OnceLock<std::net::SocketAddr> = std::sync::OnceLock::new();
pub(crate) fn verify_handover_complete() -> Result<(), String> {
if handoff_requested() && adopted_addr().is_none() {
return Err(format!(
"this build was started by an in-place upgrade and was handed a listening socket, \
but never adopted it (see the errors above). Serving would abandon the address \
the previous build is still answering on, so this build refuses to start; the \
previous build keeps serving. In-place upgrade requires the new build to bind a \
plain TCP listener ({LISTEN_FD_ENV} was set)"
));
}
if let Some(path) = carried_snapshot_path()
&& path.exists()
{
return Err(format!(
"this build was handed the previous build's live state ({}) but never adopted it: \
nothing called AppBuilder::with_live_state(...) or with_live_state_from(...). \
Starting would silently drop that state, so this build refuses to start and the \
previous build keeps serving",
path.display()
));
}
Ok(())
}
pub(crate) fn record_startup_exe() {
if let Ok(path) = std::env::current_exe() {
let _ = startup_exe_cell().set(path);
}
}
fn startup_exe_cell() -> &'static std::sync::OnceLock<std::path::PathBuf> {
static STARTUP_EXE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
&STARTUP_EXE
}
pub(crate) fn upgrade_binary() -> Result<std::path::PathBuf, UpgradeError> {
let configured = std::env::var_os(BINARY_ENV).filter(|value| !value.is_empty());
let path = match configured {
Some(value) => std::path::PathBuf::from(value),
None => startup_exe_cell().get().cloned().map_or_else(
|| std::env::current_exe().map_err(|error| UpgradeError::Binary(error.to_string())),
Ok,
)?,
};
if !path.is_file() {
return Err(UpgradeError::Binary(format!(
"{} is not a file; set {BINARY_ENV} to the new binary, or install it at that path \
before signalling",
path.display()
)));
}
Ok(path)
}
pub(crate) fn publish_upgrade_readiness() -> Result<bool, std::io::Error> {
let Some(path) = std::env::var_os(READY_FILE_ENV).filter(|value| !value.is_empty()) else {
return Ok(false);
};
let path = std::path::PathBuf::from(path);
let mut tmp = path.clone();
tmp.as_mut_os_string().push(".tmp");
write_owner_only(&tmp, generation().to_string().as_bytes())
.and_then(|()| std::fs::rename(&tmp, &path))
.inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})?;
Ok(true)
}
#[cfg(unix)]
#[derive(Debug, Clone, Copy)]
pub(crate) struct Handover {
pub successor_pid: u32,
pub generation: u64,
pub elapsed: std::time::Duration,
}
#[cfg(unix)]
pub(crate) struct UpgradePlan<'a> {
pub socket: &'a HandoffSocket,
pub registry: Option<Arc<LiveStateRegistry>>,
pub ready_timeout: std::time::Duration,
pub clock: Arc<dyn crate::time::ClockSource>,
pub entropy: Arc<dyn crate::entropy::Entropy>,
}
#[cfg(unix)]
pub(crate) async fn upgrade_in_place(plan: UpgradePlan<'_>) -> Result<Handover, UpgradeError> {
let started = plan.clock.monotonic();
let next_generation = generation().saturating_add(1);
let binary = upgrade_binary()?;
let mut handoff = Handoff {
dir: create_handoff_dir(next_generation, plan.entropy.as_ref())?,
child: None,
registry: plan.registry.clone(),
completed: false,
};
let successor_pid =
spawn_and_await_successor(&plan, &binary, &mut handoff, next_generation).await?;
handoff.completed = true;
Ok(Handover {
successor_pid,
generation: next_generation,
elapsed: plan.clock.monotonic().saturating_duration_since(started),
})
}
#[cfg(unix)]
struct Handoff {
dir: std::path::PathBuf,
child: Option<std::process::Child>,
registry: Option<Arc<LiveStateRegistry>>,
completed: bool,
}
#[cfg(unix)]
impl Drop for Handoff {
fn drop(&mut self) {
let ready_path = self.dir.join("ready");
let taken_over = self.completed
|| self
.child
.as_mut()
.is_some_and(|child| successor_took_over(&ready_path, child));
if !taken_over {
if let Some(child) = &mut self.child {
let _ = child.kill();
let _ = child.wait();
}
if let Some(registry) = &self.registry {
registry.unfreeze();
}
}
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[cfg(unix)]
fn successor_took_over(ready_path: &std::path::Path, child: &mut std::process::Child) -> bool {
ready_path.exists() && matches!(child.try_wait(), Ok(None))
}
#[cfg(unix)]
async fn spawn_and_await_successor(
plan: &UpgradePlan<'_>,
binary: &std::path::Path,
handoff: &mut Handoff,
next_generation: u64,
) -> Result<u32, UpgradeError> {
let ready_path = handoff.dir.join("ready");
let state_path = match &plan.registry {
Some(registry) => {
let envelope = registry.freeze_and_snapshot(next_generation)?;
let path = handoff.dir.join("state.json");
write_snapshot(&path, &envelope)?;
tracing::info!(
state = registry.type_name(),
version = envelope.version,
"in-place upgrade: designated live state snapshotted and frozen"
);
Some(path)
}
None => None,
};
let mut command = std::process::Command::new(binary);
command.args(std::env::args_os().skip(1));
command.env(LISTEN_FD_ENV, INHERITED_LISTENER_FD.to_string());
command.env(GENERATION_ENV, next_generation.to_string());
command.env(PREDECESSOR_PID_ENV, std::process::id().to_string());
command.env(READY_FILE_ENV, &ready_path);
match &state_path {
Some(path) => command.env(STATE_FILE_ENV, path),
None => command.env_remove(STATE_FILE_ENV),
};
command.stdin(plan.socket.stdio()?);
let child = handoff.child.insert(command.spawn()?);
let successor_pid = child.id();
tracing::info!(
successor_pid,
binary = %binary.display(),
generation = next_generation,
"in-place upgrade: successor spawned, waiting for it to serve"
);
let deadline = plan.clock.monotonic().saturating_add(plan.ready_timeout);
loop {
if ready_path.exists() {
return match child.try_wait() {
Ok(Some(status)) => Err(UpgradeError::SuccessorExited(format!(
"{status}, immediately after signalling readiness"
))),
Ok(None) => Ok(successor_pid),
Err(error) => Err(UpgradeError::Io(error)),
};
}
match child.try_wait() {
Ok(Some(status)) => return Err(UpgradeError::SuccessorExited(status.to_string())),
Ok(None) => {}
Err(error) => return Err(UpgradeError::Io(error)),
}
if plan.clock.monotonic() >= deadline {
if successor_took_over(&ready_path, child) {
return Ok(successor_pid);
}
return Err(UpgradeError::ReadyTimeout(plan.ready_timeout));
}
tokio::time::sleep(READY_POLL_INTERVAL).await;
}
}
#[cfg(unix)]
const READY_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20);
#[cfg(unix)]
fn handoff_base_dir() -> std::path::PathBuf {
if let Some(configured) = std::env::var_os(DIR_ENV).filter(|value| !value.is_empty()) {
return std::path::PathBuf::from(configured);
}
if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty()) {
let runtime = std::path::PathBuf::from(runtime);
if runtime.is_dir() {
return runtime;
}
}
std::env::temp_dir()
}
#[cfg(unix)]
fn create_handoff_dir(
generation: u64,
entropy: &dyn crate::entropy::Entropy,
) -> Result<std::path::PathBuf, std::io::Error> {
use std::os::unix::fs::DirBuilderExt as _;
let base = handoff_base_dir();
std::fs::create_dir_all(&base)?;
let pid = std::process::id();
for _ in 0..16u32 {
let nonce = entropy.next_u64();
let dir = base.join(format!("autumn-upgrade-{pid}-{generation}-{nonce:016x}"));
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
Ok(()) => return Ok(dir),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"could not create a private handoff directory under {}",
base.display()
),
))
}
#[derive(Debug)]
pub(crate) struct LiveStateInstallFailure(pub(crate) String);
#[macro_export]
macro_rules! state_migration {
(from $old:ty as $binding:ident => $new:ty {
match $scrutinee:ident {
$( $variant:ident
$( ( $($tuple:tt)* ) )?
$( { $($named:tt)* } )?
=> $arm:expr ),+ $(,)?
}
}) => {
const _: () = assert!(
<$old as $crate::upgrade::LiveState>::VERSION
!= <$new as $crate::upgrade::LiveState>::VERSION,
"a live-state migration needs two different LiveState::VERSIONs: bump the new \
shape's VERSION in the same commit that changes its fields, or the snapshot \
cannot be told apart on the wire and this migration would never run"
);
impl $crate::upgrade::MigrateFrom<$old> for $new {
fn migrate_from($binding: $old) -> Self {
#[allow(non_camel_case_types)]
type __AutumnOldLiveState = $old;
match $scrutinee {
$( __AutumnOldLiveState::$variant
$( ( $($tuple)* ) )?
$( { $($named)* } )? => $arm ),+
}
}
}
};
(from $old:ty as $binding:ident => $new:ty {
$( $field:ident : $value:expr ),+ $(,)?
}) => {
const _: () = assert!(
<$old as $crate::upgrade::LiveState>::VERSION
!= <$new as $crate::upgrade::LiveState>::VERSION,
"a live-state migration needs two different LiveState::VERSIONs: bump the new \
shape's VERSION in the same commit that changes its fields, or the snapshot \
cannot be told apart on the wire and this migration would never run"
);
impl $crate::upgrade::MigrateFrom<$old> for $new {
fn migrate_from($binding: $old) -> Self {
Self { $( $field: $value ),+ }
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
struct StatsV1 {
hits: u64,
note: String,
}
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
struct Stats {
hits: u64,
note: String,
upgrades: u64,
}
impl LiveState for StatsV1 {
const VERSION: u32 = 1;
}
impl LiveState for Stats {
const VERSION: u32 = 2;
}
crate::state_migration! {
from StatsV1 as old => Stats {
hits: old.hits,
note: old.note,
upgrades: 1,
}
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
enum ModeV1 {
Fast,
Slow(u8),
}
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
enum Mode {
Fast,
Slow { level: u8 },
}
impl LiveState for ModeV1 {
const VERSION: u32 = 1;
}
impl LiveState for Mode {
const VERSION: u32 = 2;
}
crate::state_migration! {
from ModeV1 as old => Mode {
match old {
Fast => Mode::Fast,
Slow(level) => Mode::Slow { level },
}
}
}
fn envelope_of<T: LiveState>(value: &T, generation: u64) -> StateEnvelope {
StateEnvelope {
generation,
version: T::VERSION,
type_name: std::any::type_name::<T>().to_owned(),
payload: serde_json::to_value(value).expect("test state serializes"),
}
}
#[test]
fn a_value_written_before_the_snapshot_is_readable_after_adoption() {
let handle = LiveStateHandle::new(StatsV1::default());
handle
.write(|s| {
s.hits = 41;
s.note = "carried".to_owned();
})
.expect("state is not frozen before an upgrade");
let registry = LiveStateRegistry::new(&handle);
let envelope = registry
.freeze_and_snapshot(1)
.expect("designated state snapshots");
let adopted: StatsV1 = decode(&envelope).expect("same-shape adoption decodes");
assert_eq!(adopted.hits, 41);
assert_eq!(adopted.note, "carried");
}
#[test]
fn snapshotting_freezes_writes_so_they_cannot_be_lost_silently() {
let handle = LiveStateHandle::new(StatsV1::default());
let registry = LiveStateRegistry::new(&handle);
assert!(!handle.is_frozen());
registry.freeze_and_snapshot(1).expect("snapshots");
assert!(handle.is_frozen());
assert_eq!(handle.write(|s| s.hits += 1), Err(LiveStateFrozen));
assert_eq!(handle.read(|s| s.hits), 0);
}
#[test]
fn an_aborted_upgrade_unfreezes_the_state_so_the_old_build_keeps_serving() {
let handle = LiveStateHandle::new(StatsV1::default());
let registry = LiveStateRegistry::new(&handle);
registry.freeze_and_snapshot(1).expect("snapshots");
registry.unfreeze();
assert!(!handle.is_frozen());
assert_eq!(handle.write(|s| s.hits += 1), Ok(()));
}
#[test]
fn a_shape_change_is_carried_across_by_the_registered_migration() {
let old = StatsV1 {
hits: 7,
note: "before".to_owned(),
};
let envelope = envelope_of(&old, 1);
let new: Stats = decode_migrating::<StatsV1, Stats>(&envelope).expect("migrates");
assert_eq!(
new,
Stats {
hits: 7,
note: "before".to_owned(),
upgrades: 1,
}
);
}
#[test]
fn the_current_shape_is_adopted_without_a_migration_hop() {
let current = Stats {
hits: 3,
note: "n".to_owned(),
upgrades: 9,
};
let envelope = envelope_of(¤t, 4);
let adopted: Stats = decode_migrating::<StatsV1, Stats>(&envelope).expect("decodes");
assert_eq!(adopted, current);
}
#[test]
fn an_enum_shape_migration_maps_every_variant() {
assert_eq!(Mode::migrate_from(ModeV1::Fast), Mode::Fast);
assert_eq!(Mode::migrate_from(ModeV1::Slow(3)), Mode::Slow { level: 3 });
}
#[test]
fn an_unknown_shape_version_is_refused_rather_than_silently_defaulted() {
let envelope = StateEnvelope {
generation: 1,
version: 99,
type_name: "Stats".to_owned(),
payload: serde_json::json!({}),
};
let err = decode_migrating::<StatsV1, Stats>(&envelope)
.expect_err("an unaccountable version must not be adopted");
assert!(
matches!(
err,
AdoptError::VersionMismatch {
found: 99,
expected: 2,
..
}
),
"unexpected error: {err}"
);
assert!(err.to_string().contains("version 1"), "{err}");
}
#[test]
fn a_payload_that_does_not_fit_its_declared_shape_is_refused() {
let envelope = StateEnvelope {
generation: 1,
version: 1,
type_name: "StatsV1".to_owned(),
payload: serde_json::json!({ "hits": "not-a-number" }),
};
let err = decode_migrating::<StatsV1, Stats>(&envelope).expect_err("must not decode");
assert!(
matches!(err, AdoptError::Decode { version: 1, .. }),
"unexpected error: {err}"
);
}
#[test]
fn a_snapshot_file_round_trips_and_is_consumed_on_read() {
let dir = std::env::temp_dir().join(format!(
"autumn-upgrade-test-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("state.json");
let envelope = envelope_of(
&StatsV1 {
hits: 5,
note: "n".to_owned(),
},
2,
);
write_snapshot(&path, &envelope).expect("writes");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
assert_eq!(mode & 0o777, 0o600, "snapshot must be owner-only");
}
let read = read_snapshot(&path).expect("reads");
assert_eq!(read.version, 1);
assert_eq!(read.generation, 2);
assert!(
!path.exists(),
"the snapshot must be consumed, not left holding app state on disk"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_handover_this_build_did_not_complete_is_refused_rather_than_half_taken() {
temp_env::with_var(LISTEN_FD_ENV, Some("0"), || {
let refusal = verify_handover_complete().expect_err("must refuse");
assert!(refusal.contains("never adopted it"), "{refusal}");
});
let dir = std::env::temp_dir().join(format!(
"autumn-upgrade-test-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("state.json");
write_snapshot(&path, &envelope_of(&StatsV1::default(), 1)).expect("writes");
temp_env::with_var(STATE_FILE_ENV, Some(&path), || {
let refusal = verify_handover_complete().expect_err("must refuse");
assert!(refusal.contains("never adopted it"), "{refusal}");
});
let _ = read_snapshot(&path).expect("reads");
temp_env::with_var(STATE_FILE_ENV, Some(&path), || {
verify_handover_complete().expect("a consumed snapshot is a completed handover");
});
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_successor_cannot_acknowledge_a_write_its_upgrade_might_still_discard() {
let handle = LiveStateHandle::new_frozen(StatsV1::default());
assert!(handle.is_frozen());
assert_eq!(handle.write(|s| s.hits += 1), Err(LiveStateFrozen));
assert_eq!(handle.read(|s| s.hits), 0);
LiveStateRegistry::new(&handle).unfreeze();
assert_eq!(handle.write(|s| s.hits += 1), Ok(()));
assert_eq!(handle.read(|s| s.hits), 1);
}
#[test]
fn readiness_that_cannot_be_published_is_an_error_not_a_shrug() {
let dir = std::env::temp_dir().join(format!(
"autumn-upgrade-test-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&dir);
temp_env::with_var(READY_FILE_ENV, Some(dir.join("ready")), || {
publish_upgrade_readiness().expect_err("an unwritable readiness path must be an error");
});
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("ready");
temp_env::with_var(READY_FILE_ENV, Some(&path), || {
assert!(
publish_upgrade_readiness().expect("publishes"),
"a successor with a waiting predecessor reports that it released it"
);
});
assert!(path.exists(), "the predecessor polls for this file");
assert!(
!dir.join("ready.tmp").exists(),
"the file is renamed into place, never left half-written beside it"
);
temp_env::with_vars_unset([READY_FILE_ENV], || {
assert!(
!publish_upgrade_readiness().expect("a cold start publishes nothing"),
"a cold start has no predecessor to release"
);
});
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
fn handoff_for_test(dir: std::path::PathBuf, registry: Arc<LiveStateRegistry>) -> Handoff {
let child = std::process::Command::new("sleep")
.arg("5")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("a stand-in successor process");
Handoff {
dir,
child: Some(child),
registry: Some(registry),
completed: false,
}
}
#[cfg(unix)]
fn process_is_alive(pid: u32) -> bool {
std::process::Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
#[cfg(unix)]
fn scratch_dir(line: u32) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("autumn-upgrade-test-{}-{line}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
#[cfg(unix)]
fn an_abandoned_upgrade_kills_a_successor_that_never_took_over() {
let dir = scratch_dir(line!());
let handle = LiveStateHandle::new(StatsV1::default());
let registry = Arc::new(LiveStateRegistry::new(&handle));
registry.freeze_and_snapshot(1).expect("snapshots");
let handoff = handoff_for_test(dir.clone(), registry);
let pid = handoff.child.as_ref().expect("child").id();
drop(handoff);
assert!(
!handle.is_frozen(),
"an abandoned upgrade must hand the state back, or the old build serves the rest \
of its life refusing writes"
);
assert!(
!dir.exists(),
"the handoff directory must not outlive the handoff"
);
assert!(
!process_is_alive(pid),
"the half-started successor must not be left holding the port"
);
}
#[test]
#[cfg(unix)]
fn a_successor_that_already_published_readiness_survives_an_abandoned_wait() {
let dir = scratch_dir(line!());
std::fs::write(dir.join("ready"), "1").expect("the successor published readiness");
let handle = LiveStateHandle::new(StatsV1::default());
let registry = Arc::new(LiveStateRegistry::new(&handle));
registry.freeze_and_snapshot(1).expect("snapshots");
let handoff = handoff_for_test(dir.clone(), registry);
let pid = handoff.child.as_ref().expect("child").id();
drop(handoff);
assert!(
handle.is_frozen(),
"the successor is writable, so this process must stay frozen: two writable copies \
of one block is the divergence the freeze exists to prevent"
);
assert!(
process_is_alive(pid),
"a successor that has taken over must not be killed by a wait this process \
abandoned"
);
assert!(
!dir.exists(),
"the snapshot has been read and readiness published, so the directory holding \
application state goes on this path too"
);
let _ = std::process::Command::new("kill")
.arg("-9")
.arg(pid.to_string())
.status();
}
#[test]
fn an_ordinary_cold_start_has_no_handover_to_complete() {
temp_env::with_vars_unset([LISTEN_FD_ENV, STATE_FILE_ENV], || {
assert!(!handoff_requested());
verify_handover_complete().expect("a cold start is not a half-completed handover");
});
}
#[test]
fn the_binary_to_upgrade_into_must_exist_before_anything_is_frozen() {
let missing = std::env::temp_dir().join("autumn-upgrade-no-such-binary");
let _ = std::fs::remove_file(&missing);
temp_env::with_var(BINARY_ENV, Some(&missing), || {
let error = upgrade_binary().expect_err("a binary that is not there is not a target");
assert!(
matches!(error, UpgradeError::Binary(_)),
"unexpected error: {error}"
);
assert!(error.to_string().contains("is not a file"), "{error}");
});
}
#[test]
#[cfg(unix)]
fn the_handoff_directory_is_private_to_this_user() {
let base = std::env::temp_dir().join(format!(
"autumn-upgrade-test-base-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&base);
let entropy = crate::entropy::SeededEntropy::new(1674);
let dir = temp_env::with_var(DIR_ENV, Some(&base), || {
create_handoff_dir(1, &entropy).expect("creates a private directory")
});
let mode = {
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(&dir).expect("stat").permissions().mode()
};
assert_eq!(mode & 0o777, 0o700, "handoff directory must be owner-only");
let second = temp_env::with_var(DIR_ENV, Some(&base), || {
create_handoff_dir(1, &entropy).expect("creates another private directory")
});
assert_ne!(
dir, second,
"two handoffs at the same pid and generation must not collide on a name"
);
let name = dir
.file_name()
.and_then(|name| name.to_str())
.expect("directory name");
assert!(
!name.ends_with(&format!("-{}-1-0", std::process::id())),
"the name must not be a bare pid/generation counter: {name}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_missing_snapshot_file_is_an_error_not_an_empty_adoption() {
let path = std::env::temp_dir().join("autumn-upgrade-test-does-not-exist.json");
let _ = std::fs::remove_file(&path);
let err = read_snapshot(&path).expect_err("must not silently succeed");
assert!(matches!(err, AdoptError::Read { .. }), "{err}");
}
}