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    /// Another launch of this process now shares the claim: its age, which a
549    /// claim stuck for too long is told by, restarts from this launch.
550    ///
551    /// # Errors
552    /// The owner record cannot be read or replaced.
553    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
590/// Claim the guest launch boundary, first reclaiming a claim whose owner is
591/// provably gone, and retrying once after that.
592///
593/// # Errors
594/// [`FenceError`] when the fence cannot be created, read, or removed.
595pub 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    // The root must exist for its canonical spelling to be the key.
605    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
632/// Remove the guest launch claim under `root` when its owner is provably gone
633/// and this process does not hold it. The daemon calls this at start, after
634/// taking its single-instance lock and before any launch.
635///
636/// # Errors
637/// [`FenceError`] when the claim cannot be read or removed.
638pub 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
654/// The reclaim itself. The caller holds [`HELD_HERE`] and has checked that
655/// this process does not hold the claim.
656fn 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    // Re-read immediately before removing, so a claim that changed hands since
678    // it was judged is not the one removed.
679    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
710/// Retire coordination written by a Windows recovery watchdog that no longer
711/// has authority to run. A guest launch claim is deliberately preserved. The
712/// fence and request may have different generations after a watchdog restart,
713/// so each Windows-owned generation is cleared independently.
714pub 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
734/// Retire Windows recovery coordination that no live watchdog is maintaining.
735///
736/// A watchdog keeps its recovery generation in memory only. When it exits
737/// mid-recovery -- the Windows service or the companion task restarting while
738/// WSL is being recovered -- its durable fence claim and drain request outlive
739/// it, and its successor starts with a new generation. [`clear_recovery`] only
740/// clears a matching generation, so the successor never clears them and every
741/// guest launch stays fenced forever while WSL itself is healthy.
742///
743/// A live watchdog rewrites its drain request on every unhealthy observation,
744/// so a request newer than `abandoned_after` means recovery is still being
745/// driven and nothing is touched. A Windows-owned claim younger than that is
746/// likewise left alone, because its owner may be between claiming the fence
747/// and its next request write. A guest launch claim is never removed.
748///
749/// Returns `true` when abandoned coordination was retired.
750pub 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/// Persistent runner services are outside runner-manager's attempt journal.
823/// Their presence blocks automated WSL termination even while they look idle.
824#[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    /// A [`ProcessProbe`] whose every answer is chosen by the test.
855    #[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        /// `None` reads as the beginning of time, so no record predates it.
862        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    /// Plants a guest claim exactly as an abandoned one looks on disk.
903    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    /// A child that has already exited: its PID is free or somebody else's.
916    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    /// A claim whose PID now belongs to a different process -- here, this one
948    /// -- is reclaimed: the start token, not the PID, names the owner.
949    #[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        // What a failed removal leaves: the claim let go, the directory kept.
1020        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    /// The record that blocked the incident host, as 0.4.34 wrote it.
1051    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        // One distribution restarted inside a running WSL VM: same boot, a
1099        // new PID namespace where PID 189 is somebody else's.
1100        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    /// The incident record, read by an updated daemon whose WSL VM was never
1124    /// rebooted, with PID 189 now somebody else's: the case the boot time
1125    /// alone cannot settle.
1126    #[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    /// The real boot time is read on the one platform that reclaims by it.
1155    #[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    /// A release that cannot remove the claim says so, naming the path, and
1182    /// leaves a claim the next launch reclaims as one whose release failed.
1183    #[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        // Something the removal cannot take away: a file where the claim
1189        // directory was, holding this process's own owner record.
1190        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        // A live watchdog keeps its request fresh: nothing is touched even
1303        // though the claim itself is old and of another generation.
1304        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        // The incident: a dead generation's claim and request, WSL healthy.
1312        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        // A freshly claimed fence whose request has not been rewritten yet.
1319        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        // A guest launch claim is never retired, but an abandoned request is.
1328        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}