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 pub fn renew(&self) -> Result<(), FenceError> {
554 let Some(claim) = &self.claim else {
555 return Ok(());
556 };
557 let path = claim.directory.join(OWNER_FILE);
558 let Some(mut owner) = read_json::<FenceOwner>(&path)? else {
559 return Ok(());
560 };
561 owner.acquired_at = Utc::now();
562 write_json(&path, &owner)
563 }
564
565 fn release_now(&mut self) -> Result<(), FenceError> {
566 let Some(mut claim) = self.claim.take() else {
567 return Ok(());
568 };
569 claim.release_on_drop = false;
570 let mut held = held_here();
571 held.remove(&self.key);
572 let mut result = Ok(());
573 for attempt in 1..=RELEASE_ATTEMPTS {
574 result = remove_claim(&claim.directory);
575 if result.is_ok() || attempt == RELEASE_ATTEMPTS {
576 break;
577 }
578 std::thread::sleep(RELEASE_RETRY_DELAY * attempt);
579 }
580 result
581 }
582}
583
584impl Drop for GuestLaunchClaim {
585 fn drop(&mut self) {
586 let _ = self.release_now();
587 }
588}
589
590pub fn try_claim_guest_launch(root: &Path) -> Result<GuestClaimAttempt, FenceError> {
596 try_claim_guest_launch_with(root, &HostProcesses, Utc::now())
597}
598
599pub(crate) fn try_claim_guest_launch_with(
600 root: &Path,
601 probe: &dyn ProcessProbe,
602 now: DateTime<Utc>,
603) -> Result<GuestClaimAttempt, FenceError> {
604 fs::create_dir_all(root).map_err(|source| io("create", root, source))?;
606 let key = held_key(root);
607 let mut held = held_here();
608 if held.contains(&key) {
609 return Ok(GuestClaimAttempt::Busy);
610 }
611 let mut claim = FenceClaim::try_claim(root, FenceOwnerKind::GuestLaunch, None)?;
612 let mut reclaimed = None;
613 if claim.is_none() {
614 reclaimed = reclaim_unheld(root, probe, now)?;
615 if reclaimed.is_some() {
616 claim = FenceClaim::try_claim(root, FenceOwnerKind::GuestLaunch, None)?;
617 }
618 }
619 let Some(claim) = claim else {
620 return Ok(GuestClaimAttempt::Busy);
621 };
622 held.insert(key.clone());
623 Ok(GuestClaimAttempt::Claimed {
624 claim: GuestLaunchClaim {
625 claim: Some(claim),
626 key,
627 },
628 reclaimed,
629 })
630}
631
632pub fn reclaim_stale_guest_claim(root: &Path) -> Result<Option<ReclaimedGuestClaim>, FenceError> {
639 reclaim_stale_guest_claim_with(root, &HostProcesses, Utc::now())
640}
641
642pub(crate) fn reclaim_stale_guest_claim_with(
643 root: &Path,
644 probe: &dyn ProcessProbe,
645 now: DateTime<Utc>,
646) -> Result<Option<ReclaimedGuestClaim>, FenceError> {
647 let held = held_here();
648 if held.contains(&held_key(root)) {
649 return Ok(None);
650 }
651 reclaim_unheld(root, probe, now)
652}
653
654fn reclaim_unheld(
657 root: &Path,
658 probe: &dyn ProcessProbe,
659 now: DateTime<Utc>,
660) -> Result<Option<ReclaimedGuestClaim>, FenceError> {
661 let directory = root.join(FENCE_DIRECTORY);
662 let Some(owner) = FenceClaim::owner(root)? else {
663 let abandoned = fence_modified(root)?
664 .is_some_and(|modified| elapsed_at_least(modified, now, OWNERLESS_CLAIM_GRACE));
665 if !abandoned || FenceClaim::owner(root)?.is_some() {
666 return Ok(None);
667 }
668 remove_claim(&directory)?;
669 return Ok(Some(ReclaimedGuestClaim {
670 reason: StaleGuestClaim::OwnerNeverRecorded,
671 owner: None,
672 }));
673 };
674 let Some(reason) = stale_guest_owner(&owner, probe) else {
675 return Ok(None);
676 };
677 if FenceClaim::owner(root)?.as_ref() != Some(&owner) {
680 return Ok(None);
681 }
682 remove_claim(&directory)?;
683 Ok(Some(ReclaimedGuestClaim {
684 reason,
685 owner: Some(owner),
686 }))
687}
688
689pub fn clear_recovery(root: &Path, generation: u64) -> Result<(), FenceError> {
690 let owner = FenceClaim::owner(root)?;
691 if owner.as_ref().is_some_and(|owner| {
692 owner.kind == FenceOwnerKind::WindowsRecovery && owner.generation == Some(generation)
693 }) {
694 remove_claim(&root.join(FENCE_DIRECTORY))?;
695 }
696 let request_path = root.join(REQUEST_FILE);
697 if DrainRequest::read(root)?
698 .as_ref()
699 .is_some_and(|request| request.generation != generation)
700 {
701 return Ok(());
702 }
703 match fs::remove_file(&request_path) {
704 Ok(()) => Ok(()),
705 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
706 Err(source) => Err(io("remove", &request_path, source)),
707 }
708}
709
710pub fn retire_windows_recovery(root: &Path) -> Result<(), FenceError> {
715 let recovery_generation = FenceClaim::owner(root)?.and_then(|owner| {
716 (owner.kind == FenceOwnerKind::WindowsRecovery)
717 .then_some(owner.generation)
718 .flatten()
719 });
720 if let Some(generation) = recovery_generation {
721 clear_recovery(root, generation)?;
722 }
723 if let Some(request) = DrainRequest::read(root)? {
724 clear_recovery(root, request.generation)?;
725 }
726 let status_path = root.join(RECOVERY_STATUS_FILE);
727 match fs::remove_file(&status_path) {
728 Ok(()) => Ok(()),
729 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
730 Err(source) => Err(io("remove", &status_path, source)),
731 }
732}
733
734pub fn retire_abandoned_windows_recovery(
751 root: &Path,
752 now: DateTime<Utc>,
753 abandoned_after: std::time::Duration,
754) -> Result<bool, FenceError> {
755 let abandoned = |at: DateTime<Utc>| elapsed_at_least(at, now, abandoned_after);
756 let request = DrainRequest::read(root)?;
757 if request
758 .as_ref()
759 .is_some_and(|request| !abandoned(request.requested_at))
760 {
761 return Ok(false);
762 }
763 let windows_owner =
764 FenceClaim::owner(root)?.filter(|owner| owner.kind == FenceOwnerKind::WindowsRecovery);
765 if windows_owner
766 .as_ref()
767 .is_some_and(|owner| !abandoned(owner.acquired_at))
768 {
769 return Ok(false);
770 }
771 if request.is_none() && windows_owner.is_none() {
772 return Ok(false);
773 }
774 retire_windows_recovery(root)?;
775 Ok(true)
776}
777
778fn remove_claim(directory: &Path) -> Result<(), FenceError> {
779 match fs::remove_dir_all(directory) {
780 Ok(()) => Ok(()),
781 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
782 Err(source) => Err(io("release", directory, source)),
783 }
784}
785
786fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), FenceError> {
787 let parent = path.parent().unwrap_or_else(|| Path::new("."));
788 fs::create_dir_all(parent).map_err(|source| io("create", parent, source))?;
789 let mut temporary =
790 tempfile::NamedTempFile::new_in(parent).map_err(|source| io("write", path, source))?;
791 temporary
792 .write_all(bytes)
793 .and_then(|()| temporary.as_file().sync_all())
794 .map_err(|source| io("write", path, source))?;
795 temporary
796 .persist(path)
797 .map(|_| ())
798 .map_err(|error| io("replace", path, error.error))
799}
800
801fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), FenceError> {
802 let bytes = serde_json::to_vec_pretty(value).map_err(|source| FenceError::Decode {
803 path: path.to_path_buf(),
804 source,
805 })?;
806 atomic_write(path, &bytes)
807}
808
809fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>, FenceError> {
810 let bytes = match fs::read(path) {
811 Ok(bytes) => bytes,
812 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
813 Err(source) => return Err(io("read", path, source)),
814 };
815 let value: T = serde_json::from_slice(&bytes).map_err(|source| FenceError::Decode {
816 path: path.to_path_buf(),
817 source,
818 })?;
819 Ok(Some(value))
820}
821
822#[must_use]
825pub fn unmanaged_runner_service_count() -> Option<u32> {
826 if !cfg!(target_os = "linux") {
827 return Some(0);
828 }
829 let mut names = std::collections::BTreeSet::new();
830 for directory in [
831 "/etc/systemd/system",
832 "/usr/lib/systemd/system",
833 "/lib/systemd/system",
834 ] {
835 let entries = match fs::read_dir(directory) {
836 Ok(entries) => entries,
837 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
838 Err(_) => return None,
839 };
840 for entry in entries.flatten() {
841 let name = entry.file_name().to_string_lossy().into_owned();
842 if name.starts_with("actions.runner.") && name.ends_with(".service") {
843 names.insert(name);
844 }
845 }
846 }
847 u32::try_from(names.len()).ok()
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853
854 #[derive(Default)]
856 struct FakeProbe {
857 current: Option<ProcessIdentity>,
858 recheck: Option<Adoption>,
859 pid_alive: Option<bool>,
860 boot_time: Option<DateTime<Utc>>,
861 process_started: Option<DateTime<Utc>>,
863 }
864
865 impl ProcessProbe for FakeProbe {
866 fn current(&self) -> Option<ProcessIdentity> {
867 self.current.clone()
868 }
869 fn recheck(&self, _: &ProcessIdentity) -> Option<Adoption> {
870 self.recheck.clone()
871 }
872 fn pid_alive(&self, _: u32) -> Option<bool> {
873 self.pid_alive
874 }
875 fn boot_time(&self) -> Option<DateTime<Utc>> {
876 self.boot_time
877 }
878 fn process_started(&self) -> DateTime<Utc> {
879 self.process_started.unwrap_or(DateTime::<Utc>::MIN_UTC)
880 }
881 }
882
883 fn identity(pid: u32, token: &str) -> ProcessIdentity {
884 serde_json::from_value(serde_json::json!({ "pid": pid, "start_token": token })).unwrap()
885 }
886
887 fn guest_owner(
888 process_id: u32,
889 acquired_at: DateTime<Utc>,
890 identity: Option<ProcessIdentity>,
891 ) -> FenceOwner {
892 FenceOwner {
893 schema_version: SCHEMA_VERSION,
894 kind: FenceOwnerKind::GuestLaunch,
895 generation: None,
896 process_id,
897 acquired_at,
898 identity,
899 }
900 }
901
902 fn plant(root: &Path, owner: &FenceOwner) {
904 fs::create_dir_all(root.join(FENCE_DIRECTORY)).unwrap();
905 write_json(&root.join(FENCE_DIRECTORY).join(OWNER_FILE), owner).unwrap();
906 }
907
908 fn claimed(attempt: GuestClaimAttempt) -> (GuestLaunchClaim, Option<ReclaimedGuestClaim>) {
909 match attempt {
910 GuestClaimAttempt::Claimed { claim, reclaimed } => (claim, reclaimed),
911 GuestClaimAttempt::Busy => panic!("the launch was refused"),
912 }
913 }
914
915 fn exited_child() -> u32 {
917 let mut child = crate::process::tests::quick_exit().spawn().unwrap();
918 child.wait().unwrap();
919 child.pid()
920 }
921
922 #[test]
923 fn a_guest_claim_whose_owner_exited_is_reclaimed_and_the_launch_proceeds() {
924 let root = tempfile::tempdir().unwrap();
925 let pid = exited_child();
926 plant(
927 root.path(),
928 &guest_owner(pid, Utc::now(), Some(identity(pid, "platform:gone"))),
929 );
930
931 let (claim, reclaimed) =
932 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
933
934 let reclaimed = reclaimed.expect("the dead owner's claim was reclaimed");
935 assert_eq!(reclaimed.reason, StaleGuestClaim::OwnerExited);
936 assert_eq!(reclaimed.owner.unwrap().process_id, pid);
937 let owner = FenceClaim::owner(root.path()).unwrap().unwrap();
938 assert_eq!(
939 owner.identity,
940 HostProcesses.current(),
941 "the new claim is ours"
942 );
943 drop(claim);
944 assert!(!root.path().join(FENCE_DIRECTORY).exists());
945 }
946
947 #[test]
950 fn a_guest_claim_whose_pid_now_belongs_to_another_process_is_reclaimed() {
951 let root = tempfile::tempdir().unwrap();
952 let pid = std::process::id();
953 plant(
954 root.path(),
955 &guest_owner(
956 pid,
957 Utc::now(),
958 Some(identity(pid, "linux:earlier-boot:189")),
959 ),
960 );
961
962 let (_claim, reclaimed) =
963 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
964
965 assert_eq!(reclaimed.unwrap().reason, StaleGuestClaim::OwnerExited);
966 }
967
968 #[test]
969 fn a_live_guest_claim_is_never_reclaimed() {
970 let root = tempfile::tempdir().unwrap();
971 let mut child = crate::process::tests::long_running().spawn().unwrap();
972 let live = child.identity().clone();
973 assert_eq!(
974 HostProcesses.recheck(&live),
975 Some(Adoption::Live),
976 "the owner must be known live, not merely uninspectable"
977 );
978 plant(
979 root.path(),
980 &guest_owner(child.pid(), Utc::now(), Some(live.clone())),
981 );
982
983 let attempt = try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now());
984 let startup = reclaim_stale_guest_claim_with(root.path(), &HostProcesses, Utc::now());
985 live.terminate(std::time::Duration::from_secs(5)).unwrap();
986 child.wait().unwrap();
987
988 assert!(matches!(attempt.unwrap(), GuestClaimAttempt::Busy));
989 assert_eq!(startup.unwrap(), None);
990 assert_eq!(
991 FenceClaim::owner(root.path()).unwrap().unwrap().identity,
992 Some(live)
993 );
994 }
995
996 #[test]
997 fn a_claim_this_process_holds_refuses_every_other_launch_of_it() {
998 let root = tempfile::tempdir().unwrap();
999 let (claim, reclaimed) =
1000 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
1001 assert_eq!(reclaimed, None);
1002 assert!(matches!(
1003 try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap(),
1004 GuestClaimAttempt::Busy
1005 ));
1006 assert_eq!(
1007 reclaim_stale_guest_claim_with(root.path(), &HostProcesses, Utc::now()).unwrap(),
1008 None
1009 );
1010 drop(claim);
1011 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
1012 }
1013
1014 #[test]
1015 fn a_claim_this_process_failed_to_release_is_reclaimed_by_it() {
1016 let root = tempfile::tempdir().unwrap();
1017 let (mut claim, _) =
1018 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
1019 claim.claim.take().unwrap().make_durable();
1021 held_here().remove(&held_key(root.path()));
1022 drop(claim);
1023 assert!(root.path().join(FENCE_DIRECTORY).exists());
1024
1025 let (_claim, reclaimed) =
1026 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
1027 assert_eq!(reclaimed.unwrap().reason, StaleGuestClaim::ReleaseFailed);
1028 }
1029
1030 #[test]
1031 fn a_windows_recovery_claim_is_never_reclaimed_by_the_guest() {
1032 let root = tempfile::tempdir().unwrap();
1033 let claim = FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(3))
1034 .unwrap()
1035 .unwrap();
1036 claim.make_durable();
1037 let dead = FakeProbe {
1038 recheck: Some(Adoption::Gone),
1039 pid_alive: Some(false),
1040 boot_time: Some(Utc::now() + chrono::Duration::days(1)),
1041 ..FakeProbe::default()
1042 };
1043 assert!(matches!(
1044 try_claim_guest_launch_with(root.path(), &dead, Utc::now()).unwrap(),
1045 GuestClaimAttempt::Busy
1046 ));
1047 assert!(root.path().join(FENCE_DIRECTORY).exists());
1048 }
1049
1050 const PRE_0_4_35_RECORD: &str = r#"{
1052 "schema_version": 1,
1053 "kind": "guest_launch",
1054 "generation": null,
1055 "process_id": 189,
1056 "acquired_at": "2026-09-24T00:47:18.724Z"
1057}"#;
1058
1059 #[test]
1060 fn an_old_schema_record_is_judged_by_the_boot_and_the_pid() {
1061 let old: FenceOwner = serde_json::from_str(PRE_0_4_35_RECORD).unwrap();
1062 assert_eq!(old.identity, None);
1063 let after = old.acquired_at + chrono::Duration::hours(1);
1064 let before = old.acquired_at - chrono::Duration::hours(1);
1065 let me = Some(identity(4242, "platform:me"));
1066
1067 let rebooted = FakeProbe {
1068 boot_time: Some(after),
1069 pid_alive: Some(true),
1070 ..FakeProbe::default()
1071 };
1072 assert_eq!(
1073 stale_guest_owner(&old, &rebooted),
1074 Some(StaleGuestClaim::EarlierBoot)
1075 );
1076
1077 let exited = FakeProbe {
1078 boot_time: Some(before),
1079 pid_alive: Some(false),
1080 ..FakeProbe::default()
1081 };
1082 assert_eq!(
1083 stale_guest_owner(&old, &exited),
1084 Some(StaleGuestClaim::OwnerExited)
1085 );
1086
1087 let alive = FakeProbe {
1088 boot_time: Some(before),
1089 pid_alive: Some(true),
1090 current: me.clone(),
1091 ..FakeProbe::default()
1092 };
1093 assert_eq!(stale_guest_owner(&old, &alive), None);
1094
1095 let unknown = FakeProbe::default();
1096 assert_eq!(stale_guest_owner(&old, &unknown), None);
1097
1098 let distribution_restarted = FakeProbe {
1101 boot_time: Some(before),
1102 pid_alive: Some(true),
1103 process_started: Some(after),
1104 ..FakeProbe::default()
1105 };
1106 assert_eq!(
1107 stale_guest_owner(&old, &distribution_restarted),
1108 Some(StaleGuestClaim::WrittenBeforeThisDaemon)
1109 );
1110
1111 let mine = FakeProbe {
1112 boot_time: Some(before),
1113 pid_alive: Some(true),
1114 current: Some(identity(189, "platform:me")),
1115 ..FakeProbe::default()
1116 };
1117 assert_eq!(
1118 stale_guest_owner(&old, &mine),
1119 Some(StaleGuestClaim::ReleaseFailed)
1120 );
1121 }
1122
1123 #[test]
1127 fn the_incident_record_is_reclaimed_at_daemon_start_after_an_update() {
1128 let root = tempfile::tempdir().unwrap();
1129 fs::create_dir_all(root.path().join(FENCE_DIRECTORY)).unwrap();
1130 fs::write(
1131 root.path().join(FENCE_DIRECTORY).join(OWNER_FILE),
1132 PRE_0_4_35_RECORD,
1133 )
1134 .unwrap();
1135 let same_boot_new_daemon = FakeProbe {
1136 boot_time: Some("2026-09-20T00:00:00Z".parse().unwrap()),
1137 pid_alive: Some(true),
1138 process_started: Some("2026-10-07T18:00:00Z".parse().unwrap()),
1139 ..FakeProbe::default()
1140 };
1141
1142 let reclaimed =
1143 reclaim_stale_guest_claim_with(root.path(), &same_boot_new_daemon, Utc::now())
1144 .unwrap()
1145 .unwrap();
1146
1147 assert_eq!(reclaimed.reason, StaleGuestClaim::WrittenBeforeThisDaemon);
1148 assert!(!root.path().join(FENCE_DIRECTORY).exists());
1149 claimed(
1150 try_claim_guest_launch_with(root.path(), &same_boot_new_daemon, Utc::now()).unwrap(),
1151 );
1152 }
1153
1154 #[cfg(target_os = "linux")]
1156 #[test]
1157 fn this_linux_host_reports_when_it_booted() {
1158 let booted = HostProcesses
1159 .boot_time()
1160 .expect("/proc/stat names the boot");
1161 assert!(booted < Utc::now(), "{booted}");
1162 }
1163
1164 #[test]
1165 fn an_ownerless_claim_is_reclaimed_only_after_the_grace() {
1166 let root = tempfile::tempdir().unwrap();
1167 fs::create_dir_all(root.path().join(FENCE_DIRECTORY)).unwrap();
1168 let probe = FakeProbe::default();
1169 assert_eq!(
1170 reclaim_stale_guest_claim_with(root.path(), &probe, Utc::now()).unwrap(),
1171 None
1172 );
1173 let later = Utc::now() + chrono::Duration::minutes(6);
1174 let reclaimed = reclaim_stale_guest_claim_with(root.path(), &probe, later)
1175 .unwrap()
1176 .unwrap();
1177 assert_eq!(reclaimed.reason, StaleGuestClaim::OwnerNeverRecorded);
1178 assert!(!root.path().join(FENCE_DIRECTORY).exists());
1179 }
1180
1181 #[test]
1184 fn a_release_that_fails_reports_its_path_and_the_next_launch_reclaims_it() {
1185 let root = tempfile::tempdir().unwrap();
1186 let (claim, _) =
1187 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
1188 let directory = root.path().join(FENCE_DIRECTORY);
1191 let owner = fs::read(directory.join(OWNER_FILE)).unwrap();
1192 fs::remove_dir_all(&directory).unwrap();
1193 fs::write(&directory, b"not a directory").unwrap();
1194 assert!(
1195 remove_claim(&directory).is_err(),
1196 "the plant must make removal fail"
1197 );
1198
1199 let error = claim.release().expect_err("the removal failed");
1200 assert!(error.to_string().contains(FENCE_DIRECTORY), "{error}");
1201
1202 fs::remove_file(&directory).unwrap();
1203 fs::create_dir(&directory).unwrap();
1204 fs::write(directory.join(OWNER_FILE), owner).unwrap();
1205 let (_claim, reclaimed) =
1206 claimed(try_claim_guest_launch_with(root.path(), &HostProcesses, Utc::now()).unwrap());
1207 assert_eq!(reclaimed.unwrap().reason, StaleGuestClaim::ReleaseFailed);
1208 }
1209
1210 #[test]
1211 fn one_directory_has_exactly_one_owner_and_drop_releases_guest_claim() {
1212 let root = tempfile::tempdir().unwrap();
1213 let first = FenceClaim::try_claim(root.path(), FenceOwnerKind::GuestLaunch, None)
1214 .unwrap()
1215 .unwrap();
1216 assert!(
1217 FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(1))
1218 .unwrap()
1219 .is_none()
1220 );
1221 drop(first);
1222 assert!(
1223 FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(1))
1224 .unwrap()
1225 .is_some()
1226 );
1227 }
1228
1229 #[test]
1230 fn only_matching_recovery_generation_is_cleared() {
1231 let root = tempfile::tempdir().unwrap();
1232 let claim = FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(9))
1233 .unwrap()
1234 .unwrap();
1235 claim.make_durable();
1236 DrainRequest::new(9, Utc::now()).write(root.path()).unwrap();
1237 clear_recovery(root.path(), 8).unwrap();
1238 assert!(root.path().join(FENCE_DIRECTORY).exists());
1239 assert_eq!(
1240 DrainRequest::read(root.path()).unwrap().unwrap().generation,
1241 9
1242 );
1243 clear_recovery(root.path(), 9).unwrap();
1244 assert!(!root.path().join(FENCE_DIRECTORY).exists());
1245 assert!(!root.path().join(REQUEST_FILE).exists());
1246 }
1247
1248 #[test]
1249 fn retiring_windows_recovery_handles_restarted_generations_but_keeps_guest_claims() {
1250 let root = tempfile::tempdir().unwrap();
1251 let claim = FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(7))
1252 .unwrap()
1253 .unwrap();
1254 claim.make_durable();
1255 DrainRequest::new(8, Utc::now()).write(root.path()).unwrap();
1256 RecoveryStatus {
1257 schema_version: SCHEMA_VERSION,
1258 observed_at: Utc::now(),
1259 phase: RecoveryPhase::RecoveryBlocked,
1260 consecutive_probe_failures: 9,
1261 reason: Some("stale".into()),
1262 last_recovered_at: None,
1263 }
1264 .write(root.path())
1265 .unwrap();
1266
1267 retire_windows_recovery(root.path()).unwrap();
1268
1269 assert!(!root.path().join(FENCE_DIRECTORY).exists());
1270 assert!(!root.path().join(REQUEST_FILE).exists());
1271 assert!(!root.path().join(RECOVERY_STATUS_FILE).exists());
1272
1273 let guest = FenceClaim::try_claim(root.path(), FenceOwnerKind::GuestLaunch, None)
1274 .unwrap()
1275 .unwrap();
1276 guest.make_durable();
1277 DrainRequest::new(10, Utc::now())
1278 .write(root.path())
1279 .unwrap();
1280 retire_windows_recovery(root.path()).unwrap();
1281 assert!(root.path().join(FENCE_DIRECTORY).exists());
1282 assert!(!root.path().join(REQUEST_FILE).exists());
1283 }
1284
1285 #[test]
1286 fn a_restarted_watchdog_retires_only_recovery_nobody_is_still_driving() {
1287 let root = tempfile::tempdir().unwrap();
1288 let now = Utc::now();
1289 let window = std::time::Duration::from_secs(300);
1290 let old = now - chrono::Duration::minutes(10);
1291 let claim = FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(1))
1292 .unwrap()
1293 .unwrap();
1294 claim.make_durable();
1295 let owner_path = root.path().join(FENCE_DIRECTORY).join(OWNER_FILE);
1296 let backdate_owner = || {
1297 let mut owner = FenceClaim::owner(root.path()).unwrap().unwrap();
1298 owner.acquired_at = old;
1299 write_json(&owner_path, &owner).unwrap();
1300 };
1301
1302 backdate_owner();
1305 DrainRequest::new(2, now - chrono::Duration::seconds(10))
1306 .write(root.path())
1307 .unwrap();
1308 assert!(!retire_abandoned_windows_recovery(root.path(), now, window).unwrap());
1309 assert!(root.path().join(FENCE_DIRECTORY).exists());
1310
1311 DrainRequest::new(2, old).write(root.path()).unwrap();
1313 assert!(retire_abandoned_windows_recovery(root.path(), now, window).unwrap());
1314 assert!(!root.path().join(FENCE_DIRECTORY).exists());
1315 assert!(!root.path().join(REQUEST_FILE).exists());
1316 assert!(!retire_abandoned_windows_recovery(root.path(), now, window).unwrap());
1317
1318 let claim = FenceClaim::try_claim(root.path(), FenceOwnerKind::WindowsRecovery, Some(3))
1320 .unwrap()
1321 .unwrap();
1322 claim.make_durable();
1323 assert!(!retire_abandoned_windows_recovery(root.path(), now, window).unwrap());
1324 assert!(root.path().join(FENCE_DIRECTORY).exists());
1325 remove_claim(&root.path().join(FENCE_DIRECTORY)).unwrap();
1326
1327 let guest = FenceClaim::try_claim(root.path(), FenceOwnerKind::GuestLaunch, None)
1329 .unwrap()
1330 .unwrap();
1331 guest.make_durable();
1332 backdate_owner();
1333 DrainRequest::new(4, old).write(root.path()).unwrap();
1334 assert!(retire_abandoned_windows_recovery(root.path(), now, window).unwrap());
1335 assert!(root.path().join(FENCE_DIRECTORY).exists());
1336 assert!(!root.path().join(REQUEST_FILE).exists());
1337 }
1338
1339 #[test]
1340 fn heartbeat_and_status_documents_replace_atomically() {
1341 let root = tempfile::tempdir().unwrap();
1342 let first = GuestHeartbeat {
1343 schema_version: SCHEMA_VERSION,
1344 observed_at: Utc::now(),
1345 acknowledged_generation: None,
1346 local_active_attempts: Some(1),
1347 local_busy_attempts: Some(1),
1348 managed_targets: Vec::new(),
1349 unmanaged_runner_services: Some(0),
1350 };
1351 let mut second = first.clone();
1352 second.local_active_attempts = Some(0);
1353 first.write(root.path()).unwrap();
1354 second.write(root.path()).unwrap();
1355 assert_eq!(GuestHeartbeat::read(root.path()).unwrap(), Some(second));
1356
1357 let first = RecoveryStatus {
1358 schema_version: SCHEMA_VERSION,
1359 observed_at: Utc::now(),
1360 phase: RecoveryPhase::Degraded,
1361 consecutive_probe_failures: 1,
1362 reason: None,
1363 last_recovered_at: None,
1364 };
1365 let mut second = first.clone();
1366 second.phase = RecoveryPhase::Healthy;
1367 first.write(root.path()).unwrap();
1368 second.write(root.path()).unwrap();
1369 assert_eq!(RecoveryStatus::read(root.path()).unwrap(), Some(second));
1370 }
1371}