use crate::record::write_atomic;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub const MARKER_FILE: &str = "daemon.reload.json";
pub const ENV_RELOAD_REQUEST: &str = "RIGHTKIT_RELOAD_REQUEST";
pub const ENV_RELOAD_HASH: &str = "RIGHTKIT_RELOAD_HASH";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReloadPhase {
Starting,
Draining,
Handoff,
SocketReady,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReloadMarker {
pub request_id: String,
pub hash: String,
pub phase: ReloadPhase,
pub pid: u32,
pub timestamp_ms: u64,
pub endpoint: String,
#[serde(default)]
pub detail: String,
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn read_marker(dir: &Path) -> io::Result<Option<ReloadMarker>> {
match std::fs::read(dir.join(MARKER_FILE)) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
fn write_marker(dir: &Path, marker: &ReloadMarker) -> io::Result<()> {
let body = serde_json::to_vec_pretty(marker)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
write_atomic(&dir.join(MARKER_FILE), &body)
}
pub fn publish_socket_ready(
dir: &Path,
request_id: &str,
hash: &str,
endpoint: &str,
) -> io::Result<()> {
let Some(mut marker) = read_marker(dir)? else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"no reload is in progress",
));
};
if marker.request_id != request_id || marker.hash != hash || marker.phase == ReloadPhase::Failed
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"the reload marker is for another request, or the reload failed",
));
}
marker.phase = ReloadPhase::SocketReady;
marker.pid = std::process::id();
marker.timestamp_ms = now_ms();
marker.endpoint = endpoint.to_string();
write_marker(dir, &marker)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReloadRequest {
pub request_id: String,
pub hash: String,
pub endpoint: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StagedBinary {
pub staged: PathBuf,
pub target: PathBuf,
}
pub trait ReloadHost {
fn persist_recovery(&self, request: &ReloadRequest) -> Result<(), String>;
fn fence_intake(&self, request: &ReloadRequest) -> Result<(), String>;
fn drain(&self, request: &ReloadRequest) -> Result<(), String>;
fn launch_replacement(&self, request: &ReloadRequest) -> Result<u32, String>;
fn wait_ready(
&self,
request: &ReloadRequest,
replacement_pid: u32,
timeout: Duration,
) -> Result<(), String>;
fn resume(&self, request: &ReloadRequest) -> Result<(), String>;
fn rollback(&self, request: &ReloadRequest, replacement_pid: Option<u32>)
-> Result<(), String>;
}
pub fn poll_ready(dir: &Path, request: &ReloadRequest) -> bool {
matches!(
read_marker(dir),
Ok(Some(m)) if m.phase == ReloadPhase::SocketReady
&& m.request_id == request.request_id
&& m.hash == request.hash
)
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ReloadError {
InFlight,
Marker(io::Error),
Stage { stage: &'static str, error: String },
StageAndRollback {
stage: &'static str,
error: String,
rollback: Option<String>,
marker: Option<String>,
},
}
impl std::fmt::Display for ReloadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InFlight => write!(f, "a reload is already running"),
Self::Marker(e) => write!(f, "the reload marker could not be written: {e}"),
Self::Stage { stage, error } => write!(f, "reload failed at {stage}: {error}"),
Self::StageAndRollback {
stage,
error,
rollback,
marker,
} => {
write!(f, "reload failed at {stage}: {error}")?;
if let Some(r) = rollback {
write!(f, "; rollback failed: {r}")?;
}
if let Some(m) = marker {
write!(f, "; the failed marker was not written: {m}")?;
}
Ok(())
}
}
}
}
impl std::error::Error for ReloadError {}
#[derive(Debug)]
pub struct ActivationFence {
target: PathBuf,
backup: PathBuf,
journal: PathBuf,
staged: PathBuf,
}
#[derive(Serialize, Deserialize)]
struct Journal {
target: PathBuf,
backup: PathBuf,
staged: PathBuf,
swapped: bool,
}
fn side(path: &Path, suffix: &str) -> PathBuf {
let mut name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(suffix);
path.with_file_name(name)
}
impl ActivationFence {
pub fn new(binary: &StagedBinary) -> Self {
Self {
target: binary.target.clone(),
backup: side(&binary.target, ".reload-previous"),
journal: side(&binary.target, ".reload-journal"),
staged: binary.staged.clone(),
}
}
fn write_journal(&self, swapped: bool) -> io::Result<()> {
let body = serde_json::to_vec(&Journal {
target: self.target.clone(),
backup: self.backup.clone(),
staged: self.staged.clone(),
swapped,
})
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
write_atomic(&self.journal, &body)
}
pub fn activate(&self) -> io::Result<()> {
if !self.staged.is_file() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"the staged binary is missing",
));
}
let _ = std::fs::remove_file(&self.backup);
self.write_journal(false)?;
std::fs::rename(&self.target, &self.backup)?;
if let Err(e) = std::fs::rename(&self.staged, &self.target) {
let _ = std::fs::rename(&self.backup, &self.target);
let _ = std::fs::remove_file(&self.journal);
return Err(e);
}
self.write_journal(true)
}
pub fn rollback(&self) -> io::Result<()> {
if self.backup.is_file() {
let _ = std::fs::rename(&self.target, &self.staged);
std::fs::rename(&self.backup, &self.target)?;
}
match std::fs::remove_file(&self.journal) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub fn commit(&self) -> io::Result<()> {
let _ = std::fs::remove_file(&self.backup);
match std::fs::remove_file(&self.journal) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub fn recover(binary: &StagedBinary, dir: &Path) -> io::Result<bool> {
let fence = Self::new(binary);
let Ok(bytes) = std::fs::read(&fence.journal) else {
return Ok(false);
};
let Ok(journal) = serde_json::from_slice::<Journal>(&bytes) else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"unreadable reload journal",
));
};
let ready = matches!(read_marker(dir), Ok(Some(m)) if m.phase == ReloadPhase::SocketReady);
if journal.swapped && ready {
fence.commit()?;
return Ok(false);
}
fence.rollback()?;
Ok(true)
}
}
pub struct ReloadCoordinator {
dir: PathBuf,
ready_timeout: Duration,
running: Mutex<()>,
}
impl ReloadCoordinator {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
ready_timeout: Duration::from_secs(30),
running: Mutex::new(()),
}
}
#[must_use]
pub fn ready_timeout(mut self, timeout: Duration) -> Self {
self.ready_timeout = timeout;
self
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn run(
&self,
host: &dyn ReloadHost,
request: &ReloadRequest,
staged: Option<&StagedBinary>,
) -> Result<(), ReloadError> {
let Ok(_one) = self.running.try_lock() else {
return Err(ReloadError::InFlight);
};
let mut marker = ReloadMarker {
request_id: request.request_id.clone(),
hash: request.hash.clone(),
phase: ReloadPhase::Starting,
pid: std::process::id(),
timestamp_ms: now_ms(),
endpoint: request.endpoint.clone(),
detail: String::new(),
};
write_marker(&self.dir, &marker).map_err(ReloadError::Marker)?;
let fence = staged.map(ActivationFence::new);
let mut replacement = None;
let outcome = self.stages(host, request, &mut marker, fence.as_ref(), &mut replacement);
match outcome {
Ok(()) => {
if let Some(fence) = &fence {
let _ = fence.commit();
}
Ok(())
}
Err((stage, error)) => {
marker.phase = ReloadPhase::Failed;
marker.detail = format!("{stage}: {error}");
marker.timestamp_ms = now_ms();
let marker_error = write_marker(&self.dir, &marker)
.err()
.map(|e| e.to_string());
let mut rollback_error = None;
if let Some(fence) = &fence {
if let Err(e) = fence.rollback() {
rollback_error = Some(format!("activation: {e}"));
}
}
if let Err(e) = host.rollback(request, replacement) {
rollback_error = Some(match rollback_error {
Some(prior) => format!("{prior}; host: {e}"),
None => format!("host: {e}"),
});
}
if rollback_error.is_none() && marker_error.is_none() {
Err(ReloadError::Stage { stage, error })
} else {
Err(ReloadError::StageAndRollback {
stage,
error,
rollback: rollback_error,
marker: marker_error,
})
}
}
}
}
fn stages(
&self,
host: &dyn ReloadHost,
request: &ReloadRequest,
marker: &mut ReloadMarker,
fence: Option<&ActivationFence>,
replacement: &mut Option<u32>,
) -> Result<(), (&'static str, String)> {
host.persist_recovery(request)
.map_err(|e| ("persist_recovery", e))?;
host.fence_intake(request)
.map_err(|e| ("fence_intake", e))?;
self.advance(marker, ReloadPhase::Draining)?;
host.drain(request).map_err(|e| ("drain", e))?;
self.advance(marker, ReloadPhase::Handoff)?;
if let Some(fence) = fence {
fence.activate().map_err(|e| ("activate", e.to_string()))?;
}
let pid = host
.launch_replacement(request)
.map_err(|e| ("launch_replacement", e))?;
*replacement = Some(pid);
host.wait_ready(request, pid, self.ready_timeout)
.map_err(|e| ("wait_ready", e))?;
if !poll_ready(&self.dir, request) {
return Err((
"wait_ready",
"the replacement did not publish readiness for this request".to_string(),
));
}
host.resume(request).map_err(|e| ("resume", e))
}
fn advance(
&self,
marker: &mut ReloadMarker,
phase: ReloadPhase,
) -> Result<(), (&'static str, String)> {
marker.phase = phase;
marker.timestamp_ms = now_ms();
write_marker(&self.dir, marker).map_err(|e| ("marker", e.to_string()))
}
}