1use std::fs;
25use std::io::Write as _;
26use std::path::{Path, PathBuf};
27
28use chrono::{DateTime, Utc};
29use serde::{Deserialize, Serialize};
30
31use super::discovery::{escaped_name_with_digest, validate_distribution_name};
32use crate::paths::AppPaths;
33use crate::process::{Adoption, ProcessIdentity};
34use runner_manager_domain::model::ScaleTarget;
35
36pub const GUEST_CONFIG_FILE: &str = "wsl-recovery.toml";
37pub const REQUEST_FILE: &str = "drain-request.json";
38pub const HEARTBEAT_FILE: &str = "guest-heartbeat.json";
39pub const FENCE_DIRECTORY: &str = "launch-fence";
40pub const OWNER_FILE: &str = "owner.json";
41pub const RECOVERY_STATUS_FILE: &str = "recovery-status.json";
42pub const SCHEMA_VERSION: u32 = 1;
43
44pub fn recovery_root(paths: &AppPaths, distribution: &str) -> Result<PathBuf, super::WslError> {
46 validate_distribution_name(distribution)?;
47 Ok(paths
48 .config_dir()
49 .join("wsl-recovery")
50 .join(escaped_name_with_digest(distribution)))
51}
52
53#[derive(Debug, thiserror::Error)]
54pub enum FenceError {
55 #[error("cannot {operation} WSL recovery state at {}: {source}", path.display())]
56 Io {
57 operation: &'static str,
58 path: PathBuf,
59 #[source]
60 source: std::io::Error,
61 },
62 #[error("cannot decode WSL recovery state at {}: {source}", path.display())]
63 Decode {
64 path: PathBuf,
65 #[source]
66 source: serde_json::Error,
67 },
68 #[error("WSL recovery state at {} has schema {found}, but this build supports {SCHEMA_VERSION}", path.display())]
69 Schema { path: PathBuf, found: u32 },
70}
71
72fn io(operation: &'static str, path: &Path, source: std::io::Error) -> FenceError {
73 FenceError::Io {
74 operation,
75 path: path.to_path_buf(),
76 source,
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct GuestRecoveryConfig {
83 pub schema_version: u32,
84 pub shared_root: PathBuf,
85}
86
87impl GuestRecoveryConfig {
88 #[must_use]
89 pub fn new(shared_root: PathBuf) -> Self {
90 Self {
91 schema_version: SCHEMA_VERSION,
92 shared_root,
93 }
94 }
95
96 #[must_use]
97 pub fn path(paths: &AppPaths) -> PathBuf {
98 paths.config_dir().join(GUEST_CONFIG_FILE)
99 }
100
101 pub fn read(paths: &AppPaths) -> Result<Option<Self>, FenceError> {
102 let path = Self::path(paths);
103 let text = match fs::read_to_string(&path) {
104 Ok(text) => text,
105 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
106 Err(source) => return Err(io("read", &path, source)),
107 };
108 let value: Self = toml::from_str(&text).map_err(|source| FenceError::Io {
109 operation: "decode",
110 path: path.clone(),
111 source: std::io::Error::new(std::io::ErrorKind::InvalidData, source),
112 })?;
113 if value.schema_version != SCHEMA_VERSION {
114 return Err(FenceError::Schema {
115 path,
116 found: value.schema_version,
117 });
118 }
119 Ok(Some(value))
120 }
121
122 pub fn write(&self, paths: &AppPaths) -> Result<(), FenceError> {
123 let path = Self::path(paths);
124 let text = toml::to_string_pretty(self).map_err(|source| FenceError::Io {
125 operation: "encode",
126 path: path.clone(),
127 source: std::io::Error::new(std::io::ErrorKind::InvalidData, source),
128 })?;
129 atomic_write(&path, text.as_bytes())
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(deny_unknown_fields)]
135pub struct DrainRequest {
136 pub schema_version: u32,
137 pub generation: u64,
138 pub requested_at: DateTime<Utc>,
139}
140
141impl DrainRequest {
142 #[must_use]
143 pub fn new(generation: u64, requested_at: DateTime<Utc>) -> Self {
144 Self {
145 schema_version: SCHEMA_VERSION,
146 generation,
147 requested_at,
148 }
149 }
150
151 pub fn write(&self, root: &Path) -> Result<(), FenceError> {
152 write_json(&root.join(REQUEST_FILE), self)
153 }
154
155 pub fn read(root: &Path) -> Result<Option<Self>, FenceError> {
156 read_json(&root.join(REQUEST_FILE))
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct GuestHeartbeat {
163 pub schema_version: u32,
164 pub observed_at: DateTime<Utc>,
165 pub acknowledged_generation: Option<u64>,
166 pub local_active_attempts: Option<u32>,
167 #[serde(default)]
171 pub local_busy_attempts: Option<u32>,
172 pub managed_targets: Vec<ScaleTarget>,
173 pub unmanaged_runner_services: Option<u32>,
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum RecoveryPhase {
179 Healthy,
180 Degraded,
181 Draining,
182 Recovering,
183 Backoff,
184 RecoveryBlocked,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct RecoveryStatus {
190 pub schema_version: u32,
191 pub observed_at: DateTime<Utc>,
192 pub phase: RecoveryPhase,
193 pub consecutive_probe_failures: u8,
194 pub reason: Option<String>,
195 pub last_recovered_at: Option<DateTime<Utc>>,
196}
197
198impl RecoveryStatus {
199 pub fn write(&self, root: &Path) -> Result<(), FenceError> {
200 write_json(&root.join(RECOVERY_STATUS_FILE), self)
201 }
202
203 pub fn read(root: &Path) -> Result<Option<Self>, FenceError> {
204 read_json(&root.join(RECOVERY_STATUS_FILE))
205 }
206}
207
208impl GuestHeartbeat {
209 pub fn write(&self, root: &Path) -> Result<(), FenceError> {
210 write_json(&root.join(HEARTBEAT_FILE), self)
211 }
212
213 pub fn read(root: &Path) -> Result<Option<Self>, FenceError> {
214 read_json(&root.join(HEARTBEAT_FILE))
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum FenceOwnerKind {
221 GuestLaunch,
222 WindowsRecovery,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct FenceOwner {
232 pub schema_version: u32,
233 pub kind: FenceOwnerKind,
234 pub generation: Option<u64>,
235 pub process_id: u32,
236 pub acquired_at: DateTime<Utc>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub identity: Option<ProcessIdentity>,
243}
244
245#[derive(Debug)]
247pub struct FenceClaim {
248 directory: PathBuf,
249 release_on_drop: bool,
250}
251
252impl FenceClaim {
253 pub fn try_claim(
256 root: &Path,
257 kind: FenceOwnerKind,
258 generation: Option<u64>,
259 ) -> Result<Option<Self>, FenceError> {
260 fs::create_dir_all(root).map_err(|source| io("create", root, source))?;
261 let directory = root.join(FENCE_DIRECTORY);
262 match fs::create_dir(&directory) {
263 Ok(()) => {}
264 Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None),
265 Err(source) => return Err(io("claim", &directory, source)),
266 }
267 let owner = FenceOwner {
268 schema_version: SCHEMA_VERSION,
269 kind,
270 generation,
271 process_id: std::process::id(),
272 acquired_at: Utc::now(),
273 identity: ProcessIdentity::of_current_process().ok(),
274 };
275 if let Err(error) = write_json(&directory.join(OWNER_FILE), &owner) {
276 let _ = fs::remove_dir_all(&directory);
277 return Err(error);
278 }
279 Ok(Some(Self {
280 directory,
281 release_on_drop: true,
282 }))
283 }
284
285 pub fn make_durable(mut self) {
288 self.release_on_drop = false;
289 }
290
291 pub fn owner(root: &Path) -> Result<Option<FenceOwner>, FenceError> {
292 read_json(&root.join(FENCE_DIRECTORY).join(OWNER_FILE))
293 }
294
295 pub fn release(mut self) -> Result<(), FenceError> {
296 self.release_on_drop = false;
297 remove_claim(&self.directory)
298 }
299}
300
301impl Drop for FenceClaim {
302 fn drop(&mut self) {
303 if self.release_on_drop {
304 let _ = remove_claim(&self.directory);
305 }
306 }
307}
308
309#[must_use]
312pub fn elapsed_at_least(
313 since: DateTime<Utc>,
314 now: DateTime<Utc>,
315 limit: std::time::Duration,
316) -> bool {
317 (now - since).to_std().is_ok_and(|age| age >= limit)
318}
319
320pub(crate) fn fence_modified(root: &Path) -> Result<Option<DateTime<Utc>>, FenceError> {
323 let directory = root.join(FENCE_DIRECTORY);
324 match fs::metadata(&directory).and_then(|meta| meta.modified()) {
325 Ok(modified) => Ok(Some(modified.into())),
326 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
327 Err(source) => Err(io("inspect", &directory, source)),
328 }
329}
330
331const RELEASE_ATTEMPTS: u32 = 3;
333const RELEASE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub enum StaleGuestClaim {
339 OwnerExited,
342 EarlierBoot,
344 WrittenBeforeThisDaemon,
347 ReleaseFailed,
350 OwnerNeverRecorded,
353}
354
355impl std::fmt::Display for StaleGuestClaim {
356 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357 f.write_str(match self {
358 Self::OwnerExited => "owner_exited",
359 Self::EarlierBoot => "earlier_boot",
360 Self::WrittenBeforeThisDaemon => "written_before_this_daemon",
361 Self::ReleaseFailed => "release_failed",
362 Self::OwnerNeverRecorded => "owner_never_recorded",
363 })
364 }
365}
366
367pub(crate) const OWNERLESS_CLAIM_GRACE: std::time::Duration =
371 std::time::Duration::from_secs(5 * 60);
372
373pub(crate) trait ProcessProbe {
376 fn current(&self) -> Option<ProcessIdentity>;
378 fn recheck(&self, identity: &ProcessIdentity) -> Option<Adoption>;
381 fn pid_alive(&self, pid: u32) -> Option<bool>;
384 fn boot_time(&self) -> Option<DateTime<Utc>>;
386 fn process_started(&self) -> DateTime<Utc>;
390}
391
392pub(crate) struct HostProcesses;
394
395impl ProcessProbe for HostProcesses {
396 fn current(&self) -> Option<ProcessIdentity> {
397 ProcessIdentity::of_current_process().ok()
398 }
399
400 fn recheck(&self, identity: &ProcessIdentity) -> Option<Adoption> {
401 identity.recheck().ok()
402 }
403
404 fn pid_alive(&self, pid: u32) -> Option<bool> {
405 match ProcessIdentity::resolve(pid) {
406 Ok(_) => Some(true),
407 Err(crate::process::ProcessError::NoSuchProcess { .. }) => Some(false),
408 Err(_) => None,
409 }
410 }
411
412 fn boot_time(&self) -> Option<DateTime<Utc>> {
413 crate::process::boot_time()
414 }
415
416 fn process_started(&self) -> DateTime<Utc> {
417 static STARTED: std::sync::LazyLock<DateTime<Utc>> = std::sync::LazyLock::new(Utc::now);
419 *STARTED
420 }
421}
422
423fn stale_guest_owner(owner: &FenceOwner, probe: &dyn ProcessProbe) -> Option<StaleGuestClaim> {
430 if owner.kind != FenceOwnerKind::GuestLaunch {
431 return None;
432 }
433 let current = probe.current();
434 if let Some(identity) = &owner.identity {
435 if current.as_ref() == Some(identity) {
436 return Some(StaleGuestClaim::ReleaseFailed);
437 }
438 return match probe.recheck(identity)? {
439 Adoption::Live => None,
440 Adoption::Gone | Adoption::PidRecycled { .. } => Some(StaleGuestClaim::OwnerExited),
441 };
442 }
443 if probe
450 .boot_time()
451 .is_some_and(|booted| owner.acquired_at < booted)
452 {
453 return Some(StaleGuestClaim::EarlierBoot);
454 }
455 if owner.acquired_at < probe.process_started() {
456 return Some(StaleGuestClaim::WrittenBeforeThisDaemon);
457 }
458 if current.is_some_and(|me| me.pid() == owner.process_id) {
459 return Some(StaleGuestClaim::ReleaseFailed);
462 }
463 match probe.pid_alive(owner.process_id)? {
464 true => None,
465 false => Some(StaleGuestClaim::OwnerExited),
466 }
467}
468
469#[derive(Debug, Clone, PartialEq, Eq)]
471pub struct ReclaimedGuestClaim {
472 pub reason: StaleGuestClaim,
473 pub owner: Option<FenceOwner>,
475}
476
477impl std::fmt::Display for ReclaimedGuestClaim {
478 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479 write!(f, "{}", self.reason)?;
480 if let Some(owner) = &self.owner {
481 write!(
482 f,
483 " (pid {}, acquired {})",
484 owner.process_id,
485 owner.acquired_at.to_rfc3339()
486 )?;
487 }
488 Ok(())
489 }
490}
491
492static HELD_HERE: std::sync::Mutex<std::collections::BTreeSet<PathBuf>> =
498 std::sync::Mutex::new(std::collections::BTreeSet::new());
499
500fn held_key(root: &Path) -> PathBuf {
503 fs::canonicalize(root)
504 .unwrap_or_else(|_| root.to_path_buf())
505 .join(FENCE_DIRECTORY)
506}
507
508fn held_here() -> std::sync::MutexGuard<'static, std::collections::BTreeSet<PathBuf>> {
509 HELD_HERE
510 .lock()
511 .unwrap_or_else(std::sync::PoisonError::into_inner)
512}
513
514#[derive(Debug)]
516pub enum GuestClaimAttempt {
517 Claimed {
518 claim: GuestLaunchClaim,
519 reclaimed: Option<ReclaimedGuestClaim>,
521 },
522 Busy,
524}
525
526#[derive(Debug)]
529pub struct GuestLaunchClaim {
530 claim: Option<FenceClaim>,
531 key: PathBuf,
533}
534
535impl GuestLaunchClaim {
536 pub fn release(mut self) -> Result<(), FenceError> {
545 self.release_now()
546 }
547
548 fn release_now(&mut self) -> Result<(), FenceError> {
549 let Some(mut claim) = self.claim.take() else {
550 return Ok(());
551 };
552 claim.release_on_drop = false;
553 let mut held = held_here();
554 held.remove(&self.key);
555 let mut result = Ok(());
556 for attempt in 1..=RELEASE_ATTEMPTS {
557 result = remove_claim(&claim.directory);
558 if result.is_ok() || attempt == RELEASE_ATTEMPTS {
559 break;
560 }
561 std::thread::sleep(RELEASE_RETRY_DELAY * attempt);
562 }
563 result
564 }
565}
566
567impl Drop for GuestLaunchClaim {
568 fn drop(&mut self) {
569 let _ = self.release_now();
570 }
571}
572
573pub fn try_claim_guest_launch(root: &Path) -> Result<GuestClaimAttempt, FenceError> {
579 try_claim_guest_launch_with(root, &HostProcesses, Utc::now())
580}
581
582pub(crate) fn try_claim_guest_launch_with(
583 root: &Path,
584 probe: &dyn ProcessProbe,
585 now: DateTime<Utc>,
586) -> Result<GuestClaimAttempt, FenceError> {
587 fs::create_dir_all(root).map_err(|source| io("create", root, source))?;
589 let key = held_key(root);
590 let mut held = held_here();
591 if held.contains(&key) {
592 return Ok(GuestClaimAttempt::Busy);
593 }
594 let mut claim = FenceClaim::try_claim(root, FenceOwnerKind::GuestLaunch, None)?;
595 let mut reclaimed = None;
596 if claim.is_none() {
597 reclaimed = reclaim_unheld(root, probe, now)?;
598 if reclaimed.is_some() {
599 claim = FenceClaim::try_claim(root, FenceOwnerKind::GuestLaunch, None)?;
600 }
601 }
602 let Some(claim) = claim else {
603 return Ok(GuestClaimAttempt::Busy);
604 };
605 held.insert(key.clone());
606 Ok(GuestClaimAttempt::Claimed {
607 claim: GuestLaunchClaim {
608 claim: Some(claim),
609 key,
610 },
611 reclaimed,
612 })
613}
614
615pub fn reclaim_stale_guest_claim(root: &Path) -> Result<Option<ReclaimedGuestClaim>, FenceError> {
622 reclaim_stale_guest_claim_with(root, &HostProcesses, Utc::now())
623}
624
625pub(crate) fn reclaim_stale_guest_claim_with(
626 root: &Path,
627 probe: &dyn ProcessProbe,
628 now: DateTime<Utc>,
629) -> Result<Option<ReclaimedGuestClaim>, FenceError> {
630 let held = held_here();
631 if held.contains(&held_key(root)) {
632 return Ok(None);
633 }
634 reclaim_unheld(root, probe, now)
635}
636
637fn reclaim_unheld(
640 root: &Path,
641 probe: &dyn ProcessProbe,
642 now: DateTime<Utc>,
643) -> Result<Option<ReclaimedGuestClaim>, FenceError> {
644 let directory = root.join(FENCE_DIRECTORY);
645 let Some(owner) = FenceClaim::owner(root)? else {
646 let abandoned = fence_modified(root)?
647 .is_some_and(|modified| elapsed_at_least(modified, now, OWNERLESS_CLAIM_GRACE));
648 if !abandoned || FenceClaim::owner(root)?.is_some() {
649 return Ok(None);
650 }
651 remove_claim(&directory)?;
652 return Ok(Some(ReclaimedGuestClaim {
653 reason: StaleGuestClaim::OwnerNeverRecorded,
654 owner: None,
655 }));
656 };
657 let Some(reason) = stale_guest_owner(&owner, probe) else {
658 return Ok(None);
659 };
660 if FenceClaim::owner(root)?.as_ref() != Some(&owner) {
663 return Ok(None);
664 }
665 remove_claim(&directory)?;
666 Ok(Some(ReclaimedGuestClaim {
667 reason,
668 owner: Some(owner),
669 }))
670}
671
672pub fn clear_recovery(root: &Path, generation: u64) -> Result<(), FenceError> {
673 let owner = FenceClaim::owner(root)?;
674 if owner.as_ref().is_some_and(|owner| {
675 owner.kind == FenceOwnerKind::WindowsRecovery && owner.generation == Some(generation)
676 }) {
677 remove_claim(&root.join(FENCE_DIRECTORY))?;
678 }
679 let request_path = root.join(REQUEST_FILE);
680 if DrainRequest::read(root)?
681 .as_ref()
682 .is_some_and(|request| request.generation != generation)
683 {
684 return Ok(());
685 }
686 match fs::remove_file(&request_path) {
687 Ok(()) => Ok(()),
688 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
689 Err(source) => Err(io("remove", &request_path, source)),
690 }
691}
692
693pub fn retire_windows_recovery(root: &Path) -> Result<(), FenceError> {
698 let recovery_generation = FenceClaim::owner(root)?.and_then(|owner| {
699 (owner.kind == FenceOwnerKind::WindowsRecovery)
700 .then_some(owner.generation)
701 .flatten()
702 });
703 if let Some(generation) = recovery_generation {
704 clear_recovery(root, generation)?;
705 }
706 if let Some(request) = DrainRequest::read(root)? {
707 clear_recovery(root, request.generation)?;
708 }
709 let status_path = root.join(RECOVERY_STATUS_FILE);
710 match fs::remove_file(&status_path) {
711 Ok(()) => Ok(()),
712 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
713 Err(source) => Err(io("remove", &status_path, source)),
714 }
715}
716
717pub fn retire_abandoned_windows_recovery(
734 root: &Path,
735 now: DateTime<Utc>,
736 abandoned_after: std::time::Duration,
737) -> Result<bool, FenceError> {
738 let abandoned = |at: DateTime<Utc>| elapsed_at_least(at, now, abandoned_after);
739 let request = DrainRequest::read(root)?;
740 if request
741 .as_ref()
742 .is_some_and(|request| !abandoned(request.requested_at))
743 {
744 return Ok(false);
745 }
746 let windows_owner =
747 FenceClaim::owner(root)?.filter(|owner| owner.kind == FenceOwnerKind::WindowsRecovery);
748 if windows_owner
749 .as_ref()
750 .is_some_and(|owner| !abandoned(owner.acquired_at))
751 {
752 return Ok(false);
753 }
754 if request.is_none() && windows_owner.is_none() {
755 return Ok(false);
756 }
757 retire_windows_recovery(root)?;
758 Ok(true)
759}
760
761fn remove_claim(directory: &Path) -> Result<(), FenceError> {
762 match fs::remove_dir_all(directory) {
763 Ok(()) => Ok(()),
764 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
765 Err(source) => Err(io("release", directory, source)),
766 }
767}
768
769fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), FenceError> {
770 let parent = path.parent().unwrap_or_else(|| Path::new("."));
771 fs::create_dir_all(parent).map_err(|source| io("create", parent, source))?;
772 let mut temporary =
773 tempfile::NamedTempFile::new_in(parent).map_err(|source| io("write", path, source))?;
774 temporary
775 .write_all(bytes)
776 .and_then(|()| temporary.as_file().sync_all())
777 .map_err(|source| io("write", path, source))?;
778 temporary
779 .persist(path)
780 .map(|_| ())
781 .map_err(|error| io("replace", path, error.error))
782}
783
784fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), FenceError> {
785 let bytes = serde_json::to_vec_pretty(value).map_err(|source| FenceError::Decode {
786 path: path.to_path_buf(),
787 source,
788 })?;
789 atomic_write(path, &bytes)
790}
791
792fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>, FenceError> {
793 let bytes = match fs::read(path) {
794 Ok(bytes) => bytes,
795 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
796 Err(source) => return Err(io("read", path, source)),
797 };
798 let value: T = serde_json::from_slice(&bytes).map_err(|source| FenceError::Decode {
799 path: path.to_path_buf(),
800 source,
801 })?;
802 Ok(Some(value))
803}
804
805#[must_use]
808pub fn unmanaged_runner_service_count() -> Option<u32> {
809 if !cfg!(target_os = "linux") {
810 return Some(0);
811 }
812 let mut names = std::collections::BTreeSet::new();
813 for directory in [
814 "/etc/systemd/system",
815 "/usr/lib/systemd/system",
816 "/lib/systemd/system",
817 ] {
818 let entries = match fs::read_dir(directory) {
819 Ok(entries) => entries,
820 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
821 Err(_) => return None,
822 };
823 for entry in entries.flatten() {
824 let name = entry.file_name().to_string_lossy().into_owned();
825 if name.starts_with("actions.runner.") && name.ends_with(".service") {
826 names.insert(name);
827 }
828 }
829 }
830 u32::try_from(names.len()).ok()
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 #[derive(Default)]
839 struct FakeProbe {
840 current: Option<ProcessIdentity>,
841 recheck: Option<Adoption>,
842 pid_alive: Option<bool>,
843 boot_time: Option<DateTime<Utc>>,
844 process_started: Option<DateTime<Utc>>,
846 }
847
848 impl ProcessProbe for FakeProbe {
849 fn current(&self) -> Option<ProcessIdentity> {
850 self.current.clone()
851 }
852 fn recheck(&self, _: &ProcessIdentity) -> Option<Adoption> {
853 self.recheck.clone()
854 }
855 fn pid_alive(&self, _: u32) -> Option<bool> {
856 self.pid_alive
857 }
858 fn boot_time(&self) -> Option<DateTime<Utc>> {
859 self.boot_time
860 }
861 fn process_started(&self) -> DateTime<Utc> {
862 self.process_started.unwrap_or(DateTime::<Utc>::MIN_UTC)
863 }
864 }
865
866 fn identity(pid: u32, token: &str) -> ProcessIdentity {
867 serde_json::from_value(serde_json::json!({ "pid": pid, "start_token": token })).unwrap()
868 }
869
870 fn guest_owner(
871 process_id: u32,
872 acquired_at: DateTime<Utc>,
873 identity: Option<ProcessIdentity>,
874 ) -> FenceOwner {
875 FenceOwner {
876 schema_version: SCHEMA_VERSION,
877 kind: FenceOwnerKind::GuestLaunch,
878 generation: None,
879 process_id,
880 acquired_at,
881 identity,
882 }
883 }
884
885 fn plant(root: &Path, owner: &FenceOwner) {
887 fs::create_dir_all(root.join(FENCE_DIRECTORY)).unwrap();
888 write_json(&root.join(FENCE_DIRECTORY).join(OWNER_FILE), owner).unwrap();
889 }
890
891 fn claimed(attempt: GuestClaimAttempt) -> (GuestLaunchClaim, Option<ReclaimedGuestClaim>) {
892 match attempt {
893 GuestClaimAttempt::Claimed { claim, reclaimed } => (claim, reclaimed),
894 GuestClaimAttempt::Busy => panic!("the launch was refused"),
895 }
896 }
897
898 fn exited_child() -> u32 {
900 let mut child = crate::process::tests::quick_exit().spawn().unwrap();
901 child.wait().unwrap();
902 child.pid()
903 }
904
905 #[test]
906 fn a_guest_claim_whose_owner_exited_is_reclaimed_and_the_launch_proceeds() {
907 let root = tempfile::tempdir().unwrap();
908 let pid = exited_child();
909 plant(
910 root.path(),
911 &guest_owner(pid, Utc::now(), Some(identity(pid, "platform:gone"))),
912 );
913
914 let (claim, reclaimed) =
915 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
916
917 let reclaimed = reclaimed.expect("the dead owner's claim was reclaimed");
918 assert_eq!(reclaimed.reason, StaleGuestClaim::OwnerExited);
919 assert_eq!(reclaimed.owner.unwrap().process_id, pid);
920 let owner = FenceClaim::owner(root.path()).unwrap().unwrap();
921 assert_eq!(
922 owner.identity,
923 HostProcesses.current(),
924 "the new claim is ours"
925 );
926 drop(claim);
927 assert!(!root.path().join(FENCE_DIRECTORY).exists());
928 }
929
930 #[test]
933 fn a_guest_claim_whose_pid_now_belongs_to_another_process_is_reclaimed() {
934 let root = tempfile::tempdir().unwrap();
935 let pid = std::process::id();
936 plant(
937 root.path(),
938 &guest_owner(
939 pid,
940 Utc::now(),
941 Some(identity(pid, "linux:earlier-boot:189")),
942 ),
943 );
944
945 let (_claim, reclaimed) =
946 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
947
948 assert_eq!(reclaimed.unwrap().reason, StaleGuestClaim::OwnerExited);
949 }
950
951 #[test]
952 fn a_live_guest_claim_is_never_reclaimed() {
953 let root = tempfile::tempdir().unwrap();
954 let mut child = crate::process::tests::long_running().spawn().unwrap();
955 let live = child.identity().clone();
956 assert_eq!(
957 HostProcesses.recheck(&live),
958 Some(Adoption::Live),
959 "the owner must be known live, not merely uninspectable"
960 );
961 plant(
962 root.path(),
963 &guest_owner(child.pid(), Utc::now(), Some(live.clone())),
964 );
965
966 let attempt = try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now());
967 let startup = reclaim_stale_guest_claim_with(root.path(), &HostProcesses, Utc::now());
968 live.terminate(std::time::Duration::from_secs(5)).unwrap();
969 child.wait().unwrap();
970
971 assert!(matches!(attempt.unwrap(), GuestClaimAttempt::Busy));
972 assert_eq!(startup.unwrap(), None);
973 assert_eq!(
974 FenceClaim::owner(root.path()).unwrap().unwrap().identity,
975 Some(live)
976 );
977 }
978
979 #[test]
980 fn a_claim_this_process_holds_refuses_every_other_launch_of_it() {
981 let root = tempfile::tempdir().unwrap();
982 let (claim, reclaimed) =
983 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
984 assert_eq!(reclaimed, None);
985 assert!(matches!(
986 try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap(),
987 GuestClaimAttempt::Busy
988 ));
989 assert_eq!(
990 reclaim_stale_guest_claim_with(root.path(), &HostProcesses, Utc::now()).unwrap(),
991 None
992 );
993 drop(claim);
994 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
995 }
996
997 #[test]
998 fn a_claim_this_process_failed_to_release_is_reclaimed_by_it() {
999 let root = tempfile::tempdir().unwrap();
1000 let (mut claim, _) =
1001 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
1002 claim.claim.take().unwrap().make_durable();
1004 held_here().remove(&held_key(root.path()));
1005 drop(claim);
1006 assert!(root.path().join(FENCE_DIRECTORY).exists());
1007
1008 let (_claim, reclaimed) =
1009 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
1010 assert_eq!(reclaimed.unwrap().reason, StaleGuestClaim::ReleaseFailed);
1011 }
1012
1013 #[test]
1014 fn a_windows_recovery_claim_is_never_reclaimed_by_the_guest() {
1015 let root = tempfile::tempdir().unwrap();
1016 let claim = FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(3))
1017 .unwrap()
1018 .unwrap();
1019 claim.make_durable();
1020 let dead = FakeProbe {
1021 recheck: Some(Adoption::Gone),
1022 pid_alive: Some(false),
1023 boot_time: Some(Utc::now() + chrono::Duration::days(1)),
1024 ..FakeProbe::default()
1025 };
1026 assert!(matches!(
1027 try_claim_guest_launch_with(root.path(), &dead, Utc::now()).unwrap(),
1028 GuestClaimAttempt::Busy
1029 ));
1030 assert!(root.path().join(FENCE_DIRECTORY).exists());
1031 }
1032
1033 const PRE_0_4_35_RECORD: &str = r#"{
1035 "schema_version": 1,
1036 "kind": "guest_launch",
1037 "generation": null,
1038 "process_id": 189,
1039 "acquired_at": "2026-09-24T00:47:18.724Z"
1040}"#;
1041
1042 #[test]
1043 fn an_old_schema_record_is_judged_by_the_boot_and_the_pid() {
1044 let old: FenceOwner = serde_json::from_str(PRE_0_4_35_RECORD).unwrap();
1045 assert_eq!(old.identity, None);
1046 let after = old.acquired_at + chrono::Duration::hours(1);
1047 let before = old.acquired_at - chrono::Duration::hours(1);
1048 let me = Some(identity(4242, "platform:me"));
1049
1050 let rebooted = FakeProbe {
1051 boot_time: Some(after),
1052 pid_alive: Some(true),
1053 ..FakeProbe::default()
1054 };
1055 assert_eq!(
1056 stale_guest_owner(&old, &rebooted),
1057 Some(StaleGuestClaim::EarlierBoot)
1058 );
1059
1060 let exited = FakeProbe {
1061 boot_time: Some(before),
1062 pid_alive: Some(false),
1063 ..FakeProbe::default()
1064 };
1065 assert_eq!(
1066 stale_guest_owner(&old, &exited),
1067 Some(StaleGuestClaim::OwnerExited)
1068 );
1069
1070 let alive = FakeProbe {
1071 boot_time: Some(before),
1072 pid_alive: Some(true),
1073 current: me.clone(),
1074 ..FakeProbe::default()
1075 };
1076 assert_eq!(stale_guest_owner(&old, &alive), None);
1077
1078 let unknown = FakeProbe::default();
1079 assert_eq!(stale_guest_owner(&old, &unknown), None);
1080
1081 let distribution_restarted = FakeProbe {
1084 boot_time: Some(before),
1085 pid_alive: Some(true),
1086 process_started: Some(after),
1087 ..FakeProbe::default()
1088 };
1089 assert_eq!(
1090 stale_guest_owner(&old, &distribution_restarted),
1091 Some(StaleGuestClaim::WrittenBeforeThisDaemon)
1092 );
1093
1094 let mine = FakeProbe {
1095 boot_time: Some(before),
1096 pid_alive: Some(true),
1097 current: Some(identity(189, "platform:me")),
1098 ..FakeProbe::default()
1099 };
1100 assert_eq!(
1101 stale_guest_owner(&old, &mine),
1102 Some(StaleGuestClaim::ReleaseFailed)
1103 );
1104 }
1105
1106 #[test]
1110 fn the_incident_record_is_reclaimed_at_daemon_start_after_an_update() {
1111 let root = tempfile::tempdir().unwrap();
1112 fs::create_dir_all(root.path().join(FENCE_DIRECTORY)).unwrap();
1113 fs::write(
1114 root.path().join(FENCE_DIRECTORY).join(OWNER_FILE),
1115 PRE_0_4_35_RECORD,
1116 )
1117 .unwrap();
1118 let same_boot_new_daemon = FakeProbe {
1119 boot_time: Some("2026-09-20T00:00:00Z".parse().unwrap()),
1120 pid_alive: Some(true),
1121 process_started: Some("2026-10-07T18:00:00Z".parse().unwrap()),
1122 ..FakeProbe::default()
1123 };
1124
1125 let reclaimed =
1126 reclaim_stale_guest_claim_with(root.path(), &same_boot_new_daemon, Utc::now())
1127 .unwrap()
1128 .unwrap();
1129
1130 assert_eq!(reclaimed.reason, StaleGuestClaim::WrittenBeforeThisDaemon);
1131 assert!(!root.path().join(FENCE_DIRECTORY).exists());
1132 claimed(
1133 try_claim_guest_launch_with(root.path(), &same_boot_new_daemon, Utc::now()).unwrap(),
1134 );
1135 }
1136
1137 #[cfg(target_os = "linux")]
1139 #[test]
1140 fn this_linux_host_reports_when_it_booted() {
1141 let booted = HostProcesses
1142 .boot_time()
1143 .expect("/proc/stat names the boot");
1144 assert!(booted < Utc::now(), "{booted}");
1145 }
1146
1147 #[test]
1148 fn an_ownerless_claim_is_reclaimed_only_after_the_grace() {
1149 let root = tempfile::tempdir().unwrap();
1150 fs::create_dir_all(root.path().join(FENCE_DIRECTORY)).unwrap();
1151 let probe = FakeProbe::default();
1152 assert_eq!(
1153 reclaim_stale_guest_claim_with(root.path(), &probe, Utc::now()).unwrap(),
1154 None
1155 );
1156 let later = Utc::now() + chrono::Duration::minutes(6);
1157 let reclaimed = reclaim_stale_guest_claim_with(root.path(), &probe, later)
1158 .unwrap()
1159 .unwrap();
1160 assert_eq!(reclaimed.reason, StaleGuestClaim::OwnerNeverRecorded);
1161 assert!(!root.path().join(FENCE_DIRECTORY).exists());
1162 }
1163
1164 #[test]
1167 fn a_release_that_fails_reports_its_path_and_the_next_launch_reclaims_it() {
1168 let root = tempfile::tempdir().unwrap();
1169 let (claim, _) =
1170 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
1171 let directory = root.path().join(FENCE_DIRECTORY);
1174 let owner = fs::read(directory.join(OWNER_FILE)).unwrap();
1175 fs::remove_dir_all(&directory).unwrap();
1176 fs::write(&directory, b"not a directory").unwrap();
1177 assert!(
1178 remove_claim(&directory).is_err(),
1179 "the plant must make removal fail"
1180 );
1181
1182 let error = claim.release().expect_err("the removal failed");
1183 assert!(error.to_string().contains(FENCE_DIRECTORY), "{error}");
1184
1185 fs::remove_file(&directory).unwrap();
1186 fs::create_dir(&directory).unwrap();
1187 fs::write(directory.join(OWNER_FILE), owner).unwrap();
1188 let (_claim, reclaimed) =
1189 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
1190 assert_eq!(reclaimed.unwrap().reason, StaleGuestClaim::ReleaseFailed);
1191 }
1192
1193 #[test]
1194 fn one_directory_has_exactly_one_owner_and_drop_releases_guest_claim() {
1195 let root = tempfile::tempdir().unwrap();
1196 let first = FenceClaim::try_claim(root.path(), FenceOwnerKind::GuestLaunch, None)
1197 .unwrap()
1198 .unwrap();
1199 assert!(
1200 FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(1))
1201 .unwrap()
1202 .is_none()
1203 );
1204 drop(first);
1205 assert!(
1206 FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(1))
1207 .unwrap()
1208 .is_some()
1209 );
1210 }
1211
1212 #[test]
1213 fn only_matching_recovery_generation_is_cleared() {
1214 let root = tempfile::tempdir().unwrap();
1215 let claim = FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(9))
1216 .unwrap()
1217 .unwrap();
1218 claim.make_durable();
1219 DrainRequest::new(9, Utc::now()).write(root.path()).unwrap();
1220 clear_recovery(root.path(), 8).unwrap();
1221 assert!(root.path().join(FENCE_DIRECTORY).exists());
1222 assert_eq!(
1223 DrainRequest::read(root.path()).unwrap().unwrap().generation,
1224 9
1225 );
1226 clear_recovery(root.path(), 9).unwrap();
1227 assert!(!root.path().join(FENCE_DIRECTORY).exists());
1228 assert!(!root.path().join(REQUEST_FILE).exists());
1229 }
1230
1231 #[test]
1232 fn retiring_windows_recovery_handles_restarted_generations_but_keeps_guest_claims() {
1233 let root = tempfile::tempdir().unwrap();
1234 let claim = FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(7))
1235 .unwrap()
1236 .unwrap();
1237 claim.make_durable();
1238 DrainRequest::new(8, Utc::now()).write(root.path()).unwrap();
1239 RecoveryStatus {
1240 schema_version: SCHEMA_VERSION,
1241 observed_at: Utc::now(),
1242 phase: RecoveryPhase::RecoveryBlocked,
1243 consecutive_probe_failures: 9,
1244 reason: Some("stale".into()),
1245 last_recovered_at: None,
1246 }
1247 .write(root.path())
1248 .unwrap();
1249
1250 retire_windows_recovery(root.path()).unwrap();
1251
1252 assert!(!root.path().join(FENCE_DIRECTORY).exists());
1253 assert!(!root.path().join(REQUEST_FILE).exists());
1254 assert!(!root.path().join(RECOVERY_STATUS_FILE).exists());
1255
1256 let guest = FenceClaim::try_claim(root.path(), FenceOwnerKind::GuestLaunch, None)
1257 .unwrap()
1258 .unwrap();
1259 guest.make_durable();
1260 DrainRequest::new(10, Utc::now())
1261 .write(root.path())
1262 .unwrap();
1263 retire_windows_recovery(root.path()).unwrap();
1264 assert!(root.path().join(FENCE_DIRECTORY).exists());
1265 assert!(!root.path().join(REQUEST_FILE).exists());
1266 }
1267
1268 #[test]
1269 fn a_restarted_watchdog_retires_only_recovery_nobody_is_still_driving() {
1270 let root = tempfile::tempdir().unwrap();
1271 let now = Utc::now();
1272 let window = std::time::Duration::from_secs(300);
1273 let old = now - chrono::Duration::minutes(10);
1274 let claim = FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(1))
1275 .unwrap()
1276 .unwrap();
1277 claim.make_durable();
1278 let owner_path = root.path().join(FENCE_DIRECTORY).join(OWNER_FILE);
1279 let backdate_owner = || {
1280 let mut owner = FenceClaim::owner(root.path()).unwrap().unwrap();
1281 owner.acquired_at = old;
1282 write_json(&owner_path, &owner).unwrap();
1283 };
1284
1285 backdate_owner();
1288 DrainRequest::new(2, now - chrono::Duration::seconds(10))
1289 .write(root.path())
1290 .unwrap();
1291 assert!(!retire_abandoned_windows_recovery(root.path(), now, window).unwrap());
1292 assert!(root.path().join(FENCE_DIRECTORY).exists());
1293
1294 DrainRequest::new(2, old).write(root.path()).unwrap();
1296 assert!(retire_abandoned_windows_recovery(root.path(), now, window).unwrap());
1297 assert!(!root.path().join(FENCE_DIRECTORY).exists());
1298 assert!(!root.path().join(REQUEST_FILE).exists());
1299 assert!(!retire_abandoned_windows_recovery(root.path(), now, window).unwrap());
1300
1301 let claim = FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(3))
1303 .unwrap()
1304 .unwrap();
1305 claim.make_durable();
1306 assert!(!retire_abandoned_windows_recovery(root.path(), now, window).unwrap());
1307 assert!(root.path().join(FENCE_DIRECTORY).exists());
1308 remove_claim(&root.path().join(FENCE_DIRECTORY)).unwrap();
1309
1310 let guest = FenceClaim::try_claim(root.path(), FenceOwnerKind::GuestLaunch, None)
1312 .unwrap()
1313 .unwrap();
1314 guest.make_durable();
1315 backdate_owner();
1316 DrainRequest::new(4, old).write(root.path()).unwrap();
1317 assert!(retire_abandoned_windows_recovery(root.path(), now, window).unwrap());
1318 assert!(root.path().join(FENCE_DIRECTORY).exists());
1319 assert!(!root.path().join(REQUEST_FILE).exists());
1320 }
1321
1322 #[test]
1323 fn heartbeat_and_status_documents_replace_atomically() {
1324 let root = tempfile::tempdir().unwrap();
1325 let first = GuestHeartbeat {
1326 schema_version: SCHEMA_VERSION,
1327 observed_at: Utc::now(),
1328 acknowledged_generation: None,
1329 local_active_attempts: Some(1),
1330 local_busy_attempts: Some(1),
1331 managed_targets: Vec::new(),
1332 unmanaged_runner_services: Some(0),
1333 };
1334 let mut second = first.clone();
1335 second.local_active_attempts = Some(0);
1336 first.write(root.path()).unwrap();
1337 second.write(root.path()).unwrap();
1338 assert_eq!(GuestHeartbeat::read(root.path()).unwrap(), Some(second));
1339
1340 let first = RecoveryStatus {
1341 schema_version: SCHEMA_VERSION,
1342 observed_at: Utc::now(),
1343 phase: RecoveryPhase::Degraded,
1344 consecutive_probe_failures: 1,
1345 reason: None,
1346 last_recovered_at: None,
1347 };
1348 let mut second = first.clone();
1349 second.phase = RecoveryPhase::Healthy;
1350 first.write(root.path()).unwrap();
1351 second.write(root.path()).unwrap();
1352 assert_eq!(RecoveryStatus::read(root.path()).unwrap(), Some(second));
1353 }
1354}