1use 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
29const LAUNCHES_BLOCKED_FILE: &str = "launches-blocked.toml";
32
33const SCHEMA_VERSION: u32 = 1;
34
35pub const BLOCKED_AFTER_DEFERRALS: u32 = 5;
38
39pub const BLOCKED_AFTER: Duration = Duration::from_secs(5 * 60);
42
43const FENCE_HELD_TOO_LONG: Duration = Duration::from_secs(30 * 60);
48
49const RECORD_FRESH: Duration = Duration::from_secs(15 * 60);
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Viewpoint<'a> {
59 Guest,
61 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#[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
93impl 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#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum BlockCause {
103 FenceHeld {
105 directory: PathBuf,
106 owner: Option<FenceOwner>,
107 },
108 FenceOwnerUnreadable { directory: PathBuf, detail: String },
111 DrainRequested { root: PathBuf },
113 FenceUnusable { detail: String },
115 AllocationLockBusy { path: PathBuf },
117}
118
119impl BlockCause {
120 #[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 last_refused_at: DateTime<Utc>,
212 #[serde(flatten)]
213 blocked: LaunchesBlocked,
214}
215
216pub 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
236pub fn clear_launches_blocked(paths: &AppPaths) -> Result<(), ServiceError> {
241 remove_state_record(&launches_blocked_path(paths))
242}
243
244pub 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#[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 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#[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 #[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 #[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}