Skip to main content

runner_manager_platform/
launch_health.rs

1//! Whether this host's daemon can start runners at all.
2//!
3//! A managed WSL distribution once started no runner for 13 days while every
4//! surface called it healthy. A guest launch claim had outlived its owner, so
5//! every allocation was refused; the refusal was logged once a minute at WARN
6//! and nothing else noticed. The daemon is the only process that sees those
7//! refusals, and it shares no memory with `status`, `service status` or
8//! `host doctor`, so it says so on disk: [`record_launches_blocked`] writes
9//! [`LAUNCHES_BLOCKED_FILE`] once refusals have persisted, and the next granted
10//! allocation removes it.
11//!
12//! The record alone has a blind spot: a daemon with no queued demand asks for
13//! no allocation, so it never sees the refusal. [`stuck_launch_fence`] covers
14//! that by reading the fence itself, from either side of the WSL boundary.
15
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22use crate::paths::AppPaths;
23use crate::service::{ServiceError, read_state_record, remove_state_record, write_state_record};
24use crate::wsl::fence::{
25    FENCE_DIRECTORY, FenceClaim, FenceOwner, FenceOwnerKind, GuestRecoveryConfig,
26    OWNERLESS_CLAIM_GRACE, elapsed_at_least, fence_modified,
27};
28
29/// Present while the daemon's allocations keep being refused, inside
30/// `state/`.
31const LAUNCHES_BLOCKED_FILE: &str = "launches-blocked.toml";
32
33const SCHEMA_VERSION: u32 = 1;
34
35/// Consecutive refused allocations, with no launch of this daemon's own in
36/// progress, before launches count as blocked.
37pub const BLOCKED_AFTER_DEFERRALS: u32 = 5;
38
39/// The least time those refusals must span. Brief contention is normal; five
40/// minutes of nothing but refusals is not.
41pub const BLOCKED_AFTER: Duration = Duration::from_secs(5 * 60);
42
43/// How long a launch fence may be held before it counts as stuck. A launch
44/// holds it through a package download, the registration and the spawn, which
45/// take minutes at worst.
46const FENCE_HELD_TOO_LONG: Duration = Duration::from_secs(30 * 60);
47
48/// How long a daemon's record counts after its last refusal. Refusals happen
49/// only while there is demand, so a record no longer being refreshed means
50/// nothing is being refused; a fence that is still stuck is caught by
51/// [`stuck_launch_fence`] instead.
52const RECORD_FRESH: Duration = Duration::from_secs(15 * 60);
53
54/// Which side of the WSL boundary a report is written for: the commands it
55/// can name, and the clock a fence is aged by.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Viewpoint<'a> {
58    /// Inside the managed distribution, whose name the guest does not know.
59    Guest,
60    /// On Windows, for one named distribution.
61    Windows { distribution: &'a str },
62}
63
64impl Viewpoint<'_> {
65    fn name(self) -> String {
66        match self {
67            Self::Guest => "<name>".to_string(),
68            Self::Windows { distribution } => distribution.to_string(),
69        }
70    }
71
72    fn restart(self) -> String {
73        match self {
74            Self::Guest => {
75                "`sudo systemctl restart runner-manager.service` inside the distribution".into()
76            }
77            Self::Windows { distribution } => format!(
78                "`wsl.exe -d {distribution} -u root systemctl restart runner-manager.service`"
79            ),
80        }
81    }
82}
83
84/// Why this host starts no runner, and what ends it.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct LaunchesBlocked {
87    pub since: DateTime<Utc>,
88    pub reason: String,
89    pub remedy: String,
90}
91
92/// "since <when>: <reason>", the sentence every surface prints.
93impl std::fmt::Display for LaunchesBlocked {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        write!(f, "since {}: {}", self.since.to_rfc3339(), self.reason)
96    }
97}
98
99/// What refused an allocation, as the daemon saw it.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum BlockCause {
102    /// The WSL launch fence exists and was not reclaimed.
103    FenceHeld {
104        directory: PathBuf,
105        owner: Option<FenceOwner>,
106    },
107    /// The fence's owner record exists and cannot be read, so it can never
108    /// be judged, let alone reclaimed.
109    FenceOwnerUnreadable { directory: PathBuf, detail: String },
110    /// Windows asked the distribution to drain for WSL recovery.
111    DrainRequested { root: PathBuf },
112    /// The recovery configuration could not be used.
113    FenceUnusable { detail: String },
114    /// Another process kept the host-wide allocation lock.
115    AllocationLockBusy { path: PathBuf },
116}
117
118impl BlockCause {
119    /// The operator-facing reason and remedy, refused since `since`.
120    #[must_use]
121    pub fn blocked_since(&self, since: DateTime<Utc>, viewpoint: Viewpoint<'_>) -> LaunchesBlocked {
122        let name = viewpoint.name();
123        let restart = viewpoint.restart();
124        let watch_recovery = format!(
125            "launches resume when Windows finishes recovering it (`runner-manager wsl status \
126             --distribution {name}` on Windows shows the phase), and coordination no watchdog \
127             maintains is retired 5 minutes after it stops"
128        );
129        let (reason, remedy) = match self {
130            Self::FenceHeld {
131                directory,
132                owner: Some(owner),
133            } if owner.kind == FenceOwnerKind::WindowsRecovery => (
134                format!(
135                    "Windows WSL recovery holds the launch fence ({})",
136                    directory.display()
137                ),
138                watch_recovery,
139            ),
140            Self::FenceHeld {
141                directory,
142                owner: Some(owner),
143            } => (
144                format!(
145                    "a runner launch by process {} holds the WSL launch fence ({})",
146                    owner.process_id,
147                    directory.display()
148                ),
149                format!(
150                    "if no runner is being registered, restart the service with {restart}; the \
151                     restarted daemon reclaims a fence whose owner is gone"
152                ),
153            ),
154            Self::FenceHeld {
155                directory,
156                owner: None,
157            } => (
158                format!(
159                    "the WSL launch fence ({}) has no owner record",
160                    directory.display()
161                ),
162                format!(
163                    "restart the service with {restart}; if the fence survives the restart, \
164                     delete {} once no runner-manager launch is in progress",
165                    directory.display()
166                ),
167            ),
168            Self::FenceOwnerUnreadable { directory, detail } => (
169                format!("the WSL launch fence's owner record cannot be read: {detail}"),
170                format!(
171                    "delete {} once no runner-manager launch is in progress on {name}; no daemon \
172                     reclaims a fence whose owner it cannot read",
173                    directory.display()
174                ),
175            ),
176            Self::DrainRequested { root } => (
177                format!(
178                    "Windows has asked this distribution to drain for WSL recovery ({})",
179                    root.join(crate::wsl::fence::REQUEST_FILE).display()
180                ),
181                watch_recovery,
182            ),
183            Self::FenceUnusable { detail } => (
184                format!("the WSL recovery configuration cannot be used: {detail}"),
185                format!(
186                    "repair the managed host from Windows with `runner-manager wsl install \
187                     --distribution {name}`"
188                ),
189            ),
190            Self::AllocationLockBusy { path } => (
191                format!(
192                    "another process keeps the runtime allocation lock ({})",
193                    path.display()
194                ),
195                "stop the other runner-manager agent using this state directory".to_string(),
196            ),
197        };
198        LaunchesBlocked {
199            since,
200            reason,
201            remedy,
202        }
203    }
204}
205
206#[derive(Debug, Serialize, Deserialize)]
207struct LaunchesBlockedRecord {
208    schema_version: u32,
209    /// The latest refusal, rewritten on each one. See [`RECORD_FRESH`].
210    last_refused_at: DateTime<Utc>,
211    #[serde(flatten)]
212    blocked: LaunchesBlocked,
213}
214
215/// Records that the daemon's allocations keep being refused, the latest
216/// refusal at `last_refused_at`.
217///
218/// # Errors
219/// [`ServiceError::Record`] when `state/` cannot be written.
220pub fn record_launches_blocked(
221    paths: &AppPaths,
222    blocked: &LaunchesBlocked,
223    last_refused_at: DateTime<Utc>,
224) -> Result<(), ServiceError> {
225    write_state_record(
226        &launches_blocked_path(paths),
227        &LaunchesBlockedRecord {
228            schema_version: SCHEMA_VERSION,
229            last_refused_at,
230            blocked: blocked.clone(),
231        },
232    )
233}
234
235/// Clears the record once an allocation is granted, or when a daemon starts.
236///
237/// # Errors
238/// [`ServiceError::Record`] when the record exists and cannot be removed.
239pub fn clear_launches_blocked(paths: &AppPaths) -> Result<(), ServiceError> {
240    remove_state_record(&launches_blocked_path(paths))
241}
242
243/// The daemon's own record of blocked launches, if it wrote one and refused
244/// an allocation within [`RECORD_FRESH`] of `now`.
245///
246/// # Errors
247/// [`ServiceError::Record`] when the file exists and cannot be read or parsed.
248pub fn recorded_launches_blocked(
249    paths: &AppPaths,
250    now: DateTime<Utc>,
251) -> Result<Option<LaunchesBlocked>, ServiceError> {
252    Ok(
253        read_state_record::<LaunchesBlockedRecord>(&launches_blocked_path(paths))?
254            .filter(|record| !elapsed_at_least(record.last_refused_at, now, RECORD_FRESH))
255            .map(|record| record.blocked),
256    )
257}
258
259fn launches_blocked_path(paths: &AppPaths) -> PathBuf {
260    paths.state_dir().join(LAUNCHES_BLOCKED_FILE)
261}
262
263/// A launch fence under `root` that has been held for longer than any launch
264/// takes, seen from `viewpoint`.
265///
266/// The guest ages a claim by the owner's own `acquired_at`, written by the
267/// guest clock. Windows ages it by the directory's modification time, which
268/// NTFS stamps with the Windows clock, because a WSL clock can lag the host's
269/// by a long way after the machine sleeps.
270#[must_use]
271pub fn stuck_launch_fence(
272    root: &Path,
273    now: DateTime<Utc>,
274    viewpoint: Viewpoint<'_>,
275) -> Option<LaunchesBlocked> {
276    let directory = root.join(FENCE_DIRECTORY);
277    let modified = || fence_modified(root).ok().flatten();
278    match FenceClaim::owner(root) {
279        Ok(Some(owner)) => {
280            let since = match viewpoint {
281                Viewpoint::Guest => owner.acquired_at,
282                Viewpoint::Windows { .. } => modified()?,
283            };
284            elapsed_at_least(since, now, FENCE_HELD_TOO_LONG).then(|| {
285                BlockCause::FenceHeld {
286                    directory,
287                    owner: Some(owner),
288                }
289                .blocked_since(since, viewpoint)
290            })
291        }
292        // A claimer writes its owner straight after creating the directory, so
293        // one still missing after the reclaim grace is not a launch in progress.
294        Ok(None) => {
295            let since = modified()?;
296            elapsed_at_least(since, now, OWNERLESS_CLAIM_GRACE).then(|| {
297                BlockCause::FenceHeld {
298                    directory,
299                    owner: None,
300                }
301                .blocked_since(since, viewpoint)
302            })
303        }
304        Err(error) => Some(
305            BlockCause::FenceOwnerUnreadable {
306                directory,
307                detail: error.to_string(),
308            }
309            .blocked_since(modified().unwrap_or(now), viewpoint),
310        ),
311    }
312}
313
314/// Whether this host's own daemon starts no runner: its fresh record first,
315/// then, inside a managed WSL distribution, the fence itself.
316#[must_use]
317pub fn launches_blocked(paths: &AppPaths, now: DateTime<Utc>) -> Option<LaunchesBlocked> {
318    if let Ok(Some(blocked)) = recorded_launches_blocked(paths, now) {
319        return Some(blocked);
320    }
321    let config = GuestRecoveryConfig::read(paths).ok().flatten()?;
322    stuck_launch_fence(&config.shared_root, now, Viewpoint::Guest)
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::wsl::fence::FenceOwnerKind;
329
330    fn paths(root: &Path) -> AppPaths {
331        AppPaths::rooted_at(root)
332    }
333
334    #[test]
335    fn the_record_round_trips_and_clears() {
336        let dir = tempfile::tempdir().unwrap();
337        let paths = paths(dir.path());
338        let now = Utc::now();
339        assert_eq!(recorded_launches_blocked(&paths, now).unwrap(), None);
340        let blocked = BlockCause::AllocationLockBusy {
341            path: PathBuf::from("allocation.lock"),
342        }
343        .blocked_since(now, Viewpoint::Guest);
344        record_launches_blocked(&paths, &blocked, now).unwrap();
345        assert_eq!(
346            recorded_launches_blocked(&paths, now).unwrap(),
347            Some(blocked.clone())
348        );
349        assert_eq!(launches_blocked(&paths, now), Some(blocked));
350        clear_launches_blocked(&paths).unwrap();
351        assert_eq!(launches_blocked(&paths, now), None);
352    }
353
354    /// A daemon refuses only while there is demand. A record nobody refreshes
355    /// -- the demand went elsewhere, the service was stopped -- must not keep
356    /// a host that would launch fine reported as blocked.
357    #[test]
358    fn a_record_no_refusal_refreshes_expires() {
359        let dir = tempfile::tempdir().unwrap();
360        let paths = paths(dir.path());
361        let refused = Utc::now();
362        let blocked = BlockCause::AllocationLockBusy {
363            path: PathBuf::from("allocation.lock"),
364        }
365        .blocked_since(refused, Viewpoint::Guest);
366        record_launches_blocked(&paths, &blocked, refused).unwrap();
367
368        let soon = refused + chrono::Duration::minutes(14);
369        assert_eq!(launches_blocked(&paths, soon), Some(blocked));
370        let later = refused + chrono::Duration::minutes(16);
371        assert_eq!(launches_blocked(&paths, later), None);
372    }
373
374    #[test]
375    fn a_fence_is_stuck_only_once_it_outlives_any_launch() {
376        let dir = tempfile::tempdir().unwrap();
377        assert_eq!(
378            stuck_launch_fence(dir.path(), Utc::now(), Viewpoint::Guest),
379            None
380        );
381        let claim = FenceClaim::try_claim(dir.path(), FenceOwnerKind::GuestLaunch, None)
382            .unwrap()
383            .unwrap();
384        assert_eq!(
385            stuck_launch_fence(dir.path(), Utc::now(), Viewpoint::Guest),
386            None
387        );
388        let later = Utc::now() + chrono::Duration::minutes(31);
389        let stuck = stuck_launch_fence(dir.path(), later, Viewpoint::Guest).expect("stuck");
390        assert!(
391            stuck.reason.contains("runner launch by process"),
392            "{stuck:?}"
393        );
394        assert!(stuck.remedy.contains("systemctl restart"), "{stuck:?}");
395        let windows = stuck_launch_fence(
396            dir.path(),
397            later,
398            Viewpoint::Windows {
399                distribution: "Ubuntu",
400            },
401        )
402        .expect("stuck from Windows too");
403        assert!(
404            windows.remedy.contains("wsl.exe -d Ubuntu -u root"),
405            "{windows:?}"
406        );
407        drop(claim);
408        assert_eq!(
409            stuck_launch_fence(dir.path(), later, Viewpoint::Guest),
410            None
411        );
412    }
413
414    /// Windows ages a claim by its own clock, not by the guest-written
415    /// `acquired_at`: a WSL clock lagging the host's by an hour must not make a
416    /// claim made a moment ago look stuck.
417    #[test]
418    fn windows_ages_a_fence_by_its_own_clock() {
419        let dir = tempfile::tempdir().unwrap();
420        let fence = dir.path().join(FENCE_DIRECTORY);
421        std::fs::create_dir_all(&fence).unwrap();
422        let lagging = FenceOwner {
423            schema_version: crate::wsl::fence::SCHEMA_VERSION,
424            kind: FenceOwnerKind::GuestLaunch,
425            generation: None,
426            process_id: 7,
427            acquired_at: Utc::now() - chrono::Duration::hours(1),
428            identity: None,
429        };
430        std::fs::write(
431            fence.join(crate::wsl::fence::OWNER_FILE),
432            serde_json::to_vec(&lagging).unwrap(),
433        )
434        .unwrap();
435        let windows = Viewpoint::Windows {
436            distribution: "Ubuntu",
437        };
438        assert_eq!(stuck_launch_fence(dir.path(), Utc::now(), windows), None);
439        assert!(stuck_launch_fence(dir.path(), Utc::now(), Viewpoint::Guest).is_some());
440    }
441
442    #[test]
443    fn an_unreadable_owner_is_reported_at_once_with_the_directory_to_delete() {
444        let dir = tempfile::tempdir().unwrap();
445        let fence = dir.path().join(FENCE_DIRECTORY);
446        std::fs::create_dir_all(&fence).unwrap();
447        std::fs::write(fence.join(crate::wsl::fence::OWNER_FILE), b"{not json").unwrap();
448        let stuck = stuck_launch_fence(dir.path(), Utc::now(), Viewpoint::Guest).expect("stuck");
449        assert!(stuck.reason.contains("cannot be read"), "{stuck:?}");
450        assert!(stuck.remedy.starts_with("delete "), "{stuck:?}");
451        assert!(!stuck.remedy.contains("wsl install"), "{stuck:?}");
452    }
453
454    #[test]
455    fn a_guest_reports_its_own_stuck_fence_without_a_record() {
456        let dir = tempfile::tempdir().unwrap();
457        let paths = paths(&dir.path().join("app"));
458        let shared = dir.path().join("shared");
459        GuestRecoveryConfig::new(shared.clone())
460            .write(&paths)
461            .unwrap();
462        let claim = FenceClaim::try_claim(&shared, FenceOwnerKind::GuestLaunch, None)
463            .unwrap()
464            .unwrap();
465        claim.make_durable();
466        assert_eq!(launches_blocked(&paths, Utc::now()), None);
467        let later = Utc::now() + chrono::Duration::hours(1);
468        assert!(launches_blocked(&paths, later).is_some());
469    }
470}