Skip to main content

runner_manager_platform/wsl/
fence.rs

1//! Cross-boundary recovery fence shared by Windows and a managed WSL guest.
2//!
3//! Windows share locks, Unix `flock`, and SQLite byte-range locks do not
4//! interoperate reliably through DrvFS. Directory creation does: exactly one
5//! side can create the same directory. The directory is intentionally durable
6//! on process death; an abandoned owner blocks recovery instead of allowing a
7//! possibly concurrent runner launch.
8//!
9//! # A guest claim whose owner is provably gone is reclaimed
10//!
11//! Durability has a price: a guest daemon that dies mid-launch -- WSL shut down
12//! under it, or the directory removal failing on DrvFS -- leaves a claim that
13//! nothing ever removes, and every later launch on that distribution is refused
14//! for good. That happened for 13 days on one host while the distribution
15//! looked healthy. So a guest claim records its owner's [`ProcessIdentity`],
16//! and [`try_claim_guest_launch`] reclaims one only when that owner is *provably*
17//! gone: the process exited, its PID now belongs to somebody else, the claim
18//! predates this boot, or this very process wrote it and its release failed.
19//! A live owner, an owner that cannot be inspected, and a Windows recovery
20//! claim are never touched. Only one guest launcher can exist per distribution,
21//! because the guest configuration and the agent's single-instance lock live
22//! under the same account's paths.
23
24use 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
44/// Windows-side directory shared with one exact distribution.
45pub 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    /// Attempts which the guest journal still considers to be executing a
168    /// GitHub job. This lets recovery distinguish a stuck idle/listener
169    /// process from work which must never be interrupted.
170    #[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/// Who holds the launch fence, as recorded by the holder.
226///
227/// Unlike the other documents here this one tolerates unknown fields, so that
228/// a later build can add to the owner record without this build reading a
229/// newer guest's claim as undecodable.
230#[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    /// The claimer's PID plus start token, the record `HostLock` keeps. A PID
238    /// alone cannot tell the owner from whatever holds that PID after a WSL
239    /// restart. Absent in claims written before 0.4.35, and when the claimer
240    /// could not read its own identity.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub identity: Option<ProcessIdentity>,
243}
244
245/// A successfully created cross-boundary directory claim.
246#[derive(Debug)]
247pub struct FenceClaim {
248    directory: PathBuf,
249    release_on_drop: bool,
250}
251
252impl FenceClaim {
253    /// Attempt to claim the launch boundary. `Ok(None)` means another side
254    /// owns it; absence or malformed owner metadata never makes it free.
255    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    /// Leave a recovery claim durable. A restarted watchdog may adopt only a
286    /// matching recovery owner; a guest-launch owner is never removed blindly.
287    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/// Whether at least `limit` has passed between `since` and `now`. A `since` in
310/// the future -- clock skew -- has not.
311#[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
320/// When the fence directory under `root` was last modified, or `None` when
321/// there is no fence.
322pub(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
331/// How many times [`GuestLaunchClaim::release`] tries to remove the claim.
332const RELEASE_ATTEMPTS: u32 = 3;
333/// The pause before the first retry; each later one waits a step longer.
334const RELEASE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
335
336/// Why a guest launch claim no longer has an owner.
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub enum StaleGuestClaim {
339    /// The recorded process has exited, or its PID now belongs to another
340    /// process.
341    OwnerExited,
342    /// The claim was written before this boot, so its owner cannot be running.
343    EarlierBoot,
344    /// A claim with no identity, written before this daemon started. Only
345    /// one launcher per distribution can run, so its owner is gone.
346    WrittenBeforeThisDaemon,
347    /// This process wrote the claim, no longer holds it, and could not remove
348    /// it when it let go.
349    ReleaseFailed,
350    /// The claim never recorded an owner and is older than any claimer takes
351    /// to write one.
352    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
367/// How long a claim directory may exist without `owner.json` before it is
368/// treated as abandoned. Every claimer writes the owner straight after
369/// creating the directory, so this is generous by orders of magnitude.
370pub(crate) const OWNERLESS_CLAIM_GRACE: std::time::Duration =
371    std::time::Duration::from_secs(5 * 60);
372
373/// What reclaiming needs to know about processes. A port, so that every branch
374/// can be exercised on every leg of the CI matrix.
375pub(crate) trait ProcessProbe {
376    /// This process's identity, when it can be read.
377    fn current(&self) -> Option<ProcessIdentity>;
378    /// What a recorded identity refers to now; `None` when the operating
379    /// system will not say.
380    fn recheck(&self, identity: &ProcessIdentity) -> Option<Adoption>;
381    /// Whether anything holds `pid`; `None` when the operating system will not
382    /// say.
383    fn pid_alive(&self, pid: u32) -> Option<bool>;
384    /// When this machine booted, where the platform reports it.
385    fn boot_time(&self) -> Option<DateTime<Utc>>;
386    /// When this process started reclaiming: no claim without an identity
387    /// written before then can be this process's, and the single-instance
388    /// lock means no other launcher of this distribution is running.
389    fn process_started(&self) -> DateTime<Utc>;
390}
391
392/// [`ProcessProbe`] against the machine this runs on.
393pub(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        // Set by the first reclaim, which the daemon makes at start.
418        static STARTED: std::sync::LazyLock<DateTime<Utc>> = std::sync::LazyLock::new(Utc::now);
419        *STARTED
420    }
421}
422
423/// Decide whether a recorded guest launch owner is provably gone.
424///
425/// Must not be asked while this process holds the claim: a claim carrying this
426/// process's own identity is read as one whose release failed. `None` means
427/// the claim must be honoured -- a live owner, an owner the operating system
428/// will not describe, and every Windows recovery claim.
429fn 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    // No identity: written before 0.4.35, or by a claimer that could not read
444    // its own. Judge it by the boot, by this process's start, and by the bare
445    // PID. The boot alone is not enough: restarting one distribution gives it
446    // a new PID namespace inside the same WSL VM, so the boot time stays put
447    // while its recorded PID -- a low, early-boot number -- now belongs to
448    // somebody else.
449    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        // This process wrote it and lost it, or a predecessor holding this PID
460        // earlier in the boot did. Neither is holding it now.
461        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/// A stale guest claim that was removed.
470#[derive(Debug, Clone, PartialEq, Eq)]
471pub struct ReclaimedGuestClaim {
472    pub reason: StaleGuestClaim,
473    /// The record that was removed; `None` for a claim with no owner file.
474    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
492/// Fence directories this process holds a guest claim on right now.
493///
494/// Process-wide rather than per lock, because "this process holds it" is a
495/// fact about the process: a daemon that reloads builds a new allocation lock,
496/// and a claim from the previous generation must still not look abandoned.
497static HELD_HERE: std::sync::Mutex<std::collections::BTreeSet<PathBuf>> =
498    std::sync::Mutex::new(std::collections::BTreeSet::new());
499
500/// The held-set entry for the fence under `root`: canonical where possible,
501/// so two spellings of one shared root cannot each look unheld.
502fn 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/// The outcome of [`try_claim_guest_launch`].
515#[derive(Debug)]
516pub enum GuestClaimAttempt {
517    Claimed {
518        claim: GuestLaunchClaim,
519        /// A stale claim that had to be removed first.
520        reclaimed: Option<ReclaimedGuestClaim>,
521    },
522    /// Somebody that may still be running holds it.
523    Busy,
524}
525
526/// A held guest launch claim. [`GuestLaunchClaim::release`] lets go of it and
527/// reports a failure; dropping it lets go silently.
528#[derive(Debug)]
529pub struct GuestLaunchClaim {
530    claim: Option<FenceClaim>,
531    /// This claim's entry in the held set.
532    key: PathBuf,
533}
534
535impl GuestLaunchClaim {
536    /// Release the claim, retrying the failures DrvFS reports transiently -- a
537    /// handle the Windows side still has open, an antivirus scan.
538    ///
539    /// # Errors
540    /// The last removal failure. The claim is left behind and the next launch
541    /// reclaims it as one whose release failed; the caller should say so,
542    /// because this once failed silently while a distribution started nothing
543    /// for 13 days.
544    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
573/// Claim the guest launch boundary, first reclaiming a claim whose owner is
574/// provably gone, and retrying once after that.
575///
576/// # Errors
577/// [`FenceError`] when the fence cannot be created, read, or removed.
578pub 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    // The root must exist for its canonical spelling to be the key.
588    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
615/// Remove the guest launch claim under `root` when its owner is provably gone
616/// and this process does not hold it. The daemon calls this at start, after
617/// taking its single-instance lock and before any launch.
618///
619/// # Errors
620/// [`FenceError`] when the claim cannot be read or removed.
621pub 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
637/// The reclaim itself. The caller holds [`HELD_HERE`] and has checked that
638/// this process does not hold the claim.
639fn 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    // Re-read immediately before removing, so a claim that changed hands since
661    // it was judged is not the one removed.
662    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
693/// Retire coordination written by a Windows recovery watchdog that no longer
694/// has authority to run. A guest launch claim is deliberately preserved. The
695/// fence and request may have different generations after a watchdog restart,
696/// so each Windows-owned generation is cleared independently.
697pub 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
717/// Retire Windows recovery coordination that no live watchdog is maintaining.
718///
719/// A watchdog keeps its recovery generation in memory only. When it exits
720/// mid-recovery -- the Windows service or the companion task restarting while
721/// WSL is being recovered -- its durable fence claim and drain request outlive
722/// it, and its successor starts with a new generation. [`clear_recovery`] only
723/// clears a matching generation, so the successor never clears them and every
724/// guest launch stays fenced forever while WSL itself is healthy.
725///
726/// A live watchdog rewrites its drain request on every unhealthy observation,
727/// so a request newer than `abandoned_after` means recovery is still being
728/// driven and nothing is touched. A Windows-owned claim younger than that is
729/// likewise left alone, because its owner may be between claiming the fence
730/// and its next request write. A guest launch claim is never removed.
731///
732/// Returns `true` when abandoned coordination was retired.
733pub 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/// Persistent runner services are outside runner-manager's attempt journal.
806/// Their presence blocks automated WSL termination even while they look idle.
807#[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    /// A [`ProcessProbe`] whose every answer is chosen by the test.
838    #[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        /// `None` reads as the beginning of time, so no record predates it.
845        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    /// Plants a guest claim exactly as an abandoned one looks on disk.
886    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    /// A child that has already exited: its PID is free or somebody else's.
899    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    /// A claim whose PID now belongs to a different process -- here, this one
931    /// -- is reclaimed: the start token, not the PID, names the owner.
932    #[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        // What a failed removal leaves: the claim let go, the directory kept.
1003        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    /// The record that blocked the incident host, as 0.4.34 wrote it.
1034    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        // One distribution restarted inside a running WSL VM: same boot, a
1082        // new PID namespace where PID 189 is somebody else's.
1083        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    /// The incident record, read by an updated daemon whose WSL VM was never
1107    /// rebooted, with PID 189 now somebody else's: the case the boot time
1108    /// alone cannot settle.
1109    #[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    /// The real boot time is read on the one platform that reclaims by it.
1138    #[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    /// A release that cannot remove the claim says so, naming the path, and
1165    /// leaves a claim the next launch reclaims as one whose release failed.
1166    #[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        // Something the removal cannot take away: a file where the claim
1172        // directory was, holding this process's own owner record.
1173        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        // A live watchdog keeps its request fresh: nothing is touched even
1286        // though the claim itself is old and of another generation.
1287        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        // The incident: a dead generation's claim and request, WSL healthy.
1295        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        // A freshly claimed fence whose request has not been rewritten yet.
1302        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        // A guest launch claim is never retired, but an abandoned request is.
1311        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}