Fix systemd unloaded unit

This commit is contained in:
2026-10-27 22:21:44 +02:00
parent 86c28a35b5
commit fe7e3d61c2
7 changed files with 763 additions and 43 deletions
+142 -1
View File
@@ -315,6 +315,9 @@ pub struct RestartDiagnostic {
pub struct RestartReport {
pub stage: RestartStage,
pub failure: Option<RestartFailureKind>,
pub service_failure: Option<service::RestartFailure>,
pub service_phase: Option<service::RestartPhase>,
pub systemd_unit: Option<String>,
pub backup_path: Option<std::path::PathBuf>,
pub restart_submitted: bool,
pub restart_may_still_complete: bool,
@@ -1713,6 +1716,7 @@ impl Application {
let execution = service(&settings.systemd_unit, deadline);
let job = execution.outcome;
let submitted = execution.submitted;
let service_phase = execution.phase;
if job != RestartOutcome::Succeeded {
self.active_restart_attempt = None;
let may_complete = submitted
@@ -1727,7 +1731,7 @@ impl Application {
| service::RestartFailure::JobResultUnknown
)
);
return restart_report(
let mut report = restart_report(
RestartStage::Service,
Some(RestartFailureKind::ServiceFailed),
Some(backup_path),
@@ -1735,6 +1739,13 @@ impl Application {
may_complete,
submitted,
);
report.service_failure = match job {
RestartOutcome::Failed(failure) => Some(failure),
RestartOutcome::Waiting | RestartOutcome::Succeeded => None,
};
report.service_phase = Some(service_phase);
report.systemd_unit = Some(settings.systemd_unit);
return report;
}
let remaining = deadline.remaining_at(now());
if remaining.is_zero() {
@@ -2411,6 +2422,9 @@ fn restart_report(
RestartReport {
stage,
failure,
service_failure: None,
service_phase: None,
systemd_unit: None,
backup_path,
restart_submitted,
restart_may_still_complete,
@@ -4172,6 +4186,7 @@ restart_timeout_seconds = 30
RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
}
},
|_, _| {
@@ -4210,6 +4225,7 @@ restart_timeout_seconds = 30
RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
}
},
|_, _| Err(TransportError::Transport),
@@ -4250,6 +4266,7 @@ restart_timeout_seconds = 30
RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
}
},
|interface, remaining| {
@@ -4327,6 +4344,7 @@ restart_timeout_seconds = 30
RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
}
},
|_, _| Ok(observation),
@@ -4445,6 +4463,7 @@ restart_timeout_seconds = 30
|_, _| RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
},
|_, _| Ok(incomplete),
|store, saved, backup| store.install(saved, backup),
@@ -4513,6 +4532,7 @@ restart_timeout_seconds = 30
|_, _| RestartExecution {
outcome: RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
},
|_, _| {
runtime_called.set(true);
@@ -4548,6 +4568,7 @@ restart_timeout_seconds = 30
RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
}
},
|_, _| {
@@ -4595,6 +4616,7 @@ restart_timeout_seconds = 30
RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
}
},
|_, remaining| {
@@ -4660,6 +4682,7 @@ restart_timeout_seconds = 30
|_, _| RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
},
|_, _| Err(error),
|store, saved, backup| {
@@ -4698,6 +4721,7 @@ restart_timeout_seconds = 30
|_, _| RestartExecution {
outcome: RestartOutcome::Failed(failure),
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
},
|_, _| unreachable!("terminal service failure must stop before runtime read"),
|_, _, _| unreachable!("terminal service failure must stop before persistence"),
@@ -4710,6 +4734,120 @@ restart_timeout_seconds = 30
}
}
#[test]
fn application_report_preserves_every_service_failure_and_operation_phase() {
use crate::service::RestartPhase;
let cases = [
(RestartFailure::JobFailed, RestartPhase::WaitForJob, true),
(
RestartFailure::JobDependency,
RestartPhase::WaitForJob,
true,
),
(RestartFailure::JobTimedOut, RestartPhase::WaitForJob, true),
(RestartFailure::JobCanceled, RestartPhase::WaitForJob, true),
(RestartFailure::JobSkipped, RestartPhase::WaitForJob, true),
(
RestartFailure::JobResultUnknown,
RestartPhase::WaitForJob,
true,
),
(
RestartFailure::BusDisconnected,
RestartPhase::Connect,
false,
),
(
RestartFailure::ManagerOwnerChanged,
RestartPhase::WaitForJob,
true,
),
(
RestartFailure::ManagerOwnerChanged,
RestartPhase::RequestRestart,
true,
),
(
RestartFailure::SubscriptionFailed,
RestartPhase::Subscribe,
false,
),
(
RestartFailure::UnitResolutionFailed,
RestartPhase::ResolveUnit,
false,
),
(
RestartFailure::PermissionDenied,
RestartPhase::ResolveUnit,
false,
),
(
RestartFailure::RequestFailed,
RestartPhase::RequestRestart,
false,
),
(
RestartFailure::WaitTimedOutMayStillComplete,
RestartPhase::WaitForJob,
true,
),
(
RestartFailure::EarlyEventBufferFull,
RestartPhase::WaitForJob,
true,
),
(
RestartFailure::InvalidJobPath,
RestartPhase::WaitForJob,
true,
),
];
let root = tempfile::tempdir().expect("temp directory");
let (mut app, _) = prepare_restartable(root.path());
for (failure, phase, submitted) in cases {
let preview = app.preview_restart().expect("preview saved generation");
let report = app.restart_with_adapters(
preview,
RestartChoice::RestartSavedState,
|_, _| RestartExecution {
outcome: RestartOutcome::Failed(failure),
submitted,
phase,
},
|_, _| unreachable!("service failure must stop before runtime read"),
|_, _, _| unreachable!("service failure must stop before persistence"),
);
assert_eq!(report.failure, Some(RestartFailureKind::ServiceFailed));
assert_eq!(report.service_failure, Some(failure));
assert_eq!(report.service_phase, Some(phase));
assert_eq!(report.systemd_unit.as_deref(), Some("wg-quick@wg0.service"));
assert_eq!(report.restart_submitted, submitted);
if failure == RestartFailure::ManagerOwnerChanged
&& phase == RestartPhase::RequestRestart
{
assert!(report.restart_may_still_complete);
assert!(report.manual_recovery_required);
}
assert_eq!(
report.restart_may_still_complete,
submitted
&& !matches!(
failure,
RestartFailure::JobFailed
| RestartFailure::JobDependency
| RestartFailure::JobTimedOut
| RestartFailure::JobCanceled
| RestartFailure::JobSkipped
| RestartFailure::JobResultUnknown
)
);
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
}
}
#[test]
fn restart_mismatch_and_post_restart_write_failure_never_release_addresses() {
let root = tempfile::tempdir().expect("temp directory");
@@ -4724,6 +4862,7 @@ restart_timeout_seconds = 30
|_, _| RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
},
|_, _| {
Ok(ObservedRuntime {
@@ -4744,6 +4883,7 @@ restart_timeout_seconds = 30
|_, _| RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
},
|_, _| Ok(observation),
|_, _, _| Err(PersistenceError::RecoveryRequired),
@@ -4803,6 +4943,7 @@ restart_timeout_seconds = 30
RestartExecution {
outcome: RestartOutcome::Succeeded,
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
}
},
|_, _| Err(TransportError::Transport),
+3
View File
@@ -1062,6 +1062,7 @@ fn run_failure_prevents_application_scenario(
RestartExecution {
outcome: RestartOutcome::Failed(RestartFailure::RequestFailed),
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
}
},
)
@@ -1663,6 +1664,7 @@ fn run_fixture_apply_with_cleanup(
RestartOutcome::Failed(RestartFailure::RequestFailed)
},
submitted: applied,
phase: crate::service::RestartPhase::WaitForJob,
}
}
@@ -1900,6 +1902,7 @@ mod tests {
RestartExecution {
outcome: RestartOutcome::Failed(RestartFailure::RequestFailed),
submitted: true,
phase: crate::service::RestartPhase::WaitForJob,
}
);
}
+11
View File
@@ -101,6 +101,16 @@ pub enum RestartFailure {
InvalidJobPath,
}
/// Secret-free operation boundary where a restart attempt failed.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartPhase {
Connect,
Subscribe,
ResolveUnit,
RequestRestart,
WaitForJob,
}
/// Current state of a single restart attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestartOutcome {
@@ -115,6 +125,7 @@ pub enum RestartOutcome {
pub struct RestartExecution {
pub outcome: RestartOutcome,
pub submitted: bool,
pub phase: RestartPhase,
}
/// Pure event tracker for the primary systemd unit resolved from the configured
+314 -33
View File
@@ -18,7 +18,7 @@ use zbus::{Connection, MatchRule, MessageStream, Proxy};
use super::{
JobResult, RestartDeadline, RestartExecution, RestartFailure, RestartJobTracker,
RestartOutcome, ServiceEvent,
RestartOutcome, RestartPhase, ServiceEvent,
};
const SYSTEMD_NAME: &str = "org.freedesktop.systemd1";
@@ -70,25 +70,39 @@ fn restart_with_transport_report(
deadline: RestartDeadline,
) -> RestartExecution {
if configured_unit.is_empty() || configured_unit.as_bytes().contains(&0) {
return execution(RestartOutcome::Failed(RestartFailure::RequestFailed), false);
return execution(
RestartOutcome::Failed(RestartFailure::RequestFailed),
false,
RestartPhase::RequestRestart,
);
}
if let Err(failure) = transport.connect(deadline) {
return execution(RestartOutcome::Failed(failure), false);
return execution(
RestartOutcome::Failed(failure),
false,
RestartPhase::Connect,
);
}
if deadline.is_expired_at(Instant::now()) {
return execution(
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
false,
RestartPhase::Connect,
);
}
if let Err(failure) = transport.subscribe(deadline) {
return execution(RestartOutcome::Failed(failure), false);
return execution(
RestartOutcome::Failed(failure),
false,
RestartPhase::Subscribe,
);
}
if deadline.is_expired_at(Instant::now()) {
return execution(
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
false,
RestartPhase::Subscribe,
);
}
let primary_unit = match transport.resolve_primary_unit(configured_unit, deadline) {
@@ -97,14 +111,22 @@ fn restart_with_transport_report(
return execution(
RestartOutcome::Failed(RestartFailure::UnitResolutionFailed),
false,
RestartPhase::ResolveUnit,
);
}
Err(failure) => {
return execution(
RestartOutcome::Failed(failure),
false,
RestartPhase::ResolveUnit,
);
}
Err(failure) => return execution(RestartOutcome::Failed(failure), false),
};
if deadline.is_expired_at(Instant::now()) {
return execution(
RestartOutcome::Failed(RestartFailure::WaitTimedOutMayStillComplete),
false,
RestartPhase::ResolveUnit,
);
}
@@ -113,9 +135,15 @@ fn restart_with_transport_report(
Err(failure) => {
let submitted = matches!(
failure,
RestartFailure::WaitTimedOutMayStillComplete | RestartFailure::BusDisconnected
RestartFailure::WaitTimedOutMayStillComplete
| RestartFailure::BusDisconnected
| RestartFailure::ManagerOwnerChanged
);
return execution(
RestartOutcome::Failed(failure),
submitted,
RestartPhase::RequestRestart,
);
return execution(RestartOutcome::Failed(failure), submitted);
}
};
let mut tracker = RestartJobTracker::new(primary_unit, deadline);
@@ -123,15 +151,25 @@ fn restart_with_transport_report(
while outcome == RestartOutcome::Waiting {
let event = match transport.next_event(deadline) {
Ok(event) => event,
Err(failure) => return execution(RestartOutcome::Failed(failure), true),
Err(failure) => {
return execution(
RestartOutcome::Failed(failure),
true,
RestartPhase::WaitForJob,
);
}
};
outcome = tracker.observe(event, Instant::now());
}
execution(outcome, true)
execution(outcome, true, RestartPhase::WaitForJob)
}
fn execution(outcome: RestartOutcome, submitted: bool) -> RestartExecution {
RestartExecution { outcome, submitted }
fn execution(outcome: RestartOutcome, submitted: bool, phase: RestartPhase) -> RestartExecution {
RestartExecution {
outcome,
submitted,
phase,
}
}
#[derive(Default)]
@@ -142,6 +180,100 @@ struct SystemdBus {
owner_events: Option<zbus::MessageStream>,
}
/// The small systemd unit API used by the shared resolver. Keeping method
/// selection and canonical-ID lookup here lets synthetic tests exercise the
/// same resolution flow as the production adapter.
trait UnitLoadApi {
type UnitPath;
fn load_unit(
&mut self,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<Self::UnitPath, RestartFailure>;
fn load_state(
&mut self,
unit_path: &Self::UnitPath,
deadline: RestartDeadline,
) -> Result<String, RestartFailure>;
fn canonical_id(
&mut self,
unit_path: &Self::UnitPath,
deadline: RestartDeadline,
) -> Result<String, RestartFailure>;
}
fn resolve_loaded_unit(
api: &mut impl UnitLoadApi,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
let unit_path = api.load_unit(configured_unit, deadline)?;
if !unit_is_loaded(&api.load_state(&unit_path, deadline)?) {
return Err(RestartFailure::UnitResolutionFailed);
}
api.canonical_id(&unit_path, deadline)
}
impl UnitLoadApi for SystemdBus {
type UnitPath = zbus::zvariant::OwnedObjectPath;
fn load_unit(
&mut self,
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<Self::UnitPath, RestartFailure> {
let manager = self
.manager
.as_ref()
.ok_or(RestartFailure::UnitResolutionFailed)?;
drive(manager.call("LoadUnit", &(configured_unit,)), deadline)?
.map_err(map_unit_resolution_error)
}
fn load_state(
&mut self,
unit_path: &Self::UnitPath,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
let unit = self.unit_proxy(unit_path, deadline)?;
drive(unit.get_property::<String>("LoadState"), deadline)?
.map_err(map_unit_resolution_error)
}
fn canonical_id(
&mut self,
unit_path: &Self::UnitPath,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
let unit = self.unit_proxy(unit_path, deadline)?;
drive(unit.get_property::<String>("Id"), deadline)?.map_err(map_unit_resolution_error)
}
}
impl SystemdBus {
fn unit_proxy(
&self,
unit_path: &zbus::zvariant::OwnedObjectPath,
deadline: RestartDeadline,
) -> Result<Proxy<'static>, RestartFailure> {
let connection = self
.connection
.as_ref()
.ok_or(RestartFailure::BusDisconnected)?;
drive(
Proxy::new_owned(
connection.clone(),
SYSTEMD_NAME,
unit_path.clone(),
"org.freedesktop.systemd1.Unit",
),
deadline,
)?
.map_err(map_unit_resolution_error)
}
}
impl RestartTransport for SystemdBus {
fn connect(&mut self, deadline: RestartDeadline) -> Result<(), RestartFailure> {
let connection = drive(Connection::system(), deadline)?.map_err(map_bus_error)?;
@@ -202,28 +334,7 @@ impl RestartTransport for SystemdBus {
configured_unit: &str,
deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
let manager = self
.manager
.as_ref()
.ok_or(RestartFailure::UnitResolutionFailed)?;
let connection = self
.connection
.as_ref()
.ok_or(RestartFailure::BusDisconnected)?;
let unit_path: zbus::zvariant::OwnedObjectPath =
drive(manager.call("GetUnit", &(configured_unit,)), deadline)?
.map_err(map_unit_resolution_error)?;
let unit = drive(
Proxy::new_owned(
connection.clone(),
SYSTEMD_NAME,
unit_path,
"org.freedesktop.systemd1.Unit",
),
deadline,
)?
.map_err(map_unit_resolution_error)?;
drive(unit.get_property::<String>("Id"), deadline)?.map_err(map_unit_resolution_error)
resolve_loaded_unit(self, configured_unit, deadline)
}
fn restart_unit(
@@ -348,6 +459,10 @@ impl RestartTransport for SystemdBus {
}
}
fn unit_is_loaded(load_state: &str) -> bool {
load_state == "loaded"
}
fn map_job_result(result: &str) -> JobResult {
match result {
"done" => JobResult::Done,
@@ -624,6 +739,172 @@ mod tests {
assert!(fake.deadlines.iter().all(|(_, passed)| *passed == deadline));
}
#[test]
fn systemd_load_states_allow_only_loaded_units_to_reach_restart() {
assert!(unit_is_loaded("loaded"));
for state in ["not-found", "bad-setting", "error", "masked", "stub"] {
assert!(!unit_is_loaded(state), "unexpected load state: {state}");
}
}
struct FakeUnitLoadApi {
calls: Vec<String>,
configured_unit_seen: Option<String>,
load_state: Result<String, RestartFailure>,
canonical_id: Result<String, RestartFailure>,
load_unit_result: Result<String, RestartFailure>,
}
impl UnitLoadApi for FakeUnitLoadApi {
type UnitPath = String;
fn load_unit(
&mut self,
configured_unit: &str,
_deadline: RestartDeadline,
) -> Result<Self::UnitPath, RestartFailure> {
self.calls.push("LoadUnit".to_owned());
self.configured_unit_seen = Some(configured_unit.to_owned());
self.load_unit_result.clone()
}
fn load_state(
&mut self,
unit_path: &Self::UnitPath,
_deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
self.calls.push(format!("LoadState:{unit_path}"));
self.load_state.clone()
}
fn canonical_id(
&mut self,
unit_path: &Self::UnitPath,
_deadline: RestartDeadline,
) -> Result<String, RestartFailure> {
self.calls.push(format!("Id:{unit_path}"));
self.canonical_id.clone()
}
}
impl FakeUnitLoadApi {
fn loaded(unit_path: &str, canonical_id: &str) -> Self {
Self {
calls: Vec::new(),
configured_unit_seen: None,
load_state: Ok("loaded".to_owned()),
canonical_id: Ok(canonical_id.to_owned()),
load_unit_result: Ok(unit_path.to_owned()),
}
}
}
#[test]
fn production_unit_resolver_loads_unloaded_alias_and_returns_canonical_id() {
let mut resolver = FakeUnitLoadApi::loaded(
"/org/freedesktop/systemd1/unit/wg_2dquick_40wg0_2eservice",
"wg-quick@wg0.service",
);
let canonical = resolve_loaded_unit(&mut resolver, "wireguard-vpn.service", deadline())
.expect("LoadUnit loads existing unloaded alias");
assert_eq!(
resolver.configured_unit_seen.as_deref(),
Some("wireguard-vpn.service")
);
assert_eq!(canonical, "wg-quick@wg0.service");
assert_eq!(
resolver.calls,
[
"LoadUnit",
"LoadState:/org/freedesktop/systemd1/unit/wg_2dquick_40wg0_2eservice",
"Id:/org/freedesktop/systemd1/unit/wg_2dquick_40wg0_2eservice",
]
);
}
#[test]
fn production_unit_resolver_stops_for_missing_units_and_access_denial() {
let mut missing = FakeUnitLoadApi::loaded("/unit/missing", "unused");
missing.load_state = Ok("not-found".to_owned());
assert_eq!(
resolve_loaded_unit(&mut missing, "missing.service", deadline()),
Err(RestartFailure::UnitResolutionFailed)
);
assert_eq!(missing.calls, ["LoadUnit", "LoadState:/unit/missing"]);
let mut load_error = FakeUnitLoadApi::loaded("/unit/unused", "unused");
load_error.load_unit_result = Err(RestartFailure::UnitResolutionFailed);
assert_eq!(
resolve_loaded_unit(&mut load_error, "absent.service", deadline()),
Err(RestartFailure::UnitResolutionFailed)
);
assert_eq!(load_error.calls, ["LoadUnit"]);
let mut denied = FakeUnitLoadApi::loaded("/unit/denied", "unused");
denied.load_unit_result = Err(RestartFailure::PermissionDenied);
assert_eq!(
resolve_loaded_unit(&mut denied, "restricted.service", deadline()),
Err(RestartFailure::PermissionDenied)
);
assert_eq!(denied.calls, ["LoadUnit"]);
}
#[test]
fn alias_resolution_and_access_denial_stop_before_restart() {
let mut alias = FakeBus {
configured_unit: "wireguard-vpn.service",
primary_unit: "wg-quick@wg0.service",
job_path: "/job/alias",
events: VecDeque::from([removed(
"/job/alias",
"wg-quick@wg0.service",
JobResult::Done,
)]),
..FakeBus::default()
};
let execution =
restart_with_transport_report(&mut alias, "wireguard-vpn.service", deadline());
assert_eq!(execution.outcome, RestartOutcome::Succeeded);
assert_eq!(execution.phase, RestartPhase::WaitForJob);
assert_eq!(
alias.calls[0..4],
["connect", "subscribe", "resolve", "restart"]
);
let mut denied = FakeBus {
failure: Some(("resolve", RestartFailure::PermissionDenied)),
..FakeBus::default()
};
let denied_unit = denied.configured_unit;
let execution = restart_with_transport_report(&mut denied, denied_unit, deadline());
assert_eq!(
execution.outcome,
RestartOutcome::Failed(RestartFailure::PermissionDenied)
);
assert_eq!(execution.phase, RestartPhase::ResolveUnit);
assert!(!denied.calls.contains(&"restart"));
}
#[test]
fn owner_change_while_restart_reply_is_pending_is_reported_as_submitted() {
let mut fake = FakeBus {
failure: Some(("restart", RestartFailure::ManagerOwnerChanged)),
..FakeBus::default()
};
let execution =
restart_with_transport_report(&mut fake, "wg-quick@wg0.service", deadline());
assert_eq!(
execution.outcome,
RestartOutcome::Failed(RestartFailure::ManagerOwnerChanged)
);
assert_eq!(execution.phase, RestartPhase::RequestRestart);
assert!(execution.submitted);
}
#[test]
fn expired_shared_deadline_stops_between_setup_and_submission() {
let mut fake = FakeBus {
+194 -8
View File
@@ -508,14 +508,32 @@ fn result_message(result: &CommandResult) -> String {
CommandResult::RestartPreview(_) => {
"Restart preview ready; confirm the exact saved-state action.".to_owned()
}
CommandResult::Restarted(report) => format!(
"Restart result: {:?}; failure: {:?}; submitted: {}; may still complete: {}; manual recovery required: {}.",
report.stage,
report.failure,
report.restart_submitted,
report.restart_may_still_complete,
report.manual_recovery_required
),
CommandResult::Restarted(report) => {
let mut message = format!(
"Restart result: {:?}; failure: {:?}; submitted: {}; may still complete: {}; manual recovery required: {}.",
report.stage,
report.failure,
report.restart_submitted,
report.restart_may_still_complete,
report.manual_recovery_required
);
if let Some(failure) = report.service_failure {
let phase = report
.service_phase
.map(restart_phase_label)
.unwrap_or("service operation");
let unit = report
.systemd_unit
.as_deref()
.map(render::safe_text)
.unwrap_or_else(|| "configured systemd unit".to_owned());
message.push_str(&format!(
" Systemd {phase} failed for {unit}: {}.",
restart_failure_label(failure)
));
}
message
}
CommandResult::DeliveryDestination(destination) => format!(
"Prepared destination {}. Overwrite confirmation required: {}.",
render::safe_text(&destination.path.display().to_string()),
@@ -566,6 +584,39 @@ fn result_message(result: &CommandResult) -> String {
}
}
fn restart_phase_label(phase: crate::service::RestartPhase) -> &'static str {
match phase {
crate::service::RestartPhase::Connect => "bus connection",
crate::service::RestartPhase::Subscribe => "job subscription",
crate::service::RestartPhase::ResolveUnit => "unit resolution",
crate::service::RestartPhase::RequestRestart => "restart request",
crate::service::RestartPhase::WaitForJob => "job wait",
}
}
fn restart_failure_label(failure: crate::service::RestartFailure) -> &'static str {
use crate::service::RestartFailure;
match failure {
RestartFailure::JobFailed => "job failed",
RestartFailure::JobDependency => "job dependency failed",
RestartFailure::JobTimedOut => "job timed out",
RestartFailure::JobCanceled => "job was canceled",
RestartFailure::JobSkipped => "job was skipped",
RestartFailure::JobResultUnknown => "job returned an unknown result",
RestartFailure::BusDisconnected => "D-Bus connection was lost",
RestartFailure::ManagerOwnerChanged => "systemd manager owner changed",
RestartFailure::SubscriptionFailed => "job subscription failed",
RestartFailure::UnitResolutionFailed => "unit could not be loaded",
RestartFailure::PermissionDenied => "permission denied",
RestartFailure::RequestFailed => "systemd request failed",
RestartFailure::WaitTimedOutMayStillComplete => {
"operation timed out and may still complete"
}
RestartFailure::EarlyEventBufferFull => "job event buffer was full",
RestartFailure::InvalidJobPath => "systemd returned an invalid job path",
}
}
fn process_command_result(
result: CommandResult,
state: &mut UiState,
@@ -1048,11 +1099,15 @@ mod tests {
#[test]
fn restart_results_render_cancel_success_and_uncertain_failure_honestly() {
use crate::app::{RestartFailureKind, RestartReport, RestartStage};
use crate::service::{RestartFailure, RestartPhase};
let report = |stage, failure, restart_submitted, restart_may_still_complete| {
CommandResult::Restarted(RestartReport {
stage,
failure,
service_failure: None,
service_phase: None,
systemd_unit: None,
backup_path: None,
restart_submitted,
restart_may_still_complete,
@@ -1076,6 +1131,137 @@ mod tests {
assert!(uncertain.contains("ServiceFailed"));
assert!(uncertain.contains("may still complete: true"));
assert!(uncertain.contains("manual recovery required: true"));
let detailed = CommandResult::Restarted(RestartReport {
stage: RestartStage::Service,
failure: Some(RestartFailureKind::ServiceFailed),
service_failure: Some(RestartFailure::PermissionDenied),
service_phase: Some(RestartPhase::ResolveUnit),
systemd_unit: Some("wireguard-vpn.service".to_owned()),
backup_path: None,
restart_submitted: false,
restart_may_still_complete: false,
manual_recovery_required: false,
});
let detailed = result_message(&detailed);
assert!(detailed.contains("unit resolution"));
assert!(detailed.contains("wireguard-vpn.service"));
assert!(detailed.contains("permission denied"));
assert!(!detailed.contains("org.freedesktop.DBus.Error"));
}
#[test]
fn every_redacted_service_failure_has_a_readable_tui_category_and_phase() {
use crate::app::{RestartFailureKind, RestartReport, RestartStage};
use crate::service::{RestartFailure, RestartPhase};
for (failure, phase, label) in [
(
RestartFailure::JobFailed,
RestartPhase::WaitForJob,
"job failed",
),
(
RestartFailure::JobDependency,
RestartPhase::WaitForJob,
"job dependency failed",
),
(
RestartFailure::JobTimedOut,
RestartPhase::WaitForJob,
"job timed out",
),
(
RestartFailure::JobCanceled,
RestartPhase::WaitForJob,
"job was canceled",
),
(
RestartFailure::JobSkipped,
RestartPhase::WaitForJob,
"job was skipped",
),
(
RestartFailure::JobResultUnknown,
RestartPhase::WaitForJob,
"job returned an unknown result",
),
(
RestartFailure::BusDisconnected,
RestartPhase::Connect,
"D-Bus connection was lost",
),
(
RestartFailure::ManagerOwnerChanged,
RestartPhase::WaitForJob,
"systemd manager owner changed",
),
(
RestartFailure::SubscriptionFailed,
RestartPhase::Subscribe,
"job subscription failed",
),
(
RestartFailure::UnitResolutionFailed,
RestartPhase::ResolveUnit,
"unit could not be loaded",
),
(
RestartFailure::PermissionDenied,
RestartPhase::RequestRestart,
"permission denied",
),
(
RestartFailure::RequestFailed,
RestartPhase::RequestRestart,
"systemd request failed",
),
(
RestartFailure::WaitTimedOutMayStillComplete,
RestartPhase::WaitForJob,
"operation timed out and may still complete",
),
(
RestartFailure::EarlyEventBufferFull,
RestartPhase::WaitForJob,
"job event buffer was full",
),
(
RestartFailure::InvalidJobPath,
RestartPhase::WaitForJob,
"systemd returned an invalid job path",
),
] {
let report = CommandResult::Restarted(RestartReport {
stage: RestartStage::Service,
failure: Some(RestartFailureKind::ServiceFailed),
service_failure: Some(failure),
service_phase: Some(phase),
systemd_unit: Some("wg-quick@wg0.service".to_owned()),
backup_path: None,
restart_submitted: true,
restart_may_still_complete: true,
manual_recovery_required: true,
});
let message = result_message(&report);
assert!(message.contains(label), "missing {failure:?}: {message}");
assert!(message.contains("wg-quick@wg0.service"));
assert!(message.contains(restart_phase_label(phase)));
assert!(!message.contains("org.freedesktop.DBus.Error"));
}
let hostile_unit = CommandResult::Restarted(RestartReport {
stage: RestartStage::Service,
failure: Some(RestartFailureKind::ServiceFailed),
service_failure: Some(RestartFailure::PermissionDenied),
service_phase: Some(RestartPhase::ResolveUnit),
systemd_unit: Some("wg0\u{1b}[31m.service".to_owned()),
backup_path: None,
restart_submitted: false,
restart_may_still_complete: false,
manual_recovery_required: false,
});
assert!(!result_message(&hostile_unit).contains('\u{1b}'));
}
#[test]