use super::*;
use crate::registry::RegistrationSlot;
pub(crate) fn cgroup_name(module_id: &str, alternate: bool) -> String {
let mut name = String::with_capacity(module_id.len() + 5);
for byte in module_id.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.') {
name.push(char::from(byte));
} else {
name.push_str(&format!("_{byte:02x}"));
}
}
if alternate {
name.push_str("_swap");
}
name
}
enum Warm {
Ready(ConnectionId),
Failed(CandidateFailure),
Interrupted {
command: Option<SupervisorCommand>,
connection: Option<ConnectionId>,
},
}
#[derive(Default)]
pub(super) struct SwapEnd {
pub(super) requeue: Vec<SupervisorCommand>,
}
struct CandidateFailure {
arm: SwapFailureArm,
detail: String,
exit: Option<ExitReport>,
connection: Option<ConnectionId>,
}
impl CandidateFailure {
fn into_error(self, module_id: &str) -> SuperviseError {
SuperviseError::SwapFailed {
module_id: module_id.to_string(),
arm: self.arm,
detail: self.detail,
candidate_exit: self.exit,
}
}
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_swap(
spec: &ModuleSpec,
runtime: &SupervisorRuntimeConfig,
registry: &Registry,
process_liveness: &SupervisorProcessLiveness,
snapshot: &SharedSnapshot,
child: &mut Option<SupervisedChild>,
commands: &mut mpsc::Receiver<SupervisorCommand>,
ready_timeout: Duration,
reply: oneshot::Sender<Result<(), SuperviseError>>,
) -> SwapEnd {
let mut end = SwapEnd::default();
run_swap_inner(
spec,
runtime,
registry,
process_liveness,
snapshot,
child,
commands,
ready_timeout,
reply,
&mut end,
)
.await;
end
}
#[allow(clippy::too_many_arguments)]
async fn run_swap_inner(
spec: &ModuleSpec,
runtime: &SupervisorRuntimeConfig,
registry: &Registry,
process_liveness: &SupervisorProcessLiveness,
snapshot: &SharedSnapshot,
child: &mut Option<SupervisedChild>,
commands: &mut mpsc::Receiver<SupervisorCommand>,
ready_timeout: Duration,
reply: oneshot::Sender<Result<(), SuperviseError>>,
end: &mut SwapEnd,
) {
let module_id = spec.module_id.as_str();
let (forwarding, handle, incumbent_connection) =
match admit_swap(spec, runtime, registry, snapshot, child) {
Ok(admitted) => admitted,
Err(err) => {
info!(module_id, error = %err, "swap refused before spawning a candidate");
let _ = reply.send(Err(err));
return;
}
};
let candidate_alternate = !lock_snapshot(snapshot)
.map(|state| state.in_alternate_slot)
.unwrap_or(false);
let mut candidate = match spawn_child_in_slot(
spec,
runtime.connection_file_path.as_deref(),
Some(&handle),
&runtime.stderr_ring,
runtime.capture_logs_dir.as_deref(),
#[cfg(target_os = "linux")]
runtime.cgroup_placement.as_ref(),
SpawnRole::SwapCandidate,
candidate_alternate,
) {
Ok(candidate) => candidate,
Err(err) => {
handle.close_swap(module_id);
warn!(module_id, error = %err, "swap candidate failed to spawn; incumbent untouched");
let _ = reply.send(Err(SuperviseError::SwapFailed {
module_id: module_id.to_string(),
arm: SwapFailureArm::SpawnFailed,
detail: err.to_string(),
candidate_exit: None,
}));
return;
}
};
info!(
module_id,
candidate_pid = candidate.pid,
cgroup = %cgroup_name(module_id, candidate_alternate),
incumbent_connection_id = incumbent_connection.get(),
ready_timeout_ms = ready_timeout.as_millis() as u64,
"swap candidate spawned; routing stays on the incumbent until it is ready"
);
let warm = warm_candidate(
module_id,
registry,
&mut candidate,
incumbent_connection,
ready_timeout,
commands,
end,
)
.await;
let candidate_connection = match warm {
Warm::Ready(connection) => connection,
Warm::Interrupted {
command,
connection,
} => {
let failure = CandidateFailure {
arm: SwapFailureArm::Interrupted,
detail: "an operator stop, disable or retire arrived while the candidate warmed"
.to_string(),
exit: None,
connection,
};
abandon_candidate(
module_id,
registry,
&forwarding,
&handle,
candidate,
&failure,
)
.await;
let _ = reply.send(Err(failure.into_error(module_id)));
end.requeue.extend(command);
return;
}
Warm::Failed(failure) => {
abandon_candidate(
module_id,
registry,
&forwarding,
&handle,
candidate,
&failure,
)
.await;
let _ = reply.send(Err(failure.into_error(module_id)));
return;
}
};
if let Err(failure) = probe_candidate(
module_id,
runtime,
registry,
&forwarding,
candidate_connection,
)
.await
{
abandon_candidate(
module_id,
registry,
&forwarding,
&handle,
candidate,
&failure,
)
.await;
let _ = reply.send(Err(failure.into_error(module_id)));
return;
}
let forwarding_cutover = match forwarding.cutover_candidate(module_id) {
Ok(Some(cutover)) => cutover,
Ok(None) | Err(_) => {
let failure = CandidateFailure {
arm: SwapFailureArm::CutoverLost,
detail: "the candidate's connection closed just before cutover".to_string(),
exit: None,
connection: Some(candidate_connection),
};
abandon_candidate(
module_id,
registry,
&forwarding,
&handle,
candidate,
&failure,
)
.await;
let _ = reply.send(Err(failure.into_error(module_id)));
return;
}
};
let promoted = match registry.promote_candidate(module_id) {
Ok(Some(cutover)) => cutover.promoted,
Ok(None) | Err(_) => {
error!(
module_id,
"swap candidate vanished between forwarding cutover and registry promotion; falling back to a plain restart"
);
let failure = CandidateFailure {
arm: SwapFailureArm::CutoverLost,
detail: "the candidate's registration closed during cutover; the module is being restarted plainly".to_string(),
exit: None,
connection: Some(candidate_connection),
};
abandon_candidate(
module_id,
registry,
&forwarding,
&handle,
candidate,
&failure,
)
.await;
let _ = reply.send(Err(failure.into_error(module_id)));
if let Err(err) = restart_child(
spec,
runtime,
registry,
process_liveness,
snapshot,
child,
runtime.drain_timeout,
)
.await
{
warn!(module_id, error = %err, "plain restart after a lost swap cutover failed");
fail_snapshot(snapshot, Some(module_id), None);
}
return;
}
};
handle.promote_swap_nonce(module_id, spec.reserved);
handle.notify_swap_promoted(&promoted);
let incumbent_generation = lock_snapshot(snapshot)
.map(|state| state.spawn_generation)
.unwrap_or(0);
if let Err(err) = set_running(snapshot, &candidate, module_id, &runtime.spawn_events) {
error!(module_id, error = %err, "failed to record the promoted swap candidate");
}
let _ = update_snapshot(snapshot, Some(module_id), |state| {
state.in_alternate_slot = candidate_alternate;
});
process_liveness.track(module_id.to_string(), Arc::clone(snapshot));
let incumbent = child.replace(candidate);
info!(
module_id,
promoted_connection_id = candidate_connection.get(),
superseded_connection_id = incumbent_connection.get(),
"swap cut over; new routes land on the promoted candidate, draining the incumbent"
);
let _ = reply.send(Ok(()));
retire_incumbent(
spec,
runtime,
registry,
&forwarding,
snapshot,
incumbent,
forwarding_cutover.incumbent,
incumbent_connection,
incumbent_generation,
)
.await;
handle.close_swap(module_id);
}
fn serve_command_while_warming(
module_id: &str,
command: SupervisorCommand,
end: &mut SwapEnd,
) -> Option<SupervisorCommand> {
let in_progress = || SuperviseError::SwapInProgress {
module_id: module_id.to_string(),
};
match command {
SupervisorCommand::Drain { .. }
| SupervisorCommand::Retire { .. }
| SupervisorCommand::SetEnabled { enabled: false, .. } => Some(command),
SupervisorCommand::SetEnabled {
enabled: true,
reply,
} => {
let _ = reply.send(Ok(false));
None
}
SupervisorCommand::Restart { reply, .. } | SupervisorCommand::Reload { reply } => {
let _ = reply.send(Err(in_progress()));
None
}
SupervisorCommand::Swap { reply, .. } => {
let _ = reply.send(Err(SuperviseError::SwapRefused {
module_id: module_id.to_string(),
reason: SwapRefusal::AlreadySwapping,
}));
None
}
SupervisorCommand::UpdateConfiguration {
spec,
health,
drain_timeout_ms,
reply,
} => {
let _ = reply.send(());
let (unanswered, _) = oneshot::channel();
end.requeue.push(SupervisorCommand::UpdateConfiguration {
spec,
health,
drain_timeout_ms,
reply: unanswered,
});
None
}
}
}
fn admit_swap(
spec: &ModuleSpec,
runtime: &SupervisorRuntimeConfig,
registry: &Registry,
snapshot: &SharedSnapshot,
child: &Option<SupervisedChild>,
) -> Result<(Arc<ForwardingTable>, SupervisorHandle, ConnectionId), SuperviseError> {
let module_id = spec.module_id.as_str();
let refuse = |reason| SuperviseError::SwapRefused {
module_id: module_id.to_string(),
reason,
};
if spec.overlap != ModuleOverlap::Safe {
return Err(refuse(SwapRefusal::OverlapExclusive));
}
if !lock_snapshot(snapshot)?.enabled {
return Err(SuperviseError::Disabled {
module_id: module_id.to_string(),
});
}
if spec.protocol == ModuleProtocol::None {
return Err(refuse(SwapRefusal::ProtocolNone));
}
let (Some(forwarding), Some(handle)) = (
runtime.forwarding.clone(),
runtime.supervisor_handle.clone(),
) else {
return Err(refuse(SwapRefusal::NotConfigured));
};
if handle.swap_open(module_id) {
return Err(refuse(SwapRefusal::AlreadySwapping));
}
let registration = registry
.get_module(module_id)
.map_err(SuperviseError::Registry)?;
let (Some(registration), true) = (registration, child.is_some()) else {
return Err(refuse(SwapRefusal::NotRegistered));
};
Ok((forwarding, handle, registration.connection_id))
}
#[allow(clippy::too_many_arguments)]
async fn warm_candidate(
module_id: &str,
registry: &Registry,
candidate: &mut SupervisedChild,
incumbent_connection: ConnectionId,
ready_timeout: Duration,
commands: &mut mpsc::Receiver<SupervisorCommand>,
end: &mut SwapEnd,
) -> Warm {
let deadline = Instant::now() + ready_timeout;
let mut registered: Option<ConnectionId> = None;
loop {
let slot = match registered {
Some(connection) => RegistrationSlot::Connection(connection),
None => RegistrationSlot::Candidate(module_id),
};
match registry.registration(slot) {
Ok(Some(registration)) => {
registered = Some(registration.connection_id);
if registration.ready {
return Warm::Ready(registration.connection_id);
}
if matches!(
registry.registration(RegistrationSlot::Connection(incumbent_connection)),
Ok(None)
) {
warn!(
module_id,
"incumbent went away while the swap candidate warmed; promoting the candidate before it is ready"
);
return Warm::Ready(registration.connection_id);
}
}
Ok(None) if registered.is_some() => {
}
Ok(None) => {}
Err(err) => {
return Warm::Failed(CandidateFailure {
arm: if registered.is_some() {
SwapFailureArm::NeverReady
} else {
SwapFailureArm::NeverRegistered
},
detail: format!("could not read the candidate's registration: {err}"),
exit: None,
connection: registered,
});
}
}
let now = Instant::now();
if now >= deadline {
let (arm, detail) = match registered {
Some(_) => (
SwapFailureArm::NeverReady,
format!("the candidate registered but did not declare itself ready within {ready_timeout:?}"),
),
None => (
SwapFailureArm::NeverRegistered,
format!("the candidate did not register within {ready_timeout:?}"),
),
};
return Warm::Failed(CandidateFailure {
arm,
detail,
exit: None,
connection: registered,
});
}
let poll = deadline
.saturating_duration_since(now)
.min(REGISTRY_RELEASE_POLL);
tokio::select! {
status = candidate.wait() => {
let exit = match status {
Ok(status) => classify_exit(&status),
Err(_) => wait_error_exit_report(),
};
return Warm::Failed(CandidateFailure {
arm: SwapFailureArm::CandidateExited,
detail: format!(
"the candidate exited before it was ready (code {:?}, signal {:?})",
exit.code, exit.signal
),
exit: Some(exit),
connection: registered,
});
}
command = commands.recv() => {
let Some(command) = command else {
return Warm::Interrupted { command: None, connection: registered };
};
if let Some(command) = serve_command_while_warming(module_id, command, end) {
return Warm::Interrupted { command: Some(command), connection: registered };
}
}
_ = sleep(poll) => {}
}
}
}
async fn probe_candidate(
module_id: &str,
runtime: &SupervisorRuntimeConfig,
registry: &Registry,
forwarding: &ForwardingTable,
candidate_connection: ConnectionId,
) -> Result<(), CandidateFailure> {
let unhealthy = |detail: String| CandidateFailure {
arm: SwapFailureArm::CandidateUnhealthy,
detail,
exit: None,
connection: Some(candidate_connection),
};
let advertises_health = registry
.registration(RegistrationSlot::Connection(candidate_connection))
.ok()
.flatten()
.is_some_and(|registration| {
registration
.control_ops
.iter()
.any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK)
});
if !advertises_health {
return Ok(());
}
let Some(endpoint) = forwarding
.module_endpoint_for_connection(candidate_connection)
.ok()
.flatten()
else {
return Err(unhealthy(
"the candidate's connection closed before its health probe".to_string(),
));
};
match probe_endpoint_health(endpoint, runtime, None).await {
Ok(report) if report.status == HealthStatus::Failing => Err(unhealthy(format!(
"the candidate answered its health probe with status failing{}",
report
.detail
.map(|detail| format!(": {detail}"))
.unwrap_or_default()
))),
Ok(_) => Ok(()),
Err(err) => {
debug!(module_id, error = %err, "swap candidate health probe failed");
Err(unhealthy(format!(
"the candidate's health probe failed: {err}"
)))
}
}
}
async fn abandon_candidate(
module_id: &str,
registry: &Registry,
forwarding: &ForwardingTable,
handle: &SupervisorHandle,
mut candidate: SupervisedChild,
failure: &CandidateFailure,
) {
warn!(
module_id,
arm = failure.arm.as_str(),
detail = %failure.detail,
candidate_pid = candidate.pid,
"swap failed; killing the candidate and leaving the incumbent serving"
);
if failure.exit.is_none() {
if let Err(err) = candidate.start_kill() {
debug!(module_id, error = %err, "swap candidate kill failed; it may already have exited");
}
if let Err(err) = candidate.wait().await {
warn!(module_id, error = %err, "could not reap the abandoned swap candidate");
}
}
candidate.drain_stderr(module_id).await;
let slot = match failure.connection {
Some(connection) => RegistrationSlot::Connection(connection),
None => RegistrationSlot::Candidate(module_id),
};
if let Err(err) =
wait_for_slot_registration_release(registry, slot, REGISTRY_RELEASE_TIMEOUT).await
{
warn!(module_id, error = %err, "abandoned swap candidate is still registered; closing its connection");
if let Some(connection) = failure.connection.or_else(|| {
registry
.get_candidate(module_id)
.ok()
.flatten()
.map(|registration| registration.connection_id)
}) {
forwarding.request_connection_close(
connection,
CloseReason::new(
"swap_candidate_abandoned",
format!("swap of module '{module_id}' failed; closing its candidate"),
),
);
}
}
handle.close_swap(module_id);
}
#[allow(clippy::too_many_arguments)]
async fn retire_incumbent(
spec: &ModuleSpec,
runtime: &SupervisorRuntimeConfig,
registry: &Registry,
forwarding: &ForwardingTable,
snapshot: &SharedSnapshot,
incumbent: Option<SupervisedChild>,
incumbent_endpoint: Option<crate::ModuleEndpointId>,
incumbent_connection: ConnectionId,
incumbent_generation: u64,
) {
let module_id = spec.module_id.as_str();
if let Some(endpoint) = incumbent_endpoint {
if let Err(err) = begin_forwarding_drain_with(
forwarding,
ForwardingDrainContext {
spec,
runtime,
registry,
scope: DrainScope::Endpoint(endpoint),
},
snapshot,
None,
RouteCloseReason::Restart,
runtime.drain_timeout,
)
.await
{
warn!(module_id, error = %err, "draining the swapped-out incumbent failed; stopping it anyway");
}
}
let Some(mut incumbent) = incumbent else {
return;
};
let status = match timeout(runtime.drain_timeout, incumbent.wait()).await {
Ok(status) => status,
Err(_) => {
if let Err(err) = incumbent.start_kill() {
debug!(module_id, error = %err, "swapped-out incumbent kill failed; it may already have exited");
}
incumbent.wait().await
}
};
let exit_report = match status {
Ok(status) => classify_exit(&status),
Err(err) => {
warn!(module_id, error = %err, "could not reap the swapped-out incumbent");
wait_error_exit_report()
}
};
incumbent.drain_stderr(module_id).await;
runtime.spawn_events.emit_superseded_exited(
module_id,
incumbent_generation,
incumbent.pid,
exit_report.code,
exit_report.signal,
);
{
let mut ring = runtime
.terminal_ring
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let record = TerminalRecord {
exit_code: exit_report.code,
exit_signal: exit_report.signal,
at_ms: exit_report.at_ms,
disposition: TerminalDisposition::Restarting,
exit_kind: exit_report.kind.into(),
disposition_detail: Some("replaced by a blue/green swap".to_string()),
};
ring.append_journal(module_id, &record);
ring.push(record);
}
let _ = update_snapshot(snapshot, Some(module_id), |state| {
state.last_exit = Some(exit_report.clone());
});
if let Err(err) = wait_for_slot_registration_release(
registry,
RegistrationSlot::Connection(incumbent_connection),
REGISTRY_RELEASE_TIMEOUT,
)
.await
{
warn!(module_id, error = %err, "swapped-out incumbent's registration outlived its process");
}
info!(
module_id,
exit_code = ?exit_report.code,
exit_signal = ?exit_report.signal,
"swapped-out incumbent drained and exited; swap complete"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn consumer_attestation_accepts_both_nonces_only_while_the_swap_is_open() {
let handle = SupervisorHandle::new();
handle.set_spawn_nonce("aft", "incumbent".to_string());
assert!(!handle.spawned_consumer_authorized("aft", "candidate"));
handle.open_swap("aft", "candidate".to_string());
assert!(handle.spawned_consumer_authorized("aft", "incumbent"));
assert!(handle.spawned_consumer_authorized("aft", "candidate"));
assert!(!handle.spawned_consumer_authorized("aft", "forged"));
handle.promote_swap_nonce("aft", false);
assert!(
handle.spawned_consumer_authorized("aft", "incumbent"),
"the draining incumbent's consumers must keep attesting after cutover"
);
assert!(handle.spawned_consumer_authorized("aft", "candidate"));
handle.close_swap("aft");
assert!(handle.spawned_consumer_authorized("aft", "candidate"));
assert!(!handle.spawned_consumer_authorized("aft", "incumbent"));
}
#[test]
fn cgroup_names_are_injective_across_ids_and_slots() {
assert_eq!(cgroup_name("aft", false), "aft");
assert_eq!(cgroup_name("aft", true), "aft_swap");
let ids = [
"aft",
"aft@swap",
"aft_40swap",
"aft_swap",
"aft_",
"mcp:x",
"mcp_3ax",
];
let mut names = std::collections::HashSet::new();
for id in ids {
for alternate in [false, true] {
assert!(
names.insert(cgroup_name(id, alternate)),
"{id:?} (alternate: {alternate}) names a directory another id or slot already has: {}",
cgroup_name(id, alternate)
);
}
}
}
}