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);
47
48const RECORD_FRESH: Duration = Duration::from_secs(15 * 60);
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Viewpoint<'a> {
58 Guest,
60 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#[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
92impl 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#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum BlockCause {
102 FenceHeld {
104 directory: PathBuf,
105 owner: Option<FenceOwner>,
106 },
107 FenceOwnerUnreadable { directory: PathBuf, detail: String },
110 DrainRequested { root: PathBuf },
112 FenceUnusable { detail: String },
114 AllocationLockBusy { path: PathBuf },
116}
117
118impl BlockCause {
119 #[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 last_refused_at: DateTime<Utc>,
211 #[serde(flatten)]
212 blocked: LaunchesBlocked,
213}
214
215pub 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
235pub fn clear_launches_blocked(paths: &AppPaths) -> Result<(), ServiceError> {
240 remove_state_record(&launches_blocked_path(paths))
241}
242
243pub 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#[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 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#[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 #[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 #[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}