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