1#[cfg(test)]
12use std::collections::VecDeque;
13use std::collections::{BTreeMap, BTreeSet};
14use std::ffi::OsStr;
15use std::fmt;
16use std::fs;
17use std::io::Write;
18use std::num::NonZeroU16;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::time::Duration;
22
23use async_trait::async_trait;
24use runner_manager_domain::attempt::{
25 AttemptOutcome, AttemptState, FailureReason, GithubRunnerObservation, RecoveryDecision,
26 RecoveryObservation, RecoveryTimeouts, RunnerAttempt, authorize, recovery_decision,
27};
28use runner_manager_domain::model::{AttemptId, Clock, HostId, PolicyId, ScaleTarget};
29use runner_manager_domain::path::LocalAbsolutePath;
30use runner_manager_domain::policy::{RoutingLabels, ScalePolicy};
31use runner_manager_domain::store::{Store, StoreError};
32use runner_manager_domain::workspace::{AttemptWorkspace, WorkspacePolicy};
33use runner_manager_github::jit::{
34 DEFAULT_WORK_FOLDER, EncodedJitConfig, JitError, JitGateway, JitRegistration, JitRunnerRequest,
35};
36use runner_manager_github::rest::{CancelToken, InventoryGateway};
37use runner_manager_platform::process::{
38 Adoption, ChildProcess, ProcessIdentity, RestrictiveHandoff, SpawnSpec, Termination,
39};
40use runner_manager_platform::runner_root::{
41 self, RootOwner, RootPreflight, RunnerRootError, default_runner_root,
42};
43use secrecy::SecretString;
44
45use crate::package::{PackageCache, PackageError, RunnerVersion};
46use crate::reconcile::{
47 AllocationGuard, EventSink, LaunchFailure, LaunchRequest, LifecycleEvent, OutcomeKind,
48 ReplacementIntent, RunnerLauncher,
49};
50
51const IDENTITY_FILE: &str = ".runner-process.json";
52const FALLBACK_IDENTITY_FILE: &str = ".runner-process.recovery.json";
53const UNRESOLVED_PROCESS_FILE: &str = ".runner-process.unresolved";
54const RUNNER_ID_FILE: &str = ".github-runner-id";
55const TERMINATE_INTENT_FILE: &str = ".terminate-registration-timeout";
56const MAX_POST_SPAWN_STOP_ATTEMPTS: usize = 3;
57
58const SENSITIVE_SLOT_ENTRIES: &[&str] = &[
71 "bin",
73 "externals",
74 "run.sh",
75 "run.cmd",
76 "config.sh",
77 "config.cmd",
78 ".runner",
81 ".credentials",
82 ".credentials_rsaparams",
83 ".env",
84 ".path",
85 "_diag",
86 IDENTITY_FILE,
88 FALLBACK_IDENTITY_FILE,
89 UNRESOLVED_PROCESS_FILE,
90 RUNNER_ID_FILE,
91 TERMINATE_INTENT_FILE,
92];
93#[cfg(test)]
94const TEST_LISTENER_READY: &str = ".test-listener-ready";
95
96fn runner_listener_spec(program: PathBuf, runtime: &Path) -> SpawnSpec {
101 let tmp = runtime.join("tmp");
102 let _ = std::fs::create_dir_all(&tmp);
103 SpawnSpec::new(program)
104 .arg("run")
105 .working_dir(runtime)
106 .env("TMPDIR", &tmp)
107 .env("TEMP", &tmp)
108 .env("TMP", &tmp)
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct RetryPolicy {
114 pub max_attempts: u32,
115 pub initial: Duration,
116 pub maximum: Duration,
117}
118
119impl RetryPolicy {
120 #[must_use]
121 pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
122 Self {
123 max_attempts,
124 initial,
125 maximum,
126 }
127 }
128
129 fn delay(self, failure_index: u32) -> Duration {
130 let shift = failure_index.saturating_sub(1).min(31);
131 self.initial
132 .saturating_mul(1_u32 << shift)
133 .min(self.maximum)
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum AttemptEvent {
141 State {
142 attempt: AttemptId,
143 state: AttemptState,
144 },
145 Retry {
146 attempt: AttemptId,
147 operation: &'static str,
148 delay: Duration,
149 },
150 Launched {
154 attempt: AttemptId,
155 started: bool,
156 timings: LaunchTimings,
157 },
158 Adopted {
159 attempt: AttemptId,
160 },
161 RemoteIdentityRecovered {
162 attempt: AttemptId,
163 runner_id: u64,
164 },
165 TerminateIntent {
166 attempt: AttemptId,
167 },
168 Terminated {
169 attempt: AttemptId,
170 },
171 Deregistered {
175 attempt: AttemptId,
176 runner_id: u64,
177 },
178 Concluded {
179 attempt: AttemptId,
180 outcome: OutcomeKind,
181 },
182 Cleaned {
183 attempt: AttemptId,
184 outcome: OutcomeKind,
185 },
186}
187
188pub trait AttemptEventSink: fmt::Debug + Send + Sync {
189 fn emit(&self, event: AttemptEvent);
190}
191
192#[derive(Debug, Default)]
193pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
194
195impl AttemptEventLog {
196 #[must_use]
197 pub fn events(&self) -> Vec<AttemptEvent> {
198 self.0
199 .lock()
200 .map(|events| events.clone())
201 .unwrap_or_default()
202 }
203}
204
205impl AttemptEventSink for AttemptEventLog {
206 fn emit(&self, event: AttemptEvent) {
207 if let Ok(mut events) = self.0.lock() {
208 events.push(event);
209 }
210 }
211}
212
213#[derive(Debug, Clone, Copy, Default)]
214pub struct NoAttemptEvents;
215
216impl AttemptEventSink for NoAttemptEvents {
217 fn emit(&self, _event: AttemptEvent) {}
218}
219
220#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
222pub struct LaunchTimings {
223 pub lock_wait: Duration,
225 pub materialize: Duration,
226 pub register: Duration,
227 pub spawn: Duration,
228}
229
230impl LaunchTimings {
231 #[must_use]
232 pub fn total(&self) -> Duration {
233 self.lock_wait + self.materialize + self.register + self.spawn
234 }
235}
236
237#[derive(Debug, Clone, Copy)]
238enum LaunchStep {
239 Materialize,
240 Register,
241 Spawn,
242}
243
244#[derive(Debug)]
248struct LaunchStopwatch {
249 timings: LaunchTimings,
250 running: Option<(LaunchStep, std::time::Instant)>,
251}
252
253impl LaunchStopwatch {
254 fn new(lock_wait: Duration) -> Self {
255 Self {
256 timings: LaunchTimings {
257 lock_wait,
258 ..LaunchTimings::default()
259 },
260 running: None,
261 }
262 }
263
264 fn begin(&mut self, step: LaunchStep) {
265 self.finish();
266 self.running = Some((step, std::time::Instant::now()));
267 }
268
269 fn finish(&mut self) {
270 if let Some((step, since)) = self.running.take() {
271 let slot = match step {
272 LaunchStep::Materialize => &mut self.timings.materialize,
273 LaunchStep::Register => &mut self.timings.register,
274 LaunchStep::Spawn => &mut self.timings.spawn,
275 };
276 *slot = since.elapsed();
277 }
278 }
279}
280
281const SLOW_LAUNCH: Duration = Duration::from_secs(60);
286
287#[derive(Debug, Clone, Copy, Default)]
294pub struct TracingAttemptEvents;
295
296impl AttemptEventSink for TracingAttemptEvents {
297 fn emit(&self, event: AttemptEvent) {
298 match event {
299 AttemptEvent::Retry {
300 attempt,
301 operation,
302 delay,
303 } => tracing::warn!(
304 event = "attempt_retry",
305 attempt_id = %attempt,
306 reason = operation,
307 retry_in_ms = millis(delay),
308 ),
309 AttemptEvent::Launched {
310 attempt,
311 started,
312 timings,
313 } => {
314 macro_rules! launched {
315 ($level:ident, $name:literal) => {
316 tracing::$level!(
317 event = $name,
318 attempt_id = %attempt,
319 duration_ms = millis(timings.total()),
320 lock_wait_ms = millis(timings.lock_wait),
321 materialize_ms = millis(timings.materialize),
322 register_ms = millis(timings.register),
323 spawn_ms = millis(timings.spawn),
324 )
325 };
326 }
327 if !started {
328 launched!(warn, "attempt_launch_failed");
329 } else if timings.total() >= SLOW_LAUNCH {
330 launched!(warn, "attempt_launch_slow");
331 } else {
332 launched!(info, "attempt_launched");
333 }
334 }
335 _ => {}
336 }
337 }
338}
339
340fn millis(duration: Duration) -> u64 {
341 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
342}
343
344#[async_trait]
346pub trait DemandPersistence: fmt::Debug + Send + Sync {
347 async fn persists(&self, policy: PolicyId) -> bool;
348}
349
350#[derive(Debug, Clone, Copy, Default)]
351pub struct PersistentDemand;
352
353#[async_trait]
354impl DemandPersistence for PersistentDemand {
355 async fn persists(&self, _policy: PolicyId) -> bool {
356 true
357 }
358}
359
360#[async_trait]
361pub trait RetryDelay: fmt::Debug + Send + Sync {
362 async fn wait(&self, duration: Duration);
363}
364
365#[derive(Debug, Clone, Copy, Default)]
366pub struct TokioRetryDelay;
367
368#[async_trait]
369impl RetryDelay for TokioRetryDelay {
370 async fn wait(&self, duration: Duration) {
371 tokio::time::sleep(duration).await;
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct JitRequestFailure {
377 pub terminal: bool,
378 pub reason: FailureReason,
379 pub retry_after: Option<Duration>,
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386pub struct LifecycleGithubObservation {
387 pub status: GithubRunnerObservation,
388 pub runner_id: Option<u64>,
389}
390
391impl LifecycleGithubObservation {
392 #[must_use]
393 pub const fn unreachable() -> Self {
394 Self {
395 status: GithubRunnerObservation::Unreachable,
396 runner_id: None,
397 }
398 }
399
400 #[must_use]
401 pub const fn not_registered() -> Self {
402 Self {
403 status: GithubRunnerObservation::NotRegistered,
404 runner_id: None,
405 }
406 }
407
408 #[must_use]
409 pub const fn registered(runner_id: u64, busy: bool) -> Self {
410 Self {
411 status: GithubRunnerObservation::Registered { busy },
412 runner_id: Some(runner_id),
413 }
414 }
415}
416
417#[async_trait]
420pub trait LifecycleGithub: fmt::Debug + Send + Sync {
421 async fn register(
422 &self,
423 target: &ScaleTarget,
424 request: &JitRunnerRequest,
425 cancel: &CancelToken,
426 ) -> Result<JitRegistration, JitRequestFailure>;
427
428 async fn observe(
429 &self,
430 target: &ScaleTarget,
431 attempt: AttemptId,
432 cancel: &CancelToken,
433 ) -> LifecycleGithubObservation;
434
435 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
442}
443
444#[async_trait]
445impl<T> LifecycleGithub for T
446where
447 T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
448{
449 async fn register(
450 &self,
451 target: &ScaleTarget,
452 request: &JitRunnerRequest,
453 cancel: &CancelToken,
454 ) -> Result<JitRegistration, JitRequestFailure> {
455 self.generate_jit_config(target, request, cancel)
456 .await
457 .map_err(|error| {
458 let reason = if matches!(&error, JitError::Forbidden { .. }) {
459 FailureReason::Other(
460 "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
461 .into(),
462 )
463 } else {
464 FailureReason::JitRequestFailed
465 };
466 JitRequestFailure {
467 terminal: error.is_terminal(),
468 reason,
469 retry_after: error
470 .rate_limited()
471 .map(|limit| limit.delay_from(self.now())),
472 }
473 })
474 }
475
476 async fn observe(
477 &self,
478 target: &ScaleTarget,
479 attempt: AttemptId,
480 cancel: &CancelToken,
481 ) -> LifecycleGithubObservation {
482 let expected_name = runner_name(attempt);
483 match self.list_runners(target, cancel).await {
484 Ok(inventory) => inventory
485 .runners()
486 .iter()
487 .find(|runner| runner.name == expected_name)
488 .map_or(LifecycleGithubObservation::not_registered(), |runner| {
489 LifecycleGithubObservation::registered(runner.id, runner.busy)
490 }),
491 Err(_) => LifecycleGithubObservation::unreachable(),
492 }
493 }
494
495 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
496 self.remove_runner(target, runner_id, cancel).await.is_ok()
497 }
498}
499
500#[async_trait]
502pub trait RuntimePackages: fmt::Debug + Send + Sync {
503 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason>;
504 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
505 fn prune_obsolete_guarded(
506 &self,
507 authority: PruneAuthority<'_>,
508 current: &RunnerVersion,
509 attempts: &[RunnerAttempt],
510 ) -> Result<(), FailureReason>;
511}
512
513pub struct PruneAuthority<'a> {
518 _guard: &'a AllocationGuard,
519}
520
521impl fmt::Debug for PruneAuthority<'_> {
522 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
523 f.write_str("PruneAuthority")
524 }
525}
526
527impl<'a> PruneAuthority<'a> {
528 fn from_launch_request(guard: &'a AllocationGuard) -> Self {
529 Self { _guard: guard }
530 }
531}
532
533#[derive(Debug)]
536pub struct CachedRuntimePackages {
537 cache: Arc<PackageCache>,
538 trusted: Arc<TrustedTrees>,
539}
540
541impl CachedRuntimePackages {
542 async fn blocking<T: Send + 'static>(
545 &self,
546 root: &Path,
547 work: impl FnOnce(&TrustedTrees, &Path) -> T + Send + 'static,
548 ) -> Result<T, FailureReason> {
549 let trusted = Arc::clone(&self.trusted);
550 let root = root.to_path_buf();
551 tokio::task::spawn_blocking(move || work(&trusted, &root))
552 .await
553 .map_err(|_| FailureReason::ProcessStartFailed)
554 }
555
556 #[must_use]
557 pub fn new(cache: Arc<PackageCache>) -> Self {
558 Self {
559 cache,
560 trusted: Arc::default(),
561 }
562 }
563}
564
565#[async_trait]
566impl RuntimePackages for CachedRuntimePackages {
567 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason> {
568 let (mut installed, downloaded) = self
569 .cache
570 .ensure_installed_reporting()
571 .await
572 .map_err(package_failure)?;
573 let fresh = if downloaded {
578 true
579 } else if self.blocking(installed.root(), TrustedTrees::holds).await? {
580 false
581 } else {
582 installed = self.cache.reinstall().await.map_err(package_failure)?;
583 true
584 };
585 if fresh {
586 self.blocking(installed.root(), TrustedTrees::record)
587 .await?
588 .map_err(|_| FailureReason::ProcessStartFailed)?;
589 }
590 let destination = attempt.runtime_path().to_path_buf();
593 let copied = self
594 .blocking(installed.root(), move |trusted, source| {
595 copy_package_tree(source, &destination, trusted)?;
596 if trusted.holds(source) {
599 Ok(())
600 } else {
601 Err(std::io::Error::other(
602 "the runner package changed while copied",
603 ))
604 }
605 })
606 .await?;
607 if copied.is_err() {
608 let _ = remove_materialized_package(attempt);
611 return Err(FailureReason::ProcessStartFailed);
612 }
613 if let Err(error) = self.cache.lease(attempt, installed.version()) {
614 let _ = remove_materialized_package(attempt);
618 return Err(package_failure(error));
619 }
620 Ok(installed.version().clone())
621 }
622
623 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
624 self.cache.release(attempt).map_err(package_failure)
625 }
626
627 fn prune_obsolete_guarded(
628 &self,
629 _authority: PruneAuthority<'_>,
630 current: &RunnerVersion,
631 attempts: &[RunnerAttempt],
632 ) -> Result<(), FailureReason> {
633 for installed in self.cache.installed().map_err(package_failure)? {
634 if installed.version() != current {
635 match self.cache.prune(installed.version(), attempts) {
636 Ok(()) | Err(PackageError::VersionInUse { .. }) => {}
637 Err(error) => return Err(package_failure(error)),
638 }
639 }
640 }
641 Ok(())
642 }
643}
644
645fn package_failure(error: PackageError) -> FailureReason {
646 error.failure_reason().unwrap_or(FailureReason::Other(
647 "runner package cache operation failed".into(),
648 ))
649}
650
651fn package_failure_is_terminal(reason: &FailureReason) -> bool {
652 matches!(
653 reason,
654 FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
655 )
656}
657
658const WORKSPACE_NAME_LEN: usize = 12;
681
682fn workspace_name(id: AttemptId) -> String {
684 let full = id.to_string();
685 full.chars()
686 .filter(|c| *c != '-')
687 .take(WORKSPACE_NAME_LEN)
688 .collect()
689}
690
691#[derive(Debug, Clone)]
698struct Placement {
699 runtime: PathBuf,
700 workspace: AttemptWorkspace,
701}
702
703fn root_failure(error: RunnerRootError) -> LifecycleError {
710 tracing::warn!(
727 error_kind = error.kind(),
728 "the runner root refused this launch, so no attempt was created; the host will \
729 retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
730 with the same path re-runs this check and prints the directory and the \
731 remediation in full"
732 );
733 LifecycleError::Failed(FailureReason::Other(error.to_string()))
734}
735
736fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
745 let held: BTreeSet<u16> = leases
746 .iter()
747 .filter_map(|attempt| attempt.workspace().slot_number())
748 .collect();
749 (1..=ceiling.get())
750 .find(|slot| !held.contains(slot))
751 .and_then(NonZeroU16::new)
752}
753
754fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
762 match fs::symlink_metadata(slot) {
763 Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
769 slot,
770 "is a symbolic link, junction or other reparse point, which could place runner \
771 files outside the configured root",
772 )),
773 Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
774 Ok(_) => Ok(()),
775 Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
776 .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
777 Err(source) => Err(slot_refusal(
778 slot,
779 format!("could not be inspected: {source}"),
780 )),
781 }
782}
783
784fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
796 let unreadable =
797 |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
798 let entries = fs::read_dir(slot).map_err(unreadable)?;
799 let mut refused: Vec<String> = Vec::new();
800 for entry in entries {
801 let entry = entry.map_err(unreadable)?;
802 let name = entry.file_name();
803 let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
804 slot_refusal(
805 slot,
806 format!("entry {name:?} could not be inspected: {source}"),
807 )
808 })?;
809 if is_retainable_work_folder(&name, &metadata) {
814 continue;
815 }
816 refused.push(name.to_string_lossy().into_owned());
817 }
818 if refused.is_empty() {
819 return Ok(());
820 }
821 refused.sort();
822 Err(slot_refusal(
823 slot,
824 format!(
825 "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
826 one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
827 entries listed, or let cleanup and recovery resolve them",
828 if refused.len() == 1 {
829 "an entry"
830 } else {
831 "entries"
832 },
833 refused.join(", ")
834 ),
835 ))
836}
837
838fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
839 LifecycleError::Failed(FailureReason::Other(format!(
840 "the persistent slot {} {detail}",
841 slot.display()
842 )))
843}
844
845fn is_work_folder(name: &OsStr) -> bool {
855 if cfg!(windows) {
856 name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
857 } else {
858 name == OsStr::new(DEFAULT_WORK_FOLDER)
859 }
860}
861
862fn is_link_like(metadata: &fs::Metadata) -> bool {
871 if metadata.file_type().is_symlink() {
872 return true;
873 }
874 #[cfg(windows)]
875 {
876 use std::os::windows::fs::MetadataExt;
877
878 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
879 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
880 }
881 #[cfg(not(windows))]
882 false
883}
884
885fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
893 is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
894}
895
896fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
903 match attempt.workspace() {
904 AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
905 AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
906 .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
907 }
908}
909
910fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
918 #[cfg(windows)]
919 {
920 remove_dir_all::remove_dir_all(path)
921 }
922 #[cfg(not(windows))]
923 {
924 fs::remove_dir_all(path)
925 }
926}
927
928#[derive(Debug, Clone, Copy, PartialEq, Eq)]
943enum SlotRefusal {
944 NotTheJournalledSlot,
946 PolicyRootDisagrees,
948 Containment,
950 SlotNotADirectory,
952 Enumeration,
954 WorkNotADirectory,
956 Deletion,
958 Residue,
960}
961
962impl SlotRefusal {
963 const fn class(self) -> &'static str {
965 match self {
966 Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
967 Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
968 Self::Containment => "slot_escapes_its_root",
969 Self::SlotNotADirectory => "slot_is_not_a_directory",
970 Self::Enumeration => "slot_could_not_be_enumerated",
971 Self::WorkNotADirectory => "retained_work_is_not_a_directory",
972 Self::Deletion => "slot_entry_could_not_be_removed",
973 Self::Residue => "slot_still_holds_runner_state",
974 }
975 }
976
977 const fn remediation(self) -> &'static str {
979 match self {
980 Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
981 "the attempt keeps its slot lease and nothing was removed; correct the \
982 repository's persistent workspace path, or remove the slot directory by hand \
983 once you have confirmed what is in it"
984 }
985 Self::SlotNotADirectory | Self::WorkNotADirectory => {
986 "the attempt keeps its slot lease and nothing was removed; a job replaced the \
987 slot or its `_work` with a link, so inspect it before deleting anything and \
988 treat the retained workspace as untrusted"
989 }
990 Self::Enumeration | Self::Deletion | Self::Residue => {
991 "the attempt keeps its slot lease and will be cleaned again on the next pass; \
992 release whatever is holding the files open, or remove the slot's contents by \
993 hand leaving only `_work`"
994 }
995 }
996 }
997}
998
999#[derive(Debug, Clone, PartialEq, Eq)]
1006struct SlotQuarantine {
1007 refusal: SlotRefusal,
1008 detail: String,
1009}
1010
1011impl SlotQuarantine {
1012 fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
1013 Self {
1014 refusal,
1015 detail: detail.into(),
1016 }
1017 }
1018}
1019
1020impl fmt::Display for SlotQuarantine {
1021 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1022 write!(f, "{}; {}", self.detail, self.refusal.remediation())
1023 }
1024}
1025
1026fn verify_journalled_slot(
1040 runtime: &Path,
1041 slot: NonZeroU16,
1042 configured: Option<&LocalAbsolutePath>,
1043) -> Result<(), SlotQuarantine> {
1044 let mislaid = || {
1045 SlotQuarantine::new(
1046 SlotRefusal::NotTheJournalledSlot,
1047 format!(
1048 "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
1049 runtime.display()
1050 ),
1051 )
1052 };
1053 let local = |path: &Path| {
1054 path.to_str()
1055 .and_then(|raw| LocalAbsolutePath::new(raw).ok())
1056 .ok_or_else(mislaid)
1057 };
1058
1059 let runtime_path = local(runtime)?;
1060 let root = local(runtime.parent().ok_or_else(mislaid)?)?;
1061 let name = AttemptWorkspace::persistent_slot(slot)
1068 .slot_directory_name()
1069 .expect("a persistent workspace names its slot directory");
1070 let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
1071 if derived != runtime_path {
1072 return Err(mislaid());
1073 }
1074 if let Some(configured) = configured
1075 && configured != &root
1076 {
1077 return Err(SlotQuarantine::new(
1078 SlotRefusal::PolicyRootDisagrees,
1079 format!(
1080 "the journalled slot {} is not under the repository's configured persistent root \
1081 {}",
1082 runtime.display(),
1083 configured.as_str()
1084 ),
1085 ));
1086 }
1087 runner_root::verify_containment(&root, &derived).map_err(|source| {
1090 SlotQuarantine::new(
1091 SlotRefusal::Containment,
1092 format!("the journalled slot is not inside the root it was allocated from: {source}"),
1093 )
1094 })
1095}
1096
1097fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
1108 match fs::symlink_metadata(slot) {
1109 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1110 Err(source) => Err(SlotQuarantine::new(
1111 SlotRefusal::SlotNotADirectory,
1112 format!(
1113 "the slot {} could not be inspected: {:?}",
1114 slot.display(),
1115 source.kind()
1116 ),
1117 )),
1118 Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
1119 SlotRefusal::SlotNotADirectory,
1120 format!(
1121 "the slot {} is a link or a file rather than a real directory",
1122 slot.display()
1123 ),
1124 )),
1125 Ok(_) => Ok(true),
1126 }
1127}
1128
1129fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
1144 let unreadable = |source: std::io::Error| {
1145 SlotQuarantine::new(
1146 SlotRefusal::Enumeration,
1147 format!(
1148 "the entries of {} could not be listed: {:?}",
1149 slot.display(),
1150 source.kind()
1151 ),
1152 )
1153 };
1154 for entry in fs::read_dir(slot).map_err(unreadable)? {
1155 let name = entry.map_err(unreadable)?.file_name();
1156 let path = slot.join(&name);
1157 let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
1160 continue;
1161 };
1162 if is_work_folder(&name) {
1163 if is_retainable_work_folder(&name, &metadata) {
1164 continue;
1165 }
1166 return Err(SlotQuarantine::new(
1167 SlotRefusal::WorkNotADirectory,
1168 format!(
1169 "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
1170 real directory",
1171 slot.display()
1172 ),
1173 ));
1174 }
1175 remove_slot_entry(&path, &metadata).map_err(|source| {
1176 SlotQuarantine::new(
1177 SlotRefusal::Deletion,
1178 format!(
1179 "an entry of {} could not be removed: {:?}",
1180 slot.display(),
1181 source.kind()
1182 ),
1183 )
1184 })?;
1185 }
1186 Ok(())
1187}
1188
1189fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1195 match fs::symlink_metadata(path) {
1196 Ok(metadata) => Ok(Some(metadata)),
1197 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1198 Err(source) => Err(source),
1199 }
1200}
1201
1202fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1207 let removed = if is_link_like(metadata) {
1208 fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1211 } else if metadata.is_dir() {
1212 remove_runtime_tree(path)
1213 } else {
1214 fs::remove_file(path)
1215 };
1216 match removed {
1217 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1218 other => other,
1219 }
1220}
1221
1222fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1239 let unreadable = |source: std::io::Error| {
1240 SlotQuarantine::new(
1241 SlotRefusal::Enumeration,
1242 format!(
1243 "the entries of {} could not be listed to verify the scrub: {:?}",
1244 slot.display(),
1245 source.kind()
1246 ),
1247 )
1248 };
1249 let mut residue = 0_usize;
1250 let mut named: Vec<String> = Vec::new();
1251 for entry in fs::read_dir(slot).map_err(unreadable)? {
1252 let name = entry.map_err(unreadable)?.file_name();
1253 let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1257 continue;
1258 };
1259 if is_retainable_work_folder(&name, &metadata) {
1260 continue;
1261 }
1262 residue = residue.saturating_add(1);
1263 if name
1264 .to_string_lossy()
1265 .starts_with(RestrictiveHandoff::NAME_PREFIX)
1266 {
1267 named.push("an encoded JIT handoff".to_owned());
1268 }
1269 }
1270 named.extend(
1271 SENSITIVE_SLOT_ENTRIES
1272 .iter()
1273 .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1274 .map(|entry| format!("`{entry}`")),
1275 );
1276 if residue == 0 && named.is_empty() {
1277 return Ok(());
1278 }
1279 named.sort_unstable();
1280 named.dedup();
1281 Err(SlotQuarantine::new(
1282 SlotRefusal::Residue,
1283 residue_detail(slot, residue, &named),
1284 ))
1285}
1286
1287fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1297 if residue == 0 {
1298 format!(
1302 "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1303 cleanup",
1304 slot.display(),
1305 named.join(", ")
1306 )
1307 } else {
1308 format!(
1309 "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1310 if residue == 1 { "y" } else { "ies" },
1311 slot.display(),
1312 if named.is_empty() {
1313 String::new()
1314 } else {
1315 format!(", including {}", named.join(", "))
1316 }
1317 )
1318 }
1319}
1320
1321fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1322 match outcome {
1323 AttemptOutcome::Failed {
1324 reason: FailureReason::JitExpired,
1325 } => Some("jit_expired_replacement"),
1326 AttemptOutcome::Failed {
1327 reason: FailureReason::ProcessExitedUnexpectedly,
1328 } => Some("exit_before_acceptance_replacement"),
1329 _ => None,
1330 }
1331}
1332
1333fn copy_package_tree(
1354 source: &Path,
1355 destination: &Path,
1356 seeds: &TrustedTrees,
1357) -> std::io::Result<()> {
1358 refuse_work_folder(source)?;
1359
1360 #[cfg(unix)]
1361 {
1362 let origin = package_seed(source, destination, seeds)
1366 .ok()
1367 .filter(|seed| refuse_work_folder(seed).is_ok())
1368 .unwrap_or_else(|| source.to_path_buf());
1369 clone_tree(&origin, destination)?;
1370 if origin != source && !seeds.holds(&origin) {
1373 return Err(std::io::Error::other(
1374 "the package seed changed while cloned",
1375 ));
1376 }
1377 Ok(())
1378 }
1379 #[cfg(not(unix))]
1380 {
1381 let _ = seeds;
1382 copy_package_entries(source, destination, true)
1383 }
1384}
1385
1386fn refuse_work_folder(source: &Path) -> std::io::Result<()> {
1387 if source.join(DEFAULT_WORK_FOLDER).exists() {
1388 return Err(std::io::Error::new(
1389 std::io::ErrorKind::InvalidData,
1390 "a cached runner package contains a _work folder, which means it was used \
1391 to run a job before it was archived; the cache must only contain clean \
1392 extracts to prevent data leakage",
1393 ));
1394 }
1395 Ok(())
1396}
1397
1398#[cfg(unix)]
1400const PACKAGE_SEED_DIR: &str = ".runner-package";
1401
1402#[cfg(unix)]
1421fn package_seed(
1422 source: &Path,
1423 destination: &Path,
1424 seeds: &TrustedTrees,
1425) -> std::io::Result<PathBuf> {
1426 use std::os::unix::fs::MetadataExt as _;
1427
1428 if !cfg!(target_os = "macos") {
1429 return Ok(source.to_path_buf());
1430 }
1431 let root = destination
1432 .parent()
1433 .ok_or_else(|| std::io::Error::other("a runtime directory has no parent"))?;
1434 if fs::metadata(source)?.dev() == fs::metadata(root)?.dev() {
1435 return Ok(source.to_path_buf());
1436 }
1437 refresh_package_seed(source, &root.join(PACKAGE_SEED_DIR), seeds)
1438}
1439
1440#[derive(Debug, Default)]
1455pub(crate) struct TrustedTrees {
1456 #[cfg_attr(not(unix), allow(dead_code))]
1457 prints: Mutex<std::collections::HashMap<PathBuf, [u8; 32]>>,
1458}
1459
1460impl TrustedTrees {
1461 fn holds(&self, root: &Path) -> bool {
1463 #[cfg(unix)]
1464 {
1465 let recorded = self
1466 .prints
1467 .lock()
1468 .unwrap_or_else(std::sync::PoisonError::into_inner)
1469 .get(root)
1470 .copied();
1471 recorded.is_some_and(|recorded| {
1472 tree_fingerprint(root).is_ok_and(|current| current == recorded)
1473 })
1474 }
1475 #[cfg(not(unix))]
1476 {
1477 let _ = root;
1478 true
1479 }
1480 }
1481
1482 fn record(&self, root: &Path) -> std::io::Result<()> {
1484 #[cfg(unix)]
1485 {
1486 let print = tree_fingerprint(root)?;
1487 self.prints
1488 .lock()
1489 .unwrap_or_else(std::sync::PoisonError::into_inner)
1490 .insert(root.to_path_buf(), print);
1491 }
1492 #[cfg(not(unix))]
1493 let _ = root;
1494 Ok(())
1495 }
1496}
1497
1498#[cfg(unix)]
1501fn tree_fingerprint(root: &Path) -> std::io::Result<[u8; 32]> {
1502 use sha2::{Digest as _, Sha256};
1503 use std::os::unix::fs::MetadataExt as _;
1504
1505 fn walk(root: &Path, path: &Path, hasher: &mut Sha256) -> std::io::Result<()> {
1506 let metadata = fs::symlink_metadata(path)?;
1507 let relative = path.strip_prefix(root).unwrap_or(path);
1508 hasher.update(relative.as_os_str().as_encoded_bytes());
1509 hasher.update([0]);
1510 for field in [
1511 metadata.ino(),
1512 metadata.size(),
1513 u64::from(metadata.mode()),
1514 u64::from(metadata.uid()),
1515 u64::from(metadata.gid()),
1516 ] {
1517 hasher.update(field.to_le_bytes());
1518 }
1519 hasher.update(metadata.ctime().to_le_bytes());
1520 hasher.update(metadata.ctime_nsec().to_le_bytes());
1521 if metadata.is_dir() {
1522 let mut children = fs::read_dir(path)?
1523 .map(|entry| entry.map(|entry| entry.path()))
1524 .collect::<std::io::Result<Vec<_>>>()?;
1525 children.sort();
1526 for child in children {
1527 walk(root, &child, hasher)?;
1528 }
1529 }
1530 Ok(())
1531 }
1532
1533 let mut hasher = Sha256::new();
1534 walk(root, root, &mut hasher)?;
1535 Ok(hasher.finalize().into())
1536}
1537
1538#[cfg(unix)]
1541fn refresh_package_seed(
1542 source: &Path,
1543 seeds: &Path,
1544 trusted: &TrustedTrees,
1545) -> std::io::Result<PathBuf> {
1546 use crate::package::MANIFEST_FILE;
1547
1548 let version = source
1549 .file_name()
1550 .ok_or_else(|| std::io::Error::other("a cache entry has no name"))?;
1551 let seed = seeds.join(version);
1552 let manifest = fs::read(source.join(MANIFEST_FILE))?;
1553 if fs::read(seed.join(MANIFEST_FILE)).is_ok_and(|held| held == manifest) && trusted.holds(&seed)
1554 {
1555 return Ok(seed);
1556 }
1557
1558 match remove_runtime_tree(seeds) {
1560 Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
1561 _ => {}
1562 }
1563 fs::create_dir_all(seeds)?;
1564 let staging = seeds.join(format!(".staging-{}", uuid::Uuid::new_v4()));
1565 fs::create_dir(&staging)?;
1566 if let Err(error) = clone_tree(source, &staging).and_then(|()| fs::rename(&staging, &seed)) {
1568 let _ = remove_runtime_tree(&staging);
1569 return Err(error);
1570 }
1571 trusted.record(&seed)?;
1573 Ok(seed)
1574}
1575
1576#[cfg(unix)]
1581fn clone_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
1582 #[cfg(target_os = "macos")]
1583 const CLONE: &[&str] = &["-c"];
1584 #[cfg(target_os = "linux")]
1585 const CLONE: &[&str] = &["--reflink=auto"];
1586 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1587 const CLONE: &[&str] = &[];
1588
1589 let from = format!("{}/.", source.display());
1590 let cp = |extra: &[&str]| {
1591 std::process::Command::new("cp")
1592 .arg("-a")
1593 .args(extra)
1594 .arg(&from)
1595 .arg(destination)
1596 .status()
1597 };
1598 if cp(CLONE)?.success() || cp(&[])?.success() {
1599 Ok(())
1600 } else {
1601 Err(std::io::Error::other("cp failed"))
1602 }
1603}
1604
1605#[cfg(not(unix))]
1606fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
1607 fs::create_dir_all(destination)?;
1608 for entry in fs::read_dir(source)? {
1609 let entry = entry?;
1610 if top_level && is_work_folder(&entry.file_name()) {
1611 return Err(std::io::Error::new(
1612 std::io::ErrorKind::InvalidData,
1613 format!(
1614 "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
1615 it would overwrite the job workspace a persistent slot retains"
1616 ),
1617 ));
1618 }
1619 let target = destination.join(entry.file_name());
1620 if entry.file_type()?.is_dir() {
1621 copy_package_entries(&entry.path(), &target, false)?;
1622 } else {
1623 fs::copy(entry.path(), target)?;
1624 }
1625 }
1626 Ok(())
1627}
1628
1629#[derive(Debug, Clone, PartialEq, Eq)]
1632pub struct ProcessStartFailure {
1633 pub reason: FailureReason,
1634 pub retryable: bool,
1637 pub live_pid: Option<u32>,
1640}
1641
1642impl ProcessStartFailure {
1643 fn before_spawn(reason: FailureReason) -> Self {
1644 Self {
1645 reason,
1646 retryable: true,
1647 live_pid: None,
1648 }
1649 }
1650
1651 fn after_spawn_stopped() -> Self {
1652 Self {
1653 reason: FailureReason::ProcessStartFailed,
1654 retryable: false,
1655 live_pid: None,
1656 }
1657 }
1658
1659 fn after_spawn_live(pid: u32) -> Self {
1660 Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
1661 }
1662
1663 fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
1664 Self {
1665 reason,
1666 retryable: false,
1667 live_pid: Some(pid),
1668 }
1669 }
1670}
1671
1672pub trait ProcessSupervisor: fmt::Debug + Send + Sync {
1673 fn spawn(
1674 &self,
1675 attempt: &RunnerAttempt,
1676 config: &EncodedJitConfig,
1677 ) -> Result<u32, ProcessStartFailure>;
1678 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
1679 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
1682 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
1685 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1686 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
1687 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1688}
1689
1690#[derive(Debug, Default)]
1693pub struct NativeProcesses {
1694 children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
1695 successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
1696 #[cfg(test)]
1697 post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
1698 #[cfg(test)]
1699 post_spawn_reaps: std::sync::atomic::AtomicUsize,
1700 #[cfg(test)]
1701 post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
1702 #[cfg(test)]
1703 use_long_lived_test_listener: std::sync::atomic::AtomicBool,
1704}
1705
1706#[cfg(test)]
1707#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1708enum PostSpawnBoundary {
1709 HandoffDelete,
1710 IdentitySerialize,
1711 IdentityWrite,
1712 ChildMapInsert,
1713}
1714
1715impl NativeProcesses {
1716 #[must_use]
1717 pub fn new() -> Self {
1718 Self::default()
1719 }
1720
1721 #[cfg(test)]
1722 fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
1723 self.post_spawn_faults.lock().unwrap().push_back(boundary);
1724 }
1725
1726 #[cfg(test)]
1727 fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
1728 let mut faults = self.post_spawn_faults.lock().unwrap();
1729 if faults.front() == Some(&boundary) {
1730 faults.pop_front();
1731 true
1732 } else {
1733 false
1734 }
1735 }
1736
1737 #[cfg(test)]
1738 fn fail_post_spawn_stops(&self, count: usize) {
1739 self.post_spawn_stop_failures
1740 .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
1741 }
1742
1743 #[cfg(test)]
1744 fn fail_next_post_spawn_stop(&self) {
1745 self.fail_post_spawn_stops(1);
1746 }
1747
1748 #[cfg(test)]
1749 fn use_long_lived_test_listener(&self) {
1750 self.use_long_lived_test_listener
1751 .store(true, std::sync::atomic::Ordering::SeqCst);
1752 }
1753
1754 fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
1755 #[cfg(test)]
1756 if self
1757 .post_spawn_stop_failures
1758 .fetch_update(
1759 std::sync::atomic::Ordering::SeqCst,
1760 std::sync::atomic::Ordering::SeqCst,
1761 |left| if left > 0 { Some(left - 1) } else { None },
1762 )
1763 .is_ok()
1764 {
1765 return Err(FailureReason::Other("injected runner stop failure".into()));
1766 }
1767 child
1768 .stop(Duration::from_secs(1))
1769 .map(|_| ())
1770 .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
1771 }
1772
1773 fn abort_spawned_child(
1774 &self,
1775 mut child: ChildProcess,
1776 attempt: &RunnerAttempt,
1777 remove_identity: bool,
1778 ) -> ProcessStartFailure {
1779 let mut reaped = self.stop_spawned_child(&mut child).is_ok();
1780 if reaped {
1781 if remove_identity {
1782 Self::remove_identity_files(attempt);
1783 }
1784 } else {
1785 let identity_durable =
1788 serde_json::to_vec(child.identity())
1789 .ok()
1790 .is_some_and(|identity| {
1791 self.persist_identity(attempt, &identity).is_ok()
1792 || self.persist_fallback_identity(attempt, &identity).is_ok()
1793 });
1794 if !identity_durable {
1795 for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
1800 if self.stop_spawned_child(&mut child).is_ok() {
1801 reaped = true;
1802 break;
1803 }
1804 }
1805 if !reaped {
1806 let pid = child.pid();
1811 let marker = write_durable_file(
1812 &Self::unresolved_process_path(attempt),
1813 pid.to_string().as_bytes(),
1814 );
1815 self.children
1816 .lock()
1817 .unwrap_or_else(std::sync::PoisonError::into_inner)
1818 .insert(attempt.id, child);
1819 let reason = if marker.is_ok() {
1820 FailureReason::Other(
1821 "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
1822 .into(),
1823 )
1824 } else {
1825 FailureReason::Other(
1826 "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
1827 .into(),
1828 )
1829 };
1830 return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
1831 }
1832 } else {
1833 let pid = child.pid();
1834 self.children
1835 .lock()
1836 .unwrap_or_else(std::sync::PoisonError::into_inner)
1837 .insert(attempt.id, child);
1838 return ProcessStartFailure::after_spawn_live(pid);
1839 }
1840 }
1841 #[cfg(test)]
1842 if reaped {
1843 self.post_spawn_reaps
1844 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1845 }
1846 #[cfg(not(test))]
1847 let _ = reaped;
1848 ProcessStartFailure::after_spawn_stopped()
1849 }
1850
1851 fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
1852 attempt.runtime_path().join(IDENTITY_FILE)
1853 }
1854
1855 fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
1856 attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
1857 }
1858
1859 fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
1860 attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
1861 }
1862
1863 fn remove_identity_files(attempt: &RunnerAttempt) {
1864 let _ = fs::remove_file(Self::identity_path(attempt));
1865 let _ = fs::remove_file(Self::fallback_identity_path(attempt));
1866 let _ = fs::remove_file(Self::unresolved_process_path(attempt));
1867 }
1868
1869 fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
1870 self.persist_identity_at(&Self::identity_path(attempt), bytes)
1871 }
1872
1873 fn persist_fallback_identity(
1874 &self,
1875 attempt: &RunnerAttempt,
1876 bytes: &[u8],
1877 ) -> std::io::Result<()> {
1878 self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
1879 }
1880
1881 fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1882 #[cfg(test)]
1883 if self.faults_at(PostSpawnBoundary::IdentityWrite) {
1884 return Err(std::io::Error::other("injected identity write failure"));
1885 }
1886 write_durable_file(path, bytes)
1887 }
1888
1889 fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
1890 attempt.runtime_path().join(TERMINATE_INTENT_FILE)
1891 }
1892
1893 fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
1894 match Self::read_identity_at(&Self::identity_path(attempt))? {
1895 Some(identity) => Ok(Some(identity)),
1896 None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
1897 }
1898 }
1899
1900 fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
1901 match fs::read(path) {
1902 Ok(bytes) => serde_json::from_slice(&bytes)
1903 .map(Some)
1904 .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
1905 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1906 Err(_) => Err(FailureReason::Other(
1907 "process identity journal could not be read".into(),
1908 )),
1909 }
1910 }
1911}
1912
1913impl ProcessSupervisor for NativeProcesses {
1914 fn spawn(
1915 &self,
1916 attempt: &RunnerAttempt,
1917 config: &EncodedJitConfig,
1918 ) -> Result<u32, ProcessStartFailure> {
1919 let handoff = RestrictiveHandoff::create(
1920 attempt.runtime_path(),
1921 SecretString::from(config.expose().to_owned()),
1922 )
1923 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1924 #[cfg(windows)]
1925 let program = attempt
1926 .runtime_path()
1927 .join("bin")
1928 .join("Runner.Listener.exe");
1929 #[cfg(not(windows))]
1930 let program = attempt.runtime_path().join("bin").join("Runner.Listener");
1931 if !program.is_file() {
1934 return Err(ProcessStartFailure::before_spawn(
1935 FailureReason::ProcessStartFailed,
1936 ));
1937 }
1938 #[cfg(test)]
1939 let spec = if self
1940 .use_long_lived_test_listener
1941 .load(std::sync::atomic::Ordering::SeqCst)
1942 {
1943 SpawnSpec::new(program)
1944 .args([
1945 "--ignored",
1946 "--exact",
1947 "lifecycle::tests::long_lived_native_listener_helper",
1948 "--nocapture",
1949 ])
1950 .env(
1951 "RUNNER_MANAGER_TEST_LISTENER_READY",
1952 attempt.runtime_path().join(TEST_LISTENER_READY),
1953 )
1954 .working_dir(attempt.runtime_path())
1955 } else {
1956 runner_listener_spec(program, attempt.runtime_path())
1957 };
1958 #[cfg(not(test))]
1959 let spec = runner_listener_spec(program, attempt.runtime_path());
1960 let child = spec
1961 .spawn_runner_with_handoff(&handoff)
1962 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1963 #[cfg(test)]
1965 if self.faults_at(PostSpawnBoundary::HandoffDelete) {
1966 drop(handoff);
1967 return Err(self.abort_spawned_child(child, attempt, false));
1968 }
1969 if handoff.delete().is_err() {
1970 return Err(self.abort_spawned_child(child, attempt, false));
1971 }
1972 #[cfg(test)]
1973 if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
1974 return Err(self.abort_spawned_child(child, attempt, false));
1975 }
1976 let identity = match serde_json::to_vec(child.identity()) {
1977 Ok(identity) => identity,
1978 Err(_) => {
1979 return Err(self.abort_spawned_child(child, attempt, false));
1980 }
1981 };
1982 if self.persist_identity(attempt, &identity).is_err() {
1983 return Err(self.abort_spawned_child(child, attempt, true));
1984 }
1985 let pid = child.pid();
1986 #[cfg(test)]
1987 if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
1988 return Err(self.abort_spawned_child(child, attempt, true));
1989 }
1990 let mut children = self
1991 .children
1992 .lock()
1993 .unwrap_or_else(std::sync::PoisonError::into_inner);
1994 children.insert(attempt.id, child);
1995 Ok(pid)
1996 }
1997
1998 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
1999 let mut children = self
2000 .children
2001 .lock()
2002 .unwrap_or_else(std::sync::PoisonError::into_inner);
2003 if let Some(child) = children.get_mut(&attempt.id) {
2004 return match child
2005 .try_exit_status()
2006 .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
2007 {
2008 None => Ok(true),
2009 Some(status) => {
2010 if let Ok(mut exits) = self.successful_exits.lock() {
2011 exits.insert(attempt.id, status.success());
2012 }
2013 Ok(false)
2014 }
2015 };
2016 }
2017 let Some(identity) = Self::read_identity(attempt)? else {
2018 if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
2019 return Err(FailureReason::Other(
2020 "runner process identity is missing; refusing recovery until the process is resolved"
2021 .into(),
2022 ));
2023 }
2024 return Ok(false);
2025 };
2026 match identity.recheck() {
2027 Ok(Adoption::Live) => Ok(true),
2028 Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
2029 Err(_) => Ok(false),
2030 }
2031 }
2032
2033 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2034 Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
2035 }
2036
2037 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2038 self.successful_exits
2039 .lock()
2040 .ok()
2041 .and_then(|exits| exits.get(&attempt.id).copied())
2042 .unwrap_or(false)
2043 }
2044
2045 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2046 let path = Self::intent_path(attempt);
2047 write_durable_file(&path, b"registration-timeout\n")
2048 .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
2049 }
2050
2051 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2052 Self::intent_path(attempt).is_file()
2053 }
2054
2055 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2056 let mut children = self
2057 .children
2058 .lock()
2059 .unwrap_or_else(std::sync::PoisonError::into_inner);
2060 if let Some(child) = children.get_mut(&attempt.id) {
2061 child
2062 .stop(Duration::from_secs(10))
2063 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
2064 return Ok(());
2065 }
2066 let Some(identity) = Self::read_identity(attempt)? else {
2067 return Ok(());
2068 };
2069 match identity
2070 .terminate(Duration::from_secs(10))
2071 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
2072 {
2073 Termination::Terminated | Termination::AlreadyGone => Ok(()),
2074 Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
2075 "runner PID was recycled; refusing to signal it".into(),
2076 )),
2077 }
2078 }
2079}
2080
2081pub struct LifecyclePorts {
2082 pub store: Arc<dyn Store>,
2083 pub github: Arc<dyn LifecycleGithub>,
2084 pub packages: Arc<dyn RuntimePackages>,
2085 pub processes: Arc<dyn ProcessSupervisor>,
2086 pub clock: Arc<dyn Clock>,
2087 pub demand: Arc<dyn DemandPersistence>,
2088 pub delay: Arc<dyn RetryDelay>,
2089 pub events: Arc<dyn AttemptEventSink>,
2090 pub reconcile_events: Arc<dyn EventSink>,
2091}
2092
2093impl fmt::Debug for LifecyclePorts {
2094 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2095 f.debug_struct("LifecyclePorts")
2096 .field("store", &self.store)
2097 .field("github", &self.github)
2098 .field("packages", &self.packages)
2099 .field("processes", &self.processes)
2100 .finish_non_exhaustive()
2101 }
2102}
2103
2104#[derive(Debug, thiserror::Error)]
2105pub enum LifecycleError {
2106 #[error("attempt journal operation failed")]
2107 Journal,
2108 #[error("attempt {0} is not in the journal")]
2109 Missing(AttemptId),
2110 #[error("attempt lifecycle transition was refused")]
2111 Transition,
2112 #[error("startup recovery has not completed")]
2113 RecoveryIncomplete,
2114 #[error("the persistent slot was not cleaned: {detail}")]
2124 SlotQuarantined {
2125 class: &'static str,
2127 detail: String,
2128 },
2129 #[error("attempt workspace could not be removed")]
2133 WorkspaceCleanupDeferred,
2134 #[error("runner lifecycle failed: {0}")]
2135 Failed(FailureReason),
2136}
2137
2138impl LifecycleError {
2139 fn reason(&self) -> FailureReason {
2140 match self {
2141 Self::Failed(reason) => reason.clone(),
2142 Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
2143 Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
2144 Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
2145 Self::Transition => FailureReason::Other("attempt transition was refused".into()),
2146 Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
2149 Self::WorkspaceCleanupDeferred => FailureReason::Other(self.to_string()),
2150 }
2151 }
2152}
2153
2154#[derive(Debug)]
2156pub struct LifecycleLauncher {
2157 host_id: HostId,
2158 app_paths: runner_manager_platform::paths::AppPaths,
2159 diagnostics_root: PathBuf,
2160 runner_group_id: u64,
2161 timeouts: RecoveryTimeouts,
2162 retry: RetryPolicy,
2163 cancel: CancelToken,
2164 ports: LifecyclePorts,
2165 recovery_complete: Mutex<bool>,
2166 versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
2167 pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
2168}
2169
2170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2171enum ReconcileProgress {
2172 Reconciled,
2173 Deferred,
2174 Replacement {
2175 attempt: AttemptId,
2176 operation: &'static str,
2177 },
2178}
2179
2180impl LifecycleLauncher {
2181 #[must_use]
2182 pub fn new(
2183 host_id: HostId,
2184 app_paths: runner_manager_platform::paths::AppPaths,
2185 diagnostics_root: impl Into<PathBuf>,
2186 runner_group_id: u64,
2187 timeouts: RecoveryTimeouts,
2188 retry: RetryPolicy,
2189 ports: LifecyclePorts,
2190 ) -> Self {
2191 Self {
2192 host_id,
2193 app_paths,
2194 diagnostics_root: diagnostics_root.into(),
2195 runner_group_id,
2196 timeouts,
2197 retry,
2198 cancel: CancelToken::new(),
2199 ports,
2200 recovery_complete: Mutex::new(false),
2201 versions: Mutex::new(BTreeMap::new()),
2202 pending_replacements: Mutex::new(BTreeMap::new()),
2203 }
2204 }
2205
2206 pub async fn recover_startup(
2210 &self,
2211 policies: &[ScalePolicy],
2212 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
2213 let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
2214 let attempts = self
2215 .ports
2216 .store
2217 .attempts()
2218 .map_err(|_| LifecycleError::Journal)?;
2219 let mut unresolved = false;
2220 for attempt in attempts {
2221 let Some(policy) = by_id.get(&attempt.policy_id) else {
2222 if !attempt.is_terminal() && attempt.state() != AttemptState::Cleaned {
2223 unresolved = true;
2224 }
2225 continue;
2226 };
2227 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
2228 match self.reconcile_one(policy, attempt).await? {
2229 ReconcileProgress::Deferred => unresolved = true,
2230 ReconcileProgress::Replacement { attempt, operation } => {
2231 self.pending_replacements
2232 .lock()
2233 .map_err(|_| LifecycleError::Journal)?
2234 .insert(
2235 attempt,
2236 ReplacementIntent {
2237 policy: policy.id,
2238 previous_attempt: attempt,
2239 operation,
2240 },
2241 );
2242 }
2243 ReconcileProgress::Reconciled => {}
2244 }
2245 }
2246 if unresolved {
2247 return Err(LifecycleError::RecoveryIncomplete);
2248 }
2249 *self
2250 .recovery_complete
2251 .lock()
2252 .map_err(|_| LifecycleError::Journal)? = true;
2253 Ok(self
2254 .pending_replacements
2255 .lock()
2256 .map_err(|_| LifecycleError::Journal)?
2257 .values()
2258 .copied()
2259 .collect())
2260 }
2261
2262 pub async fn supervise(
2264 &self,
2265 policy: &ScalePolicy,
2266 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
2267 let mut replacements = Vec::new();
2268 self.pending_replacements
2269 .lock()
2270 .map_err(|_| LifecycleError::Journal)?
2271 .retain(|_, intent| {
2272 if intent.policy == policy.id {
2273 replacements.push(*intent);
2274 false
2275 } else {
2276 true
2277 }
2278 });
2279 let attempts = self
2280 .ports
2281 .store
2282 .attempts_for_policy(policy.id)
2283 .map_err(|_| LifecycleError::Journal)?;
2284 for attempt in attempts {
2285 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
2286 if let ReconcileProgress::Replacement { attempt, operation } =
2287 self.reconcile_one(policy, attempt).await?
2288 {
2289 replacements.push(ReplacementIntent {
2290 policy: policy.id,
2291 previous_attempt: attempt,
2292 operation,
2293 });
2294 }
2295 }
2296 Ok(replacements)
2297 }
2298
2299 async fn reconcile_one(
2300 &self,
2301 policy: &ScalePolicy,
2302 mut attempt: RunnerAttempt,
2303 ) -> Result<ReconcileProgress, LifecycleError> {
2304 if attempt.state() == AttemptState::Cleaned {
2305 if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
2314 && attempt.runtime_path().exists()
2315 {
2316 match self.scrub_workspace(&attempt) {
2322 Ok(()) => {}
2323 Err(LifecycleError::WorkspaceCleanupDeferred) => {
2324 self.ports
2325 .reconcile_events
2326 .emit(LifecycleEvent::AttemptCleanFailed {
2327 policy: attempt.policy_id,
2328 attempt: attempt.id,
2329 reason: "late_ephemeral_workspace_could_not_be_removed",
2330 });
2331 tracing::warn!(
2332 policy_id = %attempt.policy_id,
2333 attempt_id = %attempt.id,
2334 reason = "late_ephemeral_workspace_could_not_be_removed",
2335 "a cleaned ephemeral attempt left late workspace residue; cleanup will retry without blocking the daemon"
2336 );
2337 }
2338 Err(error) => return Err(error),
2339 }
2340 }
2341 return Ok(ReconcileProgress::Reconciled);
2342 }
2343 if attempt.is_terminal() {
2344 self.clean_or_quarantine(&mut attempt)?;
2345 return Ok(ReconcileProgress::Reconciled);
2346 }
2347 let process_alive = self
2348 .ports
2349 .processes
2350 .is_alive(&attempt)
2351 .map_err(LifecycleError::Failed)?;
2352 let github = self
2353 .ports
2354 .github
2355 .observe(&policy.target, attempt.id, &self.cancel)
2356 .await;
2357
2358 if let Some(runner_id) = github.runner_id
2363 && read_runner_id(attempt.runtime_path()).is_none()
2364 {
2365 write_runner_id(attempt.runtime_path(), runner_id)?;
2366 self.ports
2367 .events
2368 .emit(AttemptEvent::RemoteIdentityRecovered {
2369 attempt: attempt.id,
2370 runner_id,
2371 });
2372 }
2373
2374 if attempt.state() == AttemptState::JitReceived
2379 && process_alive
2380 && let Some(pid) = self
2381 .ports
2382 .processes
2383 .recovered_pid(&attempt)
2384 .map_err(LifecycleError::Failed)?
2385 {
2386 attempt
2387 .started(pid, self.ports.clock.now())
2388 .map_err(|_| LifecycleError::Transition)?;
2389 self.record(&attempt)?;
2390 }
2391
2392 if attempt.state() == AttemptState::Busy
2397 && !process_alive
2398 && github.status == GithubRunnerObservation::NotRegistered
2399 && self.ports.processes.completed_successfully(&attempt)
2400 {
2401 self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
2402 self.clean_or_quarantine(&mut attempt)?;
2403 return Ok(ReconcileProgress::Reconciled);
2404 }
2405
2406 if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
2409 self.deregister_runner(policy, &attempt).await;
2410 self.conclude(
2411 &mut attempt,
2412 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
2413 )?;
2414 self.clean_or_quarantine(&mut attempt)?;
2415 return Ok(ReconcileProgress::Replacement {
2416 attempt: attempt.id,
2417 operation: "registration_timeout_replacement",
2418 });
2419 }
2420
2421 if matches!(
2427 attempt.state(),
2428 AttemptState::Allocated | AttemptState::JitReceived
2429 ) && !process_alive
2430 && matches!(github.status, GithubRunnerObservation::Registered { .. })
2431 {
2432 if attempt.state() == AttemptState::Allocated {
2433 attempt
2434 .jit_received(self.ports.clock.now())
2435 .map_err(|_| LifecycleError::Transition)?;
2436 self.record(&attempt)?;
2437 }
2438 self.deregister_runner(policy, &attempt).await;
2439 self.conclude(
2440 &mut attempt,
2441 AttemptOutcome::failed(FailureReason::JitExpired),
2442 )?;
2443 self.clean_or_quarantine(&mut attempt)?;
2444 return Ok(ReconcileProgress::Replacement {
2445 attempt: attempt.id,
2446 operation: "jit_expired_replacement",
2447 });
2448 }
2449
2450 match recovery_decision(
2451 &attempt,
2452 RecoveryObservation {
2453 process_alive,
2454 github: github.status,
2455 },
2456 self.timeouts,
2457 self.ports.clock.as_ref(),
2458 ) {
2459 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
2460 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
2461 RecoveryDecision::Adopt => {
2462 self.ports.events.emit(AttemptEvent::Adopted {
2463 attempt: attempt.id,
2464 });
2465 Ok(ReconcileProgress::Reconciled)
2466 }
2467 RecoveryDecision::Clean => {
2468 self.clean_or_quarantine(&mut attempt)?;
2469 Ok(ReconcileProgress::Reconciled)
2470 }
2471 RecoveryDecision::Observe(state) => {
2472 let runner_id = attempt
2473 .github_runner_id()
2474 .or(github.runner_id)
2475 .or_else(|| read_runner_id(attempt.runtime_path()))
2476 .ok_or(LifecycleError::Transition)?;
2477 match state {
2478 AttemptState::JitReceived => attempt
2479 .jit_received(self.ports.clock.now())
2480 .map_err(|_| LifecycleError::Transition)?,
2481 AttemptState::Starting => {
2482 let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
2483 attempt
2484 .started(pid, self.ports.clock.now())
2485 .map_err(|_| LifecycleError::Transition)?;
2486 }
2487 AttemptState::Idle => attempt
2488 .registered_idle(runner_id, self.ports.clock.now())
2489 .map_err(|_| LifecycleError::Transition)?,
2490 AttemptState::Busy => attempt
2491 .assigned_job(runner_id, self.ports.clock.now())
2492 .map_err(|_| LifecycleError::Transition)?,
2493 _ => return Err(LifecycleError::Transition),
2494 }
2495 self.record(&attempt)?;
2496 Ok(ReconcileProgress::Reconciled)
2497 }
2498 RecoveryDecision::Conclude(outcome) => {
2499 let replacement = replacement_operation(&outcome);
2500 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
2505 self.deregister_runner(policy, &attempt).await;
2506 }
2507 self.conclude(&mut attempt, outcome)?;
2508 self.clean_or_quarantine(&mut attempt)?;
2509 Ok(
2510 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
2511 ReconcileProgress::Replacement {
2512 attempt: attempt.id,
2513 operation,
2514 }
2515 }),
2516 )
2517 }
2518 RecoveryDecision::Terminate(payload) => {
2519 let idle_exit = payload.is_idle_exit();
2529 self.ports
2530 .processes
2531 .record_terminate_intent(&attempt)
2532 .map_err(LifecycleError::Failed)?;
2533 self.ports.events.emit(AttemptEvent::TerminateIntent {
2534 attempt: attempt.id,
2535 });
2536 self.ports
2537 .processes
2538 .terminate(&attempt)
2539 .map_err(LifecycleError::Failed)?;
2540 if self
2541 .ports
2542 .processes
2543 .is_alive(&attempt)
2544 .map_err(LifecycleError::Failed)?
2545 {
2546 return Ok(ReconcileProgress::Deferred);
2547 }
2548 self.ports.events.emit(AttemptEvent::Terminated {
2549 attempt: attempt.id,
2550 });
2551 let outcome = if idle_exit {
2557 AttemptOutcome::ExitedIdleWithoutWork
2558 } else {
2559 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
2560 };
2561 self.deregister_runner(policy, &attempt).await;
2562 self.conclude(&mut attempt, outcome)?;
2563 self.clean_or_quarantine(&mut attempt)?;
2564 if idle_exit {
2568 Ok(ReconcileProgress::Reconciled)
2569 } else {
2570 Ok(ReconcileProgress::Replacement {
2571 attempt: attempt.id,
2572 operation: "registration_timeout_replacement",
2573 })
2574 }
2575 }
2576 }
2577 }
2578
2579 fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2580 self.ports
2581 .store
2582 .record_attempt(attempt)
2583 .map_err(|_| LifecycleError::Journal)?;
2584 self.ports.events.emit(AttemptEvent::State {
2585 attempt: attempt.id,
2586 state: attempt.state(),
2587 });
2588 Ok(())
2589 }
2590
2591 async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
2612 let Some(runner_id) = attempt
2613 .github_runner_id()
2614 .or_else(|| read_runner_id(attempt.runtime_path()))
2615 else {
2616 return;
2617 };
2618 if self
2619 .ports
2620 .github
2621 .deregister(&policy.target, runner_id, &self.cancel)
2622 .await
2623 {
2624 self.ports.events.emit(AttemptEvent::Deregistered {
2625 attempt: attempt.id,
2626 runner_id,
2627 });
2628 } else {
2629 tracing::warn!(
2630 attempt = %attempt.id,
2631 runner_id,
2632 "the runner registration could not be removed from GitHub; it will show in the \
2633 target's runner settings until GitHub retires it or a later pass removes it"
2634 );
2635 }
2636 }
2637
2638 fn conclude(
2639 &self,
2640 attempt: &mut RunnerAttempt,
2641 outcome: AttemptOutcome,
2642 ) -> Result<(), LifecycleError> {
2643 attempt
2644 .conclude(outcome.clone(), self.ports.clock.now())
2645 .map_err(|_| LifecycleError::Transition)?;
2646 self.record(attempt)?;
2647 self.ports.events.emit(AttemptEvent::Concluded {
2648 attempt: attempt.id,
2649 outcome: OutcomeKind::of(&outcome),
2650 });
2651 Ok(())
2652 }
2653
2654 fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2670 match self.clean_attempt(attempt) {
2671 Err(LifecycleError::SlotQuarantined { class, .. }) => {
2672 self.ports
2673 .reconcile_events
2674 .emit(LifecycleEvent::AttemptCleanFailed {
2675 policy: attempt.policy_id,
2676 attempt: attempt.id,
2677 reason: class,
2678 });
2679 Ok(())
2680 }
2681 Err(LifecycleError::WorkspaceCleanupDeferred) => {
2682 self.ports
2683 .reconcile_events
2684 .emit(LifecycleEvent::AttemptCleanFailed {
2685 policy: attempt.policy_id,
2686 attempt: attempt.id,
2687 reason: "ephemeral_workspace_could_not_be_removed",
2688 });
2689 Ok(())
2690 }
2691 other => other,
2692 }
2693 }
2694
2695 fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2696 let outcome = attempt
2697 .outcome()
2698 .cloned()
2699 .ok_or(LifecycleError::Transition)?;
2700 self.preserve_diagnostics(attempt, &outcome)?;
2701 self.scrub_workspace(attempt)?;
2702 self.ports
2703 .packages
2704 .release(attempt.id)
2705 .map_err(LifecycleError::Failed)?;
2706 attempt
2707 .clean(self.ports.clock.now())
2708 .map_err(|_| LifecycleError::Transition)?;
2709 self.record(attempt)?;
2710 let kind = OutcomeKind::of(&outcome);
2711 self.ports.events.emit(AttemptEvent::Cleaned {
2712 attempt: attempt.id,
2713 outcome: kind,
2714 });
2715 self.ports
2716 .reconcile_events
2717 .emit(LifecycleEvent::AttemptCleaned {
2718 policy: attempt.policy_id,
2719 attempt: attempt.id,
2720 outcome: kind,
2721 });
2722 Ok(())
2723 }
2724
2725 fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2735 #[cfg(test)]
2736 {
2737 if matches!(
2741 std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
2742 Ok("skip_workspace_cleanup" | "reuse_job_workspace")
2743 ) {
2744 return Ok(());
2745 }
2746 }
2747 match attempt.workspace() {
2748 AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
2749 Ok(()) => Ok(()),
2750 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2751 Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
2752 },
2753 AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
2754 }
2755 }
2756
2757 fn scrub_persistent_slot(
2770 &self,
2771 attempt: &RunnerAttempt,
2772 slot: NonZeroU16,
2773 ) -> Result<(), LifecycleError> {
2774 let configured = self
2779 .ports
2780 .store
2781 .policy(attempt.policy_id)
2782 .map_err(|_| LifecycleError::Journal)?
2783 .and_then(|policy| match policy.workspace_policy() {
2784 WorkspacePolicy::Persistent { root } => Some(root.clone()),
2785 WorkspacePolicy::Ephemeral => None,
2786 });
2787 let runtime = attempt.runtime_path();
2788 self.quarantine_on_refusal(
2789 attempt,
2790 verify_journalled_slot(runtime, slot, configured.as_ref())
2791 .and_then(|()| slot_is_present(runtime))
2792 .and_then(|present| {
2793 if present {
2794 scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
2795 } else {
2796 Ok(())
2797 }
2798 }),
2799 )
2800 }
2801
2802 fn quarantine_on_refusal(
2811 &self,
2812 attempt: &RunnerAttempt,
2813 outcome: Result<(), SlotQuarantine>,
2814 ) -> Result<(), LifecycleError> {
2815 let Err(quarantine) = outcome else {
2816 return Ok(());
2817 };
2818 let detail = quarantine.to_string();
2819 tracing::warn!(
2820 attempt = %attempt.id,
2821 policy = %attempt.policy_id,
2822 slot = attempt.workspace().slot_number(),
2823 refusal = quarantine.refusal.class(),
2824 "{detail}"
2825 );
2826 Err(LifecycleError::SlotQuarantined {
2827 class: quarantine.refusal.class(),
2828 detail,
2829 })
2830 }
2831
2832 fn preserve_diagnostics(
2833 &self,
2834 attempt: &RunnerAttempt,
2835 outcome: &AttemptOutcome,
2836 ) -> Result<(), LifecycleError> {
2837 fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
2838 LifecycleError::Failed(FailureReason::Other(
2839 "diagnostics directory could not be created".into(),
2840 ))
2841 })?;
2842 let diagnostic = format!(
2845 "attempt_id={}\npolicy_id={}\noutcome={}\n",
2846 attempt.id,
2847 attempt.policy_id,
2848 OutcomeKind::of(outcome).as_str()
2849 );
2850 fs::write(
2851 self.diagnostics_root.join(format!("{}.log", attempt.id)),
2852 diagnostic,
2853 )
2854 .map_err(|_| {
2855 LifecycleError::Failed(FailureReason::Other(
2856 "redacted diagnostics could not be preserved".into(),
2857 ))
2858 })
2859 }
2860
2861 async fn materialize_with_retry(
2862 &self,
2863 policy: &ScalePolicy,
2864 attempt: &RunnerAttempt,
2865 ) -> Result<RunnerVersion, FailureReason> {
2866 let mut issued = 0_u32;
2867 loop {
2868 issued = issued.saturating_add(1);
2869 match self.ports.packages.materialize(attempt).await {
2870 Ok(version) => return Ok(version),
2871 Err(reason)
2872 if package_failure_is_terminal(&reason)
2873 || issued >= self.retry.max_attempts.max(1) =>
2874 {
2875 return Err(reason);
2876 }
2877 Err(reason) => {
2878 if !self.ports.demand.persists(policy.id).await {
2879 return Err(reason);
2880 }
2881 let delay = self.retry.delay(issued);
2882 self.ports.events.emit(AttemptEvent::Retry {
2883 attempt: attempt.id,
2884 operation: "package_materialization",
2885 delay,
2886 });
2887 self.ports.delay.wait(delay).await;
2888 if !self.ports.demand.persists(policy.id).await {
2889 return Err(reason);
2890 }
2891 }
2892 }
2893 }
2894 }
2895
2896 async fn register_with_retry(
2897 &self,
2898 policy: &ScalePolicy,
2899 attempt: AttemptId,
2900 request: &JitRunnerRequest,
2901 ) -> Result<JitRegistration, LifecycleError> {
2902 let mut issued = 0_u32;
2903 loop {
2904 issued = issued.saturating_add(1);
2905 match self
2906 .ports
2907 .github
2908 .register(&policy.target, request, &self.cancel)
2909 .await
2910 {
2911 Ok(registration) => return Ok(registration),
2912 Err(error) if error.terminal => {
2913 return Err(LifecycleError::Failed(error.reason));
2914 }
2915 Err(error) => {
2916 if issued >= self.retry.max_attempts.max(1)
2917 || !self.ports.demand.persists(policy.id).await
2918 {
2919 return Err(LifecycleError::Failed(error.reason));
2920 }
2921 let delay = error
2922 .retry_after
2923 .unwrap_or_else(|| self.retry.delay(issued));
2924 self.ports.events.emit(AttemptEvent::Retry {
2925 attempt,
2926 operation: "jit_request",
2927 delay,
2928 });
2929 self.ports.delay.wait(delay).await;
2930 if !self.ports.demand.persists(policy.id).await {
2931 return Err(LifecycleError::Failed(error.reason));
2932 }
2933 }
2934 }
2935 }
2936 }
2937
2938 fn allocate_workspace(
2948 &self,
2949 policy: &ScalePolicy,
2950 id: AttemptId,
2951 ) -> Result<Placement, LifecycleError> {
2952 let placement = match policy.workspace_policy() {
2953 WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
2958 WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
2959 };
2960 if placement.is_ok() {
2963 self.root_accepted(policy.id);
2964 }
2965 placement
2966 }
2967
2968 fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
2975 let host = self
2976 .ports
2977 .store
2978 .host(self.host_id)
2979 .map_err(|_| LifecycleError::Journal)?
2980 .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
2981 Ok(host.runner_root_override.clone())
2982 }
2983
2984 fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
2988 Ok(self
2989 .configured_host_root()?
2990 .or_else(|| default_runner_root(&self.app_paths).ok()))
2991 }
2992
2993 fn effective_host_root(
2998 &self,
2999 policy: &ScalePolicy,
3000 ) -> Result<LocalAbsolutePath, LifecycleError> {
3001 match self.configured_host_root()? {
3002 Some(configured) => Ok(configured),
3003 None => default_runner_root(&self.app_paths).map_err(|error| {
3004 self.root_refused(policy.id, "the platform default runner root", error)
3007 }),
3008 }
3009 }
3010
3011 fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
3024 let _ = runner_manager_platform::service::record_runner_root_refusal(
3025 &self.app_paths,
3026 &policy.to_string(),
3027 self.ports.clock.now(),
3028 error.kind(),
3029 root,
3030 &error.to_string(),
3031 );
3032 root_failure(error)
3033 }
3034
3035 fn root_accepted(&self, policy: PolicyId) {
3042 let _ = runner_manager_platform::service::clear_runner_root_refusal(
3043 &self.app_paths,
3044 &policy.to_string(),
3045 );
3046 }
3047
3048 fn allocate_disposable(
3051 &self,
3052 policy: &ScalePolicy,
3053 id: AttemptId,
3054 ) -> Result<Placement, LifecycleError> {
3055 let effective_root = self.effective_host_root(policy)?;
3056 RootPreflight::new(&self.app_paths)
3057 .check(&RootOwner::Host, &effective_root)
3058 .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
3059 let runtime = effective_root.as_path().join({
3060 #[cfg(test)]
3061 {
3062 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
3063 == Ok("reuse_job_workspace")
3064 {
3065 "mutant-shared-workspace".to_owned()
3066 } else {
3067 workspace_name(id)
3068 }
3069 }
3070 #[cfg(not(test))]
3071 {
3072 workspace_name(id)
3073 }
3074 });
3075 fs::create_dir_all(&runtime)
3076 .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
3077 Ok(Placement {
3078 runtime,
3079 workspace: AttemptWorkspace::Ephemeral,
3080 })
3081 }
3082
3083 fn allocate_persistent_slot(
3095 &self,
3096 policy: &ScalePolicy,
3097 root: &LocalAbsolutePath,
3098 ) -> Result<Placement, LifecycleError> {
3099 let ceiling = policy.max_capacity().ok_or_else(|| {
3102 LifecycleError::Failed(FailureReason::Other(
3103 "a persistent workspace needs the policy's max_capacity to bound its slots"
3104 .to_string(),
3105 ))
3106 })?;
3107 let leases = self
3108 .ports
3109 .store
3110 .slot_leases_for_policy(policy.id)
3111 .map_err(|_| LifecycleError::Journal)?;
3112 let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
3113 LifecycleError::Failed(FailureReason::Other(format!(
3114 "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
3115 not been cleaned, so no slot is free; raise the repository's max capacity, or \
3116 finish cleaning a concluded attempt",
3117 policy.target
3118 )))
3119 })?;
3120 let workspace = AttemptWorkspace::persistent_slot(slot);
3121 let name = workspace
3122 .slot_directory_name()
3123 .expect("a persistent allocation names its slot directory");
3124
3125 let host_root = self.resolvable_host_root()?;
3137 let mut preflight = RootPreflight::new(&self.app_paths);
3138 if let Some(host_root) = host_root {
3139 preflight = preflight.against(RootOwner::Host, host_root);
3140 }
3141 let checked = preflight
3142 .check(&RootOwner::Repository(policy.target.to_string()), root)
3143 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
3144 if let Some(leaf) = checked.leaf_to_create() {
3145 fs::create_dir(leaf).map_err(|source| {
3146 LifecycleError::Failed(FailureReason::Other(format!(
3147 "the persistent workspace root {} could not be created: {source}",
3148 leaf.display()
3149 )))
3150 })?;
3151 }
3152
3153 let slot_path = runner_root::derive_child(root, &name)
3157 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
3158 create_or_validate_slot(slot_path.as_path())?;
3159 runner_root::verify_containment(root, &slot_path)
3160 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
3161 accept_reusable_slot(slot_path.as_path())?;
3162 Ok(Placement {
3163 runtime: slot_path.as_path().to_path_buf(),
3164 workspace,
3165 })
3166 }
3167
3168 fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3180 match self.ports.store.record_attempt(attempt) {
3181 Ok(()) => {
3182 self.ports.events.emit(AttemptEvent::State {
3183 attempt: attempt.id,
3184 state: attempt.state(),
3185 });
3186 Ok(())
3187 }
3188 Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
3189 FailureReason::Other(error.to_string()),
3190 )),
3191 Err(_) => Err(LifecycleError::Journal),
3192 }
3193 }
3194
3195 async fn launch_attempt(
3196 &self,
3197 policy: &ScalePolicy,
3198 allocation_guard: &AllocationGuard,
3199 lock_wait: Duration,
3200 ) -> Result<RunnerAttempt, LifecycleError> {
3201 let (attempt, labels) = self.allocate_attempt(policy)?;
3202 let id = attempt.id;
3203 let mut stopwatch = LaunchStopwatch::new(lock_wait);
3204 let result = self
3205 .launch_steps(policy, allocation_guard, attempt, labels, &mut stopwatch)
3206 .await;
3207 stopwatch.finish();
3208 self.ports.events.emit(AttemptEvent::Launched {
3209 attempt: id,
3210 started: result.is_ok(),
3211 timings: stopwatch.timings,
3212 });
3213 result
3214 }
3215
3216 fn allocate_attempt<'p>(
3219 &self,
3220 policy: &'p ScalePolicy,
3221 ) -> Result<(RunnerAttempt, &'p RoutingLabels), LifecycleError> {
3222 if !*self
3223 .recovery_complete
3224 .lock()
3225 .map_err(|_| LifecycleError::Journal)?
3226 {
3227 return Err(LifecycleError::RecoveryIncomplete);
3228 }
3229 let labels = policy
3230 .routing_labels()
3231 .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
3232 let id = AttemptId::new_random();
3233 let placement = self.allocate_workspace(policy, id)?;
3234 let attempt = RunnerAttempt::allocate_in(
3235 id,
3236 policy.id,
3237 placement.runtime,
3238 placement.workspace,
3239 self.ports.clock.now(),
3240 );
3241 self.record_allocation(&attempt)?;
3246 Ok((attempt, labels))
3247 }
3248
3249 async fn launch_steps(
3250 &self,
3251 policy: &ScalePolicy,
3252 allocation_guard: &AllocationGuard,
3253 mut attempt: RunnerAttempt,
3254 labels: &RoutingLabels,
3255 stopwatch: &mut LaunchStopwatch,
3256 ) -> Result<RunnerAttempt, LifecycleError> {
3257 let id = attempt.id;
3258 stopwatch.begin(LaunchStep::Materialize);
3259 let version = match self.materialize_with_retry(policy, &attempt).await {
3260 Ok(version) => version,
3261 Err(reason) => return self.fail_launch(&mut attempt, reason),
3262 };
3263 self.prune_under_allocation_lock(allocation_guard, &version)?;
3264 self.versions
3265 .lock()
3266 .map_err(|_| LifecycleError::Journal)?
3267 .insert(id, version);
3268
3269 stopwatch.begin(LaunchStep::Register);
3270 let jit_request =
3271 JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
3272 let registration = match self.register_with_retry(policy, id, &jit_request).await {
3273 Ok(registration) => registration,
3274 Err(error) => return self.fail_launch(&mut attempt, error.reason()),
3275 };
3276 stopwatch.begin(LaunchStep::Spawn);
3277 let runner_id = registration.runner().id;
3278 write_runner_id(attempt.runtime_path(), runner_id)?;
3279 attempt
3280 .jit_received(self.ports.clock.now())
3281 .map_err(|_| LifecycleError::Transition)?;
3282 self.record(&attempt)?;
3283 let config = registration.into_config();
3284 let mut issued = 0_u32;
3285 let pid = loop {
3286 issued = issued.saturating_add(1);
3287 match self.ports.processes.spawn(&attempt, &config) {
3288 Ok(pid) => break pid,
3289 Err(error) => {
3290 if let Some(pid) = error.live_pid {
3291 attempt
3292 .started(pid, self.ports.clock.now())
3293 .map_err(|_| LifecycleError::Transition)?;
3294 self.record(&attempt)?;
3295 return Err(LifecycleError::Failed(error.reason));
3296 }
3297 if !error.retryable
3298 || issued >= self.retry.max_attempts.max(1)
3299 || !self.ports.demand.persists(policy.id).await
3300 {
3301 return self.fail_launch(&mut attempt, error.reason);
3302 }
3303 let delay = self.retry.delay(issued);
3304 self.ports.events.emit(AttemptEvent::Retry {
3305 attempt: attempt.id,
3306 operation: "process_start",
3307 delay,
3308 });
3309 self.ports.delay.wait(delay).await;
3310 if !self.ports.demand.persists(policy.id).await {
3311 return self.fail_launch(&mut attempt, error.reason);
3312 }
3313 }
3314 }
3315 };
3316 attempt
3317 .started(pid, self.ports.clock.now())
3318 .map_err(|_| LifecycleError::Transition)?;
3319 self.record(&attempt)?;
3320 Ok(attempt)
3321 }
3322
3323 fn fail_launch<T>(
3324 &self,
3325 attempt: &mut RunnerAttempt,
3326 reason: FailureReason,
3327 ) -> Result<T, LifecycleError> {
3328 self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
3329 Err(LifecycleError::Failed(reason))
3330 }
3331
3332 fn prune_under_allocation_lock(
3335 &self,
3336 guard: &AllocationGuard,
3337 version: &RunnerVersion,
3338 ) -> Result<(), LifecycleError> {
3339 let attempts = self
3340 .ports
3341 .store
3342 .attempts()
3343 .map_err(|_| LifecycleError::Journal)?;
3344 self.ports
3345 .packages
3346 .prune_obsolete_guarded(
3347 PruneAuthority::from_launch_request(guard),
3348 version,
3349 &attempts,
3350 )
3351 .map_err(LifecycleError::Failed)?;
3352 #[cfg(target_os = "macos")]
3353 self.prune_abandoned_seeds(&attempts);
3354 Ok(())
3355 }
3356
3357 #[cfg(target_os = "macos")]
3369 fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
3370 let Ok(policies) = self.ports.store.policies() else {
3371 return;
3372 };
3373 let Ok(Some(host_root)) = self.resolvable_host_root() else {
3374 return;
3375 };
3376 let in_use: BTreeSet<PathBuf> = policies
3377 .iter()
3378 .filter_map(|policy| match policy.workspace_policy() {
3379 WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
3380 WorkspacePolicy::Ephemeral => None,
3381 })
3382 .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
3383 .collect();
3384 let used: BTreeSet<&Path> = attempts
3386 .iter()
3387 .filter_map(|attempt| attempt.runtime_path().parent())
3388 .collect();
3389 for root in used.into_iter().map(canonical_or_raw) {
3390 if !in_use.contains(&root) {
3391 let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
3392 }
3393 }
3394 }
3395}
3396
3397#[cfg(target_os = "macos")]
3398fn canonical_or_raw(path: &Path) -> PathBuf {
3399 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
3400}
3401
3402#[async_trait]
3403impl RunnerLauncher for LifecycleLauncher {
3404 async fn supervise(
3405 &self,
3406 policy: &ScalePolicy,
3407 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
3408 LifecycleLauncher::supervise(self, policy)
3409 .await
3410 .map_err(|error| LaunchFailure::new(error.reason()))
3411 }
3412
3413 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3414 self.ports.store.attempts().map_err(|_| {
3415 LaunchFailure::new(FailureReason::Other(
3416 "attempt journal could not be read".into(),
3417 ))
3418 })
3419 }
3420
3421 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
3422 self.launch_attempt(request.policy, request.allocation_guard, request.lock_wait)
3423 .await
3424 .map_err(|error| LaunchFailure::new(error.reason()))
3425 }
3426
3427 async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
3428 let mut attempt = self
3429 .ports
3430 .store
3431 .attempt(id)
3432 .map_err(|_| {
3433 LaunchFailure::new(FailureReason::Other(
3434 "attempt journal could not be read".into(),
3435 ))
3436 })?
3437 .ok_or_else(|| {
3438 LaunchFailure::new(FailureReason::Other(
3439 "attempt disappeared from the journal".into(),
3440 ))
3441 })?;
3442 self.clean_attempt(&mut attempt)
3443 .map_err(|error| LaunchFailure::new(error.reason()))
3444 }
3445}
3446
3447fn runner_name(attempt: AttemptId) -> String {
3448 format!("runner-manager-{attempt}")
3449}
3450
3451fn read_runner_id(runtime: &Path) -> Option<u64> {
3452 fs::read_to_string(runtime.join(RUNNER_ID_FILE))
3453 .ok()?
3454 .trim()
3455 .parse()
3456 .ok()
3457}
3458
3459fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
3460 let target = runtime.join(RUNNER_ID_FILE);
3461 if let Some(existing) = read_runner_id(runtime) {
3462 return (existing == runner_id)
3463 .then_some(())
3464 .ok_or(LifecycleError::Journal);
3465 }
3466 let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
3467 write_durable_file(&temporary, runner_id.to_string().as_bytes())
3468 .map_err(|_| LifecycleError::Journal)?;
3469 match fs::rename(&temporary, &target) {
3470 Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
3471 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
3472 let _ = fs::remove_file(&temporary);
3473 (read_runner_id(runtime) == Some(runner_id))
3474 .then_some(())
3475 .ok_or(LifecycleError::Journal)
3476 }
3477 Err(_) => {
3478 let _ = fs::remove_file(&temporary);
3479 Err(LifecycleError::Journal)
3480 }
3481 }
3482}
3483
3484fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
3485 let mut file = fs::OpenOptions::new()
3486 .create(true)
3487 .truncate(true)
3488 .write(true)
3489 .open(path)?;
3490 file.write_all(bytes)?;
3491 file.sync_all()?;
3492 let parent = path.parent().ok_or_else(|| {
3493 std::io::Error::new(
3494 std::io::ErrorKind::InvalidInput,
3495 "file has no parent directory",
3496 )
3497 })?;
3498 sync_directory(parent)
3499}
3500
3501#[cfg(unix)]
3502fn sync_directory(path: &Path) -> std::io::Result<()> {
3503 fs::File::open(path)?.sync_all()
3504}
3505
3506#[cfg(windows)]
3507fn sync_directory(path: &Path) -> std::io::Result<()> {
3508 use std::os::windows::fs::OpenOptionsExt;
3509
3510 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
3511 const FILE_SHARE_ALL: u32 = 0x0000_0007;
3512 const GENERIC_WRITE: u32 = 0x4000_0000;
3513 fs::OpenOptions::new()
3514 .access_mode(GENERIC_WRITE)
3515 .share_mode(FILE_SHARE_ALL)
3516 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
3517 .open(path)?
3518 .sync_all()
3519}
3520
3521#[cfg(test)]
3522mod tests {
3523 use super::*;
3524 use std::collections::BTreeSet;
3525 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
3526
3527 use crate::reconcile::{AllocationLock, InProcessAllocationLock};
3528 use runner_manager_domain::model::{Elapsed, TargetScope};
3529 use runner_manager_domain::store::SqliteStore;
3530 use runner_manager_github::jit::JitRunner;
3531 use runner_manager_testkit::clock::FakeClock;
3532 use runner_manager_testkit::fixtures;
3533
3534 type CapturedFields = Vec<(String, String)>;
3536
3537 #[derive(Clone, Default)]
3545 struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
3546
3547 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
3548 fn on_event(
3549 &self,
3550 event: &tracing::Event<'_>,
3551 _context: tracing_subscriber::layer::Context<'_, S>,
3552 ) {
3553 struct Collect(Vec<(String, String)>);
3554 impl tracing::field::Visit for Collect {
3555 fn record_debug(
3556 &mut self,
3557 field: &tracing::field::Field,
3558 value: &dyn std::fmt::Debug,
3559 ) {
3560 self.0.push((
3563 field.name().to_owned(),
3564 format!("{value:?}").trim_matches('"').to_owned(),
3565 ));
3566 }
3567 }
3568 let mut collected = Collect(Vec::new());
3569 event.record(&mut collected);
3570 self.0
3571 .lock()
3572 .expect("the capture mutex is not poisoned")
3573 .push(collected.0);
3574 }
3575 }
3576
3577 #[test]
3595 fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
3596 use runner_manager_platform::logging;
3597 use tracing_subscriber::layer::SubscriberExt as _;
3598
3599 let captured = CapturedEvents::default();
3600 let error = RunnerRootError::DeniedByPrivacyPolicy {
3601 requested: PathBuf::from("/Volumes/NVME/runners"),
3602 refused: PathBuf::from("/Volumes/NVME"),
3603 remediation: RootOwner::Host.remediation(),
3604 };
3605 let kind = error.kind();
3606
3607 let failure = tracing::subscriber::with_default(
3608 tracing_subscriber::registry().with(captured.clone()),
3609 || root_failure(error),
3610 );
3611
3612 assert!(
3617 matches!(
3618 &failure,
3619 LifecycleError::Failed(FailureReason::Other(detail))
3620 if detail.contains("/Volumes/NVME/runners")
3621 && detail.contains("Full Disk Access")
3622 ),
3623 "the reason must still carry the detail: {failure:?}"
3624 );
3625
3626 let events = captured
3627 .0
3628 .lock()
3629 .expect("the capture mutex is not poisoned")
3630 .clone();
3631 let event = events
3632 .iter()
3633 .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
3634 .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
3635
3636 for (name, value) in event {
3641 assert!(
3642 logging::is_field_allowed(name),
3643 "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
3644 logging::REDACTION
3645 );
3646 assert_eq!(
3647 &logging::redact(value),
3648 value,
3649 "`{name}` does not survive value-shape scrubbing: {event:?}"
3650 );
3651 }
3652 }
3653
3654 fn nz(slot: u16) -> NonZeroU16 {
3656 NonZeroU16::new(slot).expect("a positive slot")
3657 }
3658
3659 const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
3660
3661 #[derive(Debug, Default)]
3662 struct FakeGithubLifecycle {
3663 registration_failures: Mutex<VecDeque<bool>>,
3664 observations: Mutex<VecDeque<LifecycleGithubObservation>>,
3665 registrations: AtomicUsize,
3666 remaining_runners: AtomicUsize,
3667 deregistrations: Mutex<Vec<u64>>,
3671 deregistration_fails: AtomicBool,
3674 journal: Mutex<Option<Arc<SqliteStore>>>,
3679 registration_facts: Mutex<Vec<RegistrationFact>>,
3681 }
3682
3683 #[derive(Debug, Clone)]
3690 struct RegistrationFact {
3691 leased_slots: Vec<u16>,
3693 work_folder: String,
3695 runner_name: String,
3698 }
3699
3700 impl FakeGithubLifecycle {
3701 fn fail(mut self, terminal: bool) -> Self {
3702 self.registration_failures
3703 .get_mut()
3704 .expect("unpoisoned")
3705 .push_back(terminal);
3706 self
3707 }
3708
3709 fn watch_journal(&self, store: Arc<SqliteStore>) {
3710 *self.journal.lock().unwrap() = Some(store);
3711 }
3712
3713 fn registration_facts(&self) -> Vec<RegistrationFact> {
3714 self.registration_facts.lock().unwrap().clone()
3715 }
3716
3717 fn observe(&self, observation: GithubRunnerObservation) {
3718 let observation = match observation {
3719 GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
3720 GithubRunnerObservation::NotRegistered => {
3721 LifecycleGithubObservation::not_registered()
3722 }
3723 GithubRunnerObservation::Registered { busy } => {
3724 LifecycleGithubObservation::registered(73, busy)
3725 }
3726 };
3727 self.observations.lock().unwrap().push_back(observation);
3728 }
3729 }
3730
3731 #[async_trait]
3732 impl LifecycleGithub for FakeGithubLifecycle {
3733 async fn register(
3734 &self,
3735 _target: &ScaleTarget,
3736 request: &JitRunnerRequest,
3737 _cancel: &CancelToken,
3738 ) -> Result<JitRegistration, JitRequestFailure> {
3739 self.registrations.fetch_add(1, Ordering::SeqCst);
3740 if let Some(store) = self.journal.lock().unwrap().as_ref() {
3741 let slots = store
3742 .attempts()
3743 .expect("the journal is readable")
3744 .iter()
3745 .filter_map(|attempt| attempt.workspace().slot_number())
3746 .collect();
3747 self.registration_facts
3748 .lock()
3749 .unwrap()
3750 .push(RegistrationFact {
3751 leased_slots: slots,
3752 work_folder: request.work_folder().to_string(),
3753 runner_name: request.name().to_string(),
3754 });
3755 }
3756 if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
3757 return Err(JitRequestFailure {
3758 terminal,
3759 reason: if terminal {
3760 FailureReason::Other("GitHub refused JIT registration with 403".into())
3761 } else {
3762 FailureReason::JitRequestFailed
3763 },
3764 retry_after: None,
3765 });
3766 }
3767 self.remaining_runners.store(1, Ordering::SeqCst);
3768 Ok(JitRegistration::new(
3769 EncodedJitConfig::new(JIT),
3770 JitRunner {
3771 id: 73,
3772 name: request.name().to_string(),
3773 os: "windows".into(),
3774 status: "offline".into(),
3775 busy: false,
3776 runner_group_id: Some(1),
3777 labels: request.labels().to_vec(),
3778 },
3779 ))
3780 }
3781
3782 async fn observe(
3783 &self,
3784 _target: &ScaleTarget,
3785 _attempt: AttemptId,
3786 _cancel: &CancelToken,
3787 ) -> LifecycleGithubObservation {
3788 let observation = self
3789 .observations
3790 .lock()
3791 .unwrap()
3792 .pop_front()
3793 .unwrap_or(LifecycleGithubObservation::not_registered());
3794 if observation.status == GithubRunnerObservation::NotRegistered {
3795 self.remaining_runners.store(0, Ordering::SeqCst);
3796 }
3797 observation
3798 }
3799
3800 async fn deregister(
3801 &self,
3802 _target: &ScaleTarget,
3803 runner_id: u64,
3804 _cancel: &CancelToken,
3805 ) -> bool {
3806 self.deregistrations.lock().unwrap().push(runner_id);
3807 if self.deregistration_fails.load(Ordering::SeqCst) {
3808 return false;
3809 }
3810 self.remaining_runners.store(0, Ordering::SeqCst);
3811 true
3812 }
3813 }
3814
3815 #[derive(Debug)]
3816 struct FakePackages {
3817 version: RunnerVersion,
3818 leases: Mutex<BTreeSet<AttemptId>>,
3819 materializations: AtomicUsize,
3820 materialization_failures: AtomicUsize,
3821 release_failures: AtomicUsize,
3822 releases: AtomicUsize,
3823 prunes: AtomicUsize,
3824 prune_currents: Mutex<Vec<RunnerVersion>>,
3825 }
3826
3827 impl Default for FakePackages {
3828 fn default() -> Self {
3829 Self {
3830 version: RunnerVersion::parse("2.330.0").unwrap(),
3831 leases: Mutex::new(BTreeSet::new()),
3832 materializations: AtomicUsize::new(0),
3833 materialization_failures: AtomicUsize::new(0),
3834 release_failures: AtomicUsize::new(0),
3835 releases: AtomicUsize::new(0),
3836 prunes: AtomicUsize::new(0),
3837 prune_currents: Mutex::new(Vec::new()),
3838 }
3839 }
3840 }
3841
3842 impl FakePackages {
3843 fn fail_materializations(&self, count: usize) {
3844 self.materialization_failures.store(count, Ordering::SeqCst);
3845 }
3846
3847 fn fail_releases(&self, count: usize) {
3848 self.release_failures.store(count, Ordering::SeqCst);
3849 }
3850 }
3851
3852 #[async_trait]
3853 impl RuntimePackages for FakePackages {
3854 async fn materialize(
3855 &self,
3856 attempt: &RunnerAttempt,
3857 ) -> Result<RunnerVersion, FailureReason> {
3858 self.materializations.fetch_add(1, Ordering::SeqCst);
3859 if self
3860 .materialization_failures
3861 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3862 if left > 0 { Some(left - 1) } else { None }
3863 })
3864 .is_ok()
3865 {
3866 return Err(FailureReason::Other(
3867 "runner package materialization failed transiently".into(),
3868 ));
3869 }
3870 fs::create_dir_all(attempt.runtime_path()).unwrap();
3871 fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
3872 self.leases.lock().unwrap().insert(attempt.id);
3873 Ok(self.version.clone())
3874 }
3875
3876 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
3877 if self
3878 .release_failures
3879 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3880 if left > 0 { Some(left - 1) } else { None }
3881 })
3882 .is_ok()
3883 {
3884 return Err(FailureReason::Other(
3885 "runner package lease could not be released".into(),
3886 ));
3887 }
3888 self.leases.lock().unwrap().remove(&attempt);
3889 self.releases.fetch_add(1, Ordering::SeqCst);
3890 Ok(())
3891 }
3892
3893 fn prune_obsolete_guarded(
3894 &self,
3895 _authority: PruneAuthority<'_>,
3896 current: &RunnerVersion,
3897 _attempts: &[RunnerAttempt],
3898 ) -> Result<(), FailureReason> {
3899 self.prunes.fetch_add(1, Ordering::SeqCst);
3900 self.prune_currents.lock().unwrap().push(current.clone());
3901 Ok(())
3902 }
3903 }
3904
3905 #[derive(Debug, Default)]
3906 struct FakeProcesses {
3907 alive: AtomicBool,
3908 completed_successfully: AtomicBool,
3909 spawns: AtomicUsize,
3910 spawn_failures: AtomicUsize,
3911 live_spawn_failure: AtomicBool,
3912 terminations: AtomicUsize,
3913 intent: AtomicBool,
3914 intent_failure: AtomicBool,
3915 actions: Mutex<Vec<&'static str>>,
3916 saw_secret: AtomicBool,
3917 }
3918
3919 impl FakeProcesses {
3920 fn fail_spawns(&self, count: usize) {
3921 self.spawn_failures.store(count, Ordering::SeqCst);
3922 }
3923
3924 fn fail_spawn_with_live_child(&self) {
3925 self.live_spawn_failure.store(true, Ordering::SeqCst);
3926 }
3927
3928 fn set_alive(&self, alive: bool) {
3929 self.alive.store(alive, Ordering::SeqCst);
3930 }
3931
3932 fn finish_successfully(&self) {
3933 self.completed_successfully.store(true, Ordering::SeqCst);
3934 self.alive.store(false, Ordering::SeqCst);
3935 }
3936
3937 fn fail_intent(&self) {
3938 self.intent_failure.store(true, Ordering::SeqCst);
3939 }
3940 }
3941
3942 impl ProcessSupervisor for FakeProcesses {
3943 fn spawn(
3944 &self,
3945 attempt: &RunnerAttempt,
3946 config: &EncodedJitConfig,
3947 ) -> Result<u32, ProcessStartFailure> {
3948 self.spawns.fetch_add(1, Ordering::SeqCst);
3949 let handoff = RestrictiveHandoff::create(
3952 attempt.runtime_path(),
3953 SecretString::from(config.expose().to_owned()),
3954 )
3955 .unwrap();
3956 self.saw_secret
3957 .store(config.expose() == JIT, Ordering::SeqCst);
3958 let handoff_path = handoff.path().to_path_buf();
3959 let failing = self
3960 .spawn_failures
3961 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3962 if left > 0 { Some(left - 1) } else { None }
3963 })
3964 .is_ok();
3965 drop(handoff);
3966 assert!(!handoff_path.exists(), "handoff must be absent on return");
3967 if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
3968 self.alive.store(true, Ordering::SeqCst);
3969 return Err(ProcessStartFailure::after_spawn_live(4242));
3970 }
3971 if failing {
3972 return Err(ProcessStartFailure::before_spawn(
3973 FailureReason::ProcessStartFailed,
3974 ));
3975 }
3976 self.alive.store(true, Ordering::SeqCst);
3977 Ok(4242)
3978 }
3979
3980 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
3981 self.actions.lock().unwrap().push("observe_process");
3982 Ok(self.alive.load(Ordering::SeqCst))
3983 }
3984
3985 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3986 Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
3987 }
3988
3989 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
3990 self.completed_successfully.load(Ordering::SeqCst)
3991 }
3992
3993 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3994 self.actions.lock().unwrap().push("terminate_intent");
3995 if self.intent_failure.load(Ordering::SeqCst) {
3996 return Err(FailureReason::Other(
3997 "terminate intent directory sync failed".into(),
3998 ));
3999 }
4000 self.intent.store(true, Ordering::SeqCst);
4001 Ok(())
4002 }
4003
4004 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
4005 self.intent.load(Ordering::SeqCst)
4006 }
4007
4008 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
4009 assert!(
4010 self.intent.load(Ordering::SeqCst),
4011 "the durable intent must exist before signalling"
4012 );
4013 self.actions.lock().unwrap().push("terminate");
4014 self.terminations.fetch_add(1, Ordering::SeqCst);
4015 self.alive.store(false, Ordering::SeqCst);
4016 Ok(())
4017 }
4018 }
4019
4020 #[derive(Debug, Default)]
4021 struct FakeDemand {
4022 answers: Mutex<VecDeque<bool>>,
4023 }
4024
4025 impl FakeDemand {
4026 fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
4027 Self {
4028 answers: Mutex::new(answers.into_iter().collect()),
4029 }
4030 }
4031 }
4032
4033 #[async_trait]
4034 impl DemandPersistence for FakeDemand {
4035 async fn persists(&self, _policy: PolicyId) -> bool {
4036 self.answers.lock().unwrap().pop_front().unwrap_or(true)
4037 }
4038 }
4039
4040 #[derive(Debug, Default)]
4041 struct FakeDelay(Mutex<Vec<Duration>>);
4042
4043 #[async_trait]
4044 impl RetryDelay for FakeDelay {
4045 async fn wait(&self, duration: Duration) {
4046 self.0.lock().unwrap().push(duration);
4047 }
4048 }
4049
4050 struct Harness {
4051 _root: tempfile::TempDir,
4052 app_paths: runner_manager_platform::paths::AppPaths,
4053 launcher: LifecycleLauncher,
4054 demand: Arc<dyn DemandPersistence>,
4055 store: Arc<SqliteStore>,
4056 github: Arc<FakeGithubLifecycle>,
4057 packages: Arc<FakePackages>,
4058 processes: Arc<FakeProcesses>,
4059 clock: Arc<FakeClock>,
4060 events: Arc<AttemptEventLog>,
4061 reconcile_events: Arc<crate::reconcile::EventLog>,
4062 delay: Arc<FakeDelay>,
4063 host: runner_manager_domain::model::Host,
4064 policy: ScalePolicy,
4065 allocation_lock: InProcessAllocationLock,
4066 workspace_root: Option<LocalAbsolutePath>,
4068 }
4069
4070 impl Harness {
4071 fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
4072 let root = tempfile::tempdir().unwrap();
4073 let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
4074 paths.create_all().unwrap();
4075 let policy = fixtures::policy()
4076 .repository("octo/repo")
4077 .autoscale("home", 2)
4078 .active()
4079 .build();
4080 let host_root = root.path().join("host-root");
4086 fs::create_dir_all(&host_root).unwrap();
4087 let mut host = fixtures::host().build();
4088 host.runner_root_override = Some(
4089 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
4090 .expect("a local absolute host root"),
4091 );
4092 let store = Arc::new(SqliteStore::open_in_memory().unwrap());
4093 store.put_host(&host).unwrap();
4094 let github = Arc::new(github);
4095 let packages = Arc::new(FakePackages::default());
4096 let processes = Arc::new(FakeProcesses::default());
4097 let clock = Arc::new(FakeClock::default());
4098 let events = Arc::new(AttemptEventLog::default());
4099 let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
4100 let delay = Arc::new(FakeDelay::default());
4101 let ports = LifecyclePorts {
4102 store: Arc::clone(&store) as Arc<dyn Store>,
4103 github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
4104 packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
4105 processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
4106 clock: Arc::clone(&clock) as Arc<dyn Clock>,
4107 demand: Arc::clone(&demand),
4108 delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
4109 events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
4110 reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
4111 };
4112 let launcher = Self::launcher_over(policy.host_id, &paths, ports);
4113 Self {
4114 _root: root,
4115 app_paths: paths,
4116 launcher,
4117 demand,
4118 store,
4119 github,
4120 packages,
4121 processes,
4122 clock,
4123 events,
4124 reconcile_events,
4125 delay,
4126 host,
4127 policy,
4128 allocation_lock: InProcessAllocationLock::new(),
4129 workspace_root: None,
4130 }
4131 }
4132
4133 fn with_host_runner_root(mut self) -> Self {
4140 let host_root = self.host_root();
4141 fs::create_dir_all(&host_root).unwrap();
4142 self.host.runner_root_override = Some(
4143 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
4144 .expect("a local absolute host root"),
4145 );
4146 self.store.put_host(&self.host).unwrap();
4147 self
4148 }
4149
4150 fn with_persistent_workspace(mut self, capacity: u16) -> Self {
4152 self = self.with_host_runner_root();
4153 let root = self._root.path().join("persist");
4154 let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
4155 .expect("a local absolute workspace root");
4156 self.policy = fixtures::policy()
4157 .repository("octo/repo")
4158 .autoscale("home", capacity)
4159 .active()
4160 .build();
4161 self.policy
4162 .set_workspace_policy(
4163 WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
4164 .expect("a repository may be persistent"),
4165 )
4166 .expect("a repository may be persistent");
4167 self.workspace_root = Some(root);
4168 self.store.insert_policy(&self.policy).unwrap();
4171 self
4172 }
4173
4174 fn workspace_root(&self) -> &LocalAbsolutePath {
4175 self.workspace_root
4176 .as_ref()
4177 .expect("this harness configured a persistent workspace")
4178 }
4179
4180 fn slot_path(&self, slot: u16) -> PathBuf {
4181 self.workspace_root().as_path().join(format!("s{slot}"))
4182 }
4183
4184 fn host_root(&self) -> PathBuf {
4185 self._root.path().join("host-root")
4186 }
4187
4188 fn attempt(&self, id: AttemptId) -> RunnerAttempt {
4189 self.store
4190 .attempt(id)
4191 .unwrap()
4192 .expect("the attempt is journalled")
4193 }
4194
4195 fn conclude(&self, id: AttemptId) -> RunnerAttempt {
4197 let mut attempt = self.attempt(id);
4198 attempt
4199 .conclude(
4200 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
4201 self.clock.now(),
4202 )
4203 .unwrap();
4204 self.store.record_attempt(&attempt).unwrap();
4205 attempt
4206 }
4207
4208 async fn cleanup_retaining_work(&self, id: AttemptId) {
4209 self.conclude(id);
4210 self.launcher
4211 .clean(id)
4212 .await
4213 .expect("the slot is scrubbed and the lease released");
4214 }
4215
4216 fn launcher_over(
4219 host: HostId,
4220 paths: &runner_manager_platform::paths::AppPaths,
4221 ports: LifecyclePorts,
4222 ) -> LifecycleLauncher {
4223 LifecycleLauncher::new(
4224 host,
4225 paths.clone(),
4226 paths.logs_dir(),
4227 1,
4228 RecoveryTimeouts::new(
4229 Elapsed::seconds(10),
4230 Elapsed::seconds(10),
4231 Elapsed::seconds(10),
4232 ),
4233 RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
4234 ports,
4235 )
4236 }
4237
4238 fn restart(&self) -> LifecycleLauncher {
4241 Self::launcher_over(
4242 self.policy.host_id,
4243 &self.app_paths,
4244 LifecyclePorts {
4245 store: Arc::clone(&self.store) as Arc<dyn Store>,
4246 github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
4247 packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
4248 processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
4249 clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
4250 demand: Arc::clone(&self.demand),
4251 delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
4252 events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
4253 reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
4254 },
4255 )
4256 }
4257
4258 async fn ready(&self) {
4259 self.launcher
4260 .recover_startup(std::slice::from_ref(&self.policy))
4261 .await
4262 .unwrap();
4263 }
4264
4265 async fn launch(&self) -> RunnerAttempt {
4266 self.launch_result().await.unwrap()
4267 }
4268
4269 async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
4270 self.launch_after_waiting(Duration::ZERO).await
4271 }
4272
4273 async fn launch_after_waiting(
4274 &self,
4275 lock_wait: Duration,
4276 ) -> Result<RunnerAttempt, LaunchFailure> {
4277 let guard = self.allocation_lock.acquire().await.unwrap();
4278 self.launcher
4279 .launch(LaunchRequest {
4280 host: &self.host,
4281 policy: &self.policy,
4282 allocation_guard: &guard,
4283 lock_wait,
4284 })
4285 .await
4286 }
4287
4288 fn only_attempt(&self) -> RunnerAttempt {
4289 self.store.attempts().unwrap().into_iter().next().unwrap()
4290 }
4291 }
4292
4293 #[tokio::test]
4302 async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
4303 use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
4304
4305 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
4306 .with_host_runner_root();
4307 harness.ready().await;
4308
4309 let unusable = harness
4313 ._root
4314 .path()
4315 .join("absent")
4316 .join("deeper")
4317 .join("runners");
4318 let mut host = harness.host.clone();
4319 host.runner_root_override = Some(
4320 LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
4321 .expect("a local absolute host root"),
4322 );
4323 harness.store.put_host(&host).unwrap();
4324
4325 let failure = harness
4326 .launch_result()
4327 .await
4328 .expect_err("a root whose parents are missing cannot hold a runner");
4329 assert!(
4330 matches!(failure.reason, FailureReason::Other(_)),
4331 "{failure:?}"
4332 );
4333
4334 let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
4335 let refusal = refusals
4336 .first()
4337 .expect("the refusal reached the one surface that can hold it");
4338 assert_eq!(refusal.policy, harness.policy.id.to_string());
4339 assert_eq!(refusal.kind, "missing_parents");
4340 assert!(
4341 refusal.root.contains("runners") && refusal.detail.contains("runners"),
4342 "the directory must be named in full: {refusal:?}"
4343 );
4344
4345 harness.store.put_host(&harness.host).unwrap();
4348 harness.launch().await;
4349 assert!(
4350 runner_root_refusals(&harness.app_paths)
4351 .expect("readable")
4352 .is_empty(),
4353 "a successful placement clears that policy's record"
4354 );
4355
4356 clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
4357 .expect("cleanup");
4358 }
4359
4360 #[tokio::test]
4361 async fn a_launch_reports_how_long_each_step_took() {
4362 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4363 harness.ready().await;
4364 let started = harness.launch().await;
4365 let events = harness.events.events();
4366 let launched = events
4367 .iter()
4368 .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
4369 .expect("a successful launch reports its step timings");
4370 let starting = events
4371 .iter()
4372 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
4373 .unwrap();
4374 assert!(starting < launched, "timings follow the start: {events:?}");
4375 }
4376
4377 #[tokio::test]
4378 async fn a_failed_launch_still_reports_its_step_timings() {
4379 let harness = Harness::new(
4380 FakeGithubLifecycle::default().fail(true),
4381 Arc::new(PersistentDemand),
4382 );
4383 harness.ready().await;
4384 let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
4385 assert!(failed.is_err());
4386
4387 let timings = harness
4388 .events
4389 .events()
4390 .into_iter()
4391 .find_map(|event| match event {
4392 AttemptEvent::Launched {
4393 started: false,
4394 timings,
4395 ..
4396 } => Some(timings),
4397 _ => None,
4398 })
4399 .expect("a launch that failed at registration reports its timings");
4400 assert_eq!(timings.lock_wait, Duration::from_secs(90));
4401 assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
4402 assert!(timings.total() >= Duration::from_secs(90));
4403 }
4404
4405 #[cfg(target_os = "macos")]
4406 #[tokio::test]
4407 async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
4408 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4409 harness.ready().await;
4410
4411 let old_root = harness._root.path().join("old-root");
4413 let old_runtime = old_root.join("abandoned");
4414 fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
4415 fs::create_dir_all(&old_runtime).unwrap();
4416 let old = RunnerAttempt::allocate(
4417 AttemptId::new_random(),
4418 harness.policy.id,
4419 &old_runtime,
4420 harness.clock.now(),
4421 );
4422 harness.store.record_attempt(&old).unwrap();
4423 let kept = harness.host_root().join(PACKAGE_SEED_DIR);
4424 fs::create_dir_all(kept.join("2.336.0")).unwrap();
4425
4426 harness.launch().await;
4427
4428 assert!(
4429 !old_root.join(PACKAGE_SEED_DIR).exists(),
4430 "the abandoned root's seed is removed"
4431 );
4432 assert!(old_runtime.exists(), "only the seed is touched");
4433 assert!(kept.exists(), "the host root's own seed is kept");
4434 }
4435
4436 #[tokio::test]
4437 async fn a_job_walks_every_state_and_cleans_every_artifact() {
4438 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4439 harness.ready().await;
4440 let started = harness.launch().await;
4441 assert_eq!(started.state(), AttemptState::Starting);
4442 assert_eq!(read_runner_id(started.runtime_path()), Some(73));
4443
4444 harness
4445 .github
4446 .observe(GithubRunnerObservation::Registered { busy: false });
4447 harness.launcher.supervise(&harness.policy).await.unwrap();
4448 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4449
4450 harness
4451 .github
4452 .observe(GithubRunnerObservation::Registered { busy: true });
4453 harness.launcher.supervise(&harness.policy).await.unwrap();
4454 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
4455
4456 harness.processes.finish_successfully();
4457 harness
4458 .github
4459 .observe(GithubRunnerObservation::NotRegistered);
4460 harness.launcher.supervise(&harness.policy).await.unwrap();
4461 let cleaned = harness.only_attempt();
4462 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4463 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
4464 assert!(!started.runtime_path().exists());
4465 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4466 assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
4467
4468 let states: Vec<_> = harness
4469 .events
4470 .events()
4471 .into_iter()
4472 .filter_map(|event| match event {
4473 AttemptEvent::State { state, .. } => Some(state),
4474 _ => None,
4475 })
4476 .collect();
4477 assert_eq!(
4478 states,
4479 vec![
4480 AttemptState::Allocated,
4481 AttemptState::JitReceived,
4482 AttemptState::Starting,
4483 AttemptState::Idle,
4484 AttemptState::Busy,
4485 AttemptState::Finished,
4486 AttemptState::Cleaned,
4487 ]
4488 );
4489 }
4490
4491 #[tokio::test]
4492 async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
4493 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4494 harness.ready().await;
4495 let started = harness.launch().await;
4496 let runtime = started.runtime_path().to_path_buf();
4497
4498 harness
4499 .github
4500 .observe(GithubRunnerObservation::Registered { busy: true });
4501 harness.launcher.supervise(&harness.policy).await.unwrap();
4502 harness.processes.finish_successfully();
4503 harness
4504 .github
4505 .observe(GithubRunnerObservation::NotRegistered);
4506 harness.launcher.supervise(&harness.policy).await.unwrap();
4507
4508 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
4509 assert!(!runtime.exists());
4510 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4511
4512 let residue = runtime.join("_work").join("late-node-process");
4516 fs::create_dir_all(&residue).unwrap();
4517 fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
4518
4519 harness.launcher.supervise(&harness.policy).await.unwrap();
4520
4521 assert!(!runtime.exists(), "late ephemeral residue is reaped");
4522 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
4523 assert_eq!(
4524 harness.packages.releases.load(Ordering::SeqCst),
4525 1,
4526 "reaping residue does not release the package lease twice"
4527 );
4528 }
4529
4530 #[tokio::test]
4531 async fn locked_late_residue_never_blocks_startup_recovery() {
4532 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4533 harness.ready().await;
4534 let started = harness.launch().await;
4535 let runtime = started.runtime_path().to_path_buf();
4536
4537 harness
4538 .github
4539 .observe(GithubRunnerObservation::Registered { busy: true });
4540 harness.launcher.supervise(&harness.policy).await.unwrap();
4541 harness.processes.finish_successfully();
4542 harness
4543 .github
4544 .observe(GithubRunnerObservation::NotRegistered);
4545 harness.launcher.supervise(&harness.policy).await.unwrap();
4546 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
4547
4548 let held = runtime.join("_work").join("late-node-process");
4549 fs::create_dir_all(&held).unwrap();
4550 fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
4551 let Some(block) = BlockedDeletion::inject(&held) else {
4552 eprintln!(
4553 "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
4554 );
4555 return;
4556 };
4557
4558 let restarted = harness.restart();
4559 restarted
4560 .recover_startup(std::slice::from_ref(&harness.policy))
4561 .await
4562 .expect("late residue cannot take the daemon offline");
4563 assert!(runtime.exists(), "the locked residue remains for a retry");
4564 assert!(
4565 harness
4566 .reconcile_events
4567 .events()
4568 .iter()
4569 .any(|event| matches!(
4570 event,
4571 LifecycleEvent::AttemptCleanFailed {
4572 attempt,
4573 reason: "late_ephemeral_workspace_could_not_be_removed",
4574 ..
4575 } if *attempt == started.id
4576 )),
4577 "the non-blocking cleanup failure remains visible"
4578 );
4579
4580 block.release();
4581 restarted
4582 .supervise(&harness.policy)
4583 .await
4584 .expect("the ordinary retry succeeds after the lock is released");
4585 assert!(!runtime.exists(), "the retry removes the late residue");
4586 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
4587 assert_eq!(
4588 harness.packages.releases.load(Ordering::SeqCst),
4589 1,
4590 "retrying residue never releases the package lease twice"
4591 );
4592 }
4593
4594 #[tokio::test]
4595 async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
4596 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4597 harness.ready().await;
4598
4599 let terminal = harness.launch().await;
4603 let terminal_runtime = terminal.runtime_path().to_path_buf();
4604 harness.conclude(terminal.id);
4605 let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
4606 else {
4607 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
4608 return;
4609 };
4610
4611 let cleaned = harness.launch().await;
4615 let cleaned_runtime = cleaned.runtime_path().to_path_buf();
4616 harness.conclude(cleaned.id);
4617 harness
4618 .launcher
4619 .clean(cleaned.id)
4620 .await
4621 .expect("the second attempt initially cleans");
4622 let Some(cleaned_block) =
4623 BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
4624 else {
4625 terminal_block.release();
4626 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
4627 return;
4628 };
4629
4630 let restarted = harness.restart();
4631 for _ in 0..2 {
4632 restarted
4633 .recover_startup(std::slice::from_ref(&harness.policy))
4634 .await
4635 .expect("repeated recovery remains ready while both residues are locked");
4636 }
4637 assert_ne!(
4638 harness.attempt(terminal.id).state(),
4639 AttemptState::Cleaned,
4640 "the journal does not claim the terminal workspace was removed"
4641 );
4642 assert_eq!(
4643 harness.attempt(cleaned.id).state(),
4644 AttemptState::Cleaned,
4645 "late residue does not undo an already durable cleaned transition"
4646 );
4647 assert!(terminal_runtime.exists());
4648 assert!(cleaned_runtime.exists());
4649 assert_eq!(
4650 harness.packages.releases.load(Ordering::SeqCst),
4651 1,
4652 "repeated recovery neither loses nor duplicates package leases"
4653 );
4654
4655 terminal_block.release();
4656 cleaned_block.release();
4657 restarted
4658 .supervise(&harness.policy)
4659 .await
4660 .expect("one ordinary pass clears both residues after their locks disappear");
4661 assert!(!terminal_runtime.exists());
4662 assert!(!cleaned_runtime.exists());
4663 assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
4664 assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
4665 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
4666 }
4667
4668 #[tokio::test]
4669 async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
4670 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4671 harness.ready().await;
4672 let attempt = harness.launch().await;
4673 let runtime = attempt.runtime_path().to_path_buf();
4674 harness.conclude(attempt.id);
4675 fs::remove_dir_all(&runtime).unwrap();
4676
4677 harness
4678 .restart()
4679 .recover_startup(std::slice::from_ref(&harness.policy))
4680 .await
4681 .expect("an already absent disposable runtime is successful cleanup");
4682
4683 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
4684 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4685 }
4686
4687 #[tokio::test]
4688 async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
4689 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4690 harness.ready().await;
4691 let attempt = harness.launch().await;
4692 let runtime = attempt.runtime_path().to_path_buf();
4693 harness.conclude(attempt.id);
4694 harness.packages.fail_releases(1);
4695
4696 let restarted = harness.restart();
4697 let failure = restarted
4698 .recover_startup(std::slice::from_ref(&harness.policy))
4699 .await
4700 .expect_err("a package-accounting failure is not safe to downgrade to residue");
4701 assert!(
4702 failure
4703 .reason()
4704 .to_string()
4705 .contains("package lease could not be released")
4706 );
4707 assert!(
4708 !runtime.exists(),
4709 "workspace removal completed before release failed"
4710 );
4711 assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
4712
4713 restarted
4714 .recover_startup(std::slice::from_ref(&harness.policy))
4715 .await
4716 .expect("the same journal entry retries safely after the transient failure");
4717 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
4718 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4719 }
4720
4721 #[tokio::test]
4722 async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
4723 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
4724 .with_persistent_workspace(1);
4725 harness.ready().await;
4726 let attempt = harness.launch().await;
4727 let retained = attempt.runtime_path().join("_work").join("checkout-marker");
4728 fs::create_dir_all(retained.parent().unwrap()).unwrap();
4729 fs::write(&retained, b"persistent checkout").unwrap();
4730 harness.cleanup_retaining_work(attempt.id).await;
4731
4732 harness
4733 .restart()
4734 .recover_startup(std::slice::from_ref(&harness.policy))
4735 .await
4736 .expect("a cleaned persistent slot is already reconciled");
4737
4738 assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
4739 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4740 }
4741
4742 #[tokio::test]
4743 async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
4744 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4745 harness.ready().await;
4746 let started = harness.launch().await;
4747 harness
4748 .github
4749 .observe(GithubRunnerObservation::Registered { busy: false });
4750 harness.launcher.supervise(&harness.policy).await.unwrap();
4751 harness.clock.advance_secs(11);
4752 harness.processes.set_alive(false);
4753 harness
4754 .github
4755 .observe(GithubRunnerObservation::NotRegistered);
4756 harness.launcher.supervise(&harness.policy).await.unwrap();
4757
4758 let cleaned = harness.only_attempt();
4759 assert!(cleaned.outcome().unwrap().is_idle_exit());
4760 assert!(!cleaned.outcome().unwrap().is_failure());
4761 assert!(!started.runtime_path().exists());
4762 assert!(
4763 harness
4764 .reconcile_events
4765 .events()
4766 .iter()
4767 .any(|event| matches!(
4768 event,
4769 LifecycleEvent::AttemptCleaned {
4770 outcome: OutcomeKind::IdleExit,
4771 ..
4772 }
4773 ))
4774 );
4775 assert!(!harness.events.events().iter().any(|event| matches!(
4776 event,
4777 AttemptEvent::Concluded {
4778 outcome: OutcomeKind::Failed,
4779 ..
4780 }
4781 )));
4782 }
4783
4784 #[tokio::test]
4785 async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
4786 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4787 harness.processes.fail_spawns(2);
4788 harness.ready().await;
4789 let attempt = harness.launch().await;
4790 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
4791 assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
4792 let names: Vec<_> = fs::read_dir(attempt.runtime_path())
4793 .unwrap()
4794 .map(|entry| entry.unwrap().file_name())
4795 .collect();
4796 assert!(
4797 names.iter().all(|name| {
4798 !name
4799 .to_string_lossy()
4800 .starts_with(RestrictiveHandoff::NAME_PREFIX)
4801 }),
4802 "JIT artifact survived: {names:?}"
4803 );
4804 assert_eq!(
4805 *harness.delay.0.lock().unwrap(),
4806 vec![Duration::from_millis(10), Duration::from_millis(20)]
4807 );
4808 }
4809
4810 #[tokio::test]
4811 async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
4812 let gone = Harness::new(
4813 FakeGithubLifecycle::default().fail(false),
4814 Arc::new(FakeDemand::answering([false])),
4815 );
4816 gone.ready().await;
4817 assert!(gone.launch_result().await.is_err());
4818 assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
4819 assert!(gone.delay.0.lock().unwrap().is_empty());
4820
4821 let forbidden = Harness::new(
4822 FakeGithubLifecycle::default().fail(true),
4823 Arc::new(PersistentDemand),
4824 );
4825 forbidden.ready().await;
4826 assert!(forbidden.launch_result().await.is_err());
4827 assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
4828 assert!(forbidden.delay.0.lock().unwrap().is_empty());
4829 assert!(matches!(
4830 forbidden.only_attempt().outcome(),
4831 Some(AttemptOutcome::Failed {
4832 reason: FailureReason::Other(action)
4833 }) if action.contains("403")
4834 ));
4835
4836 let transient = Harness::new(
4837 FakeGithubLifecycle::default().fail(false).fail(false),
4838 Arc::new(PersistentDemand),
4839 );
4840 transient.ready().await;
4841 transient.launch().await;
4842 assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
4843 assert_eq!(
4844 *transient.delay.0.lock().unwrap(),
4845 vec![Duration::from_millis(10), Duration::from_millis(20)]
4846 );
4847 }
4848
4849 #[test]
4857 fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
4858 const MAX_PATH: usize = 260;
4859 let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
4861 let repo = "GitHub-Runner-Scaler-UI";
4865 let deepest = format!(
4866 r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
4867 "0".repeat(40)
4868 );
4869
4870 let name = workspace_name(AttemptId::new_random());
4871 assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
4872 assert!(
4873 name.chars().all(|c| c.is_ascii_hexdigit()),
4874 "a directory name must not carry the identifier's dashes: {name}"
4875 );
4876
4877 let full = format!(r"{root}\{name}\{deepest}");
4878 assert!(
4879 full.len() < MAX_PATH,
4880 "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
4881 full.len()
4882 );
4883
4884 let old = format!(
4887 r"{root}\{}\{}\{deepest}",
4888 PolicyId::new_random(),
4889 AttemptId::new_random()
4890 );
4891 assert!(
4892 old.len() > MAX_PATH,
4893 "the old layout is supposed to be the thing that did not fit: {} characters",
4894 old.len()
4895 );
4896 }
4897
4898 #[tokio::test]
4899 async fn two_attempts_never_share_a_workspace_even_after_failure() {
4900 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4901 harness.ready().await;
4902 let first = harness.launch().await;
4903 fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
4904 harness
4905 .github
4906 .observe(GithubRunnerObservation::Registered { busy: false });
4907 harness.launcher.supervise(&harness.policy).await.unwrap();
4908 harness.clock.advance_secs(11);
4909 harness.processes.set_alive(false);
4910 harness
4911 .github
4912 .observe(GithubRunnerObservation::NotRegistered);
4913 harness.launcher.supervise(&harness.policy).await.unwrap();
4914 assert!(!first.runtime_path().exists());
4915
4916 let second = harness.launch().await;
4917 assert_ne!(first.runtime_path(), second.runtime_path());
4918 assert!(!second.runtime_path().join("hostile-leftover").exists());
4919
4920 fs::write(
4921 second.runtime_path().join("hostile-on-failure"),
4922 b"second job",
4923 )
4924 .unwrap();
4925 harness.processes.set_alive(false);
4926 harness
4927 .github
4928 .observe(GithubRunnerObservation::NotRegistered);
4929 harness.launcher.supervise(&harness.policy).await.unwrap();
4930 assert!(
4931 !second.runtime_path().exists(),
4932 "failed workspace was retained"
4933 );
4934 }
4935
4936 #[tokio::test]
4937 async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
4938 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4939 harness.ready().await;
4940 let attempt = harness.launch().await;
4941
4942 harness
4946 .github
4947 .observe(GithubRunnerObservation::Registered { busy: false });
4948 harness.launcher.supervise(&harness.policy).await.unwrap();
4949 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4950
4951 harness.clock.advance_secs(9);
4954 harness
4955 .github
4956 .observe(GithubRunnerObservation::Registered { busy: false });
4957 let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
4958 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4959 assert!(none_yet.is_empty());
4960 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
4961
4962 harness.clock.advance_secs(1);
4964 harness
4965 .github
4966 .observe(GithubRunnerObservation::Registered { busy: false });
4967 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4968
4969 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4970 assert_eq!(
4971 concluded.outcome(),
4972 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4973 "a surplus runner did not fail; recording one as a failure sends an operator \
4974 hunting a fault that does not exist"
4975 );
4976 assert_eq!(concluded.state(), AttemptState::Cleaned);
4977 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
4978 assert!(!attempt.runtime_path().exists());
4979
4980 assert_eq!(
4983 *harness.github.deregistrations.lock().unwrap(),
4984 vec![73],
4985 "the attempt's own runner id, deleted exactly once"
4986 );
4987
4988 assert!(
4991 replacements.is_empty(),
4992 "a surplus exit must not request a replacement"
4993 );
4994 }
4995
4996 #[tokio::test]
4997 async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
4998 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5002 harness.ready().await;
5003 let attempt = harness.launch().await;
5004 harness
5005 .github
5006 .observe(GithubRunnerObservation::Registered { busy: false });
5007 harness.launcher.supervise(&harness.policy).await.unwrap();
5008
5009 harness
5010 .github
5011 .deregistration_fails
5012 .store(true, Ordering::SeqCst);
5013 harness.clock.advance_secs(11);
5014 harness
5015 .github
5016 .observe(GithubRunnerObservation::Registered { busy: false });
5017 harness.launcher.supervise(&harness.policy).await.unwrap();
5018
5019 assert_eq!(
5020 *harness.github.deregistrations.lock().unwrap(),
5021 vec![73],
5022 "the delete was attempted"
5023 );
5024 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
5025 assert_eq!(
5026 concluded.outcome(),
5027 Some(&AttemptOutcome::ExitedIdleWithoutWork),
5028 "the attempt concluded anyway"
5029 );
5030 assert_eq!(concluded.state(), AttemptState::Cleaned);
5031 assert!(!attempt.runtime_path().exists());
5032 }
5033
5034 #[tokio::test]
5035 async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
5036 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5037 harness.ready().await;
5038 let first = harness.launch().await;
5039 harness.processes.set_alive(false);
5040 harness
5041 .github
5042 .observe(GithubRunnerObservation::NotRegistered);
5043 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
5044 let failed = harness.store.attempt(first.id).unwrap().unwrap();
5045 assert!(matches!(
5046 failed.outcome(),
5047 Some(AttemptOutcome::Failed {
5048 reason: FailureReason::ProcessExitedUnexpectedly
5049 })
5050 ));
5051 assert!(!first.runtime_path().exists());
5052
5053 assert_eq!(
5054 replacements,
5055 vec![ReplacementIntent {
5056 policy: harness.policy.id,
5057 previous_attempt: first.id,
5058 operation: "exit_before_acceptance_replacement",
5059 }]
5060 );
5061 assert_eq!(harness.store.attempts().unwrap().len(), 1);
5062 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
5063 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
5064 assert!(harness.delay.0.lock().unwrap().is_empty());
5065 }
5066
5067 #[tokio::test]
5068 async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
5069 let harness = Harness::new(
5070 FakeGithubLifecycle::default(),
5071 Arc::new(FakeDemand::answering([false])),
5072 );
5073 let id = AttemptId::new_random();
5074 let runtime = harness
5075 .launcher
5076 .app_paths
5077 .runtime_dir()
5078 .join(harness.policy.id.to_string())
5079 .join(id.to_string());
5080 fs::create_dir_all(&runtime).unwrap();
5081 let mut attempt =
5082 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
5083 attempt.jit_received(harness.clock.now()).unwrap();
5084 harness.store.record_attempt(&attempt).unwrap();
5085 harness.clock.advance_secs(11);
5086 let replacements = harness
5087 .launcher
5088 .recover_startup(std::slice::from_ref(&harness.policy))
5089 .await
5090 .unwrap();
5091 assert_eq!(
5092 replacements,
5093 vec![ReplacementIntent {
5094 policy: harness.policy.id,
5095 previous_attempt: id,
5096 operation: "jit_expired_replacement",
5097 }]
5098 );
5099
5100 let cleaned = harness.store.attempt(id).unwrap().unwrap();
5101 assert_eq!(cleaned.state(), AttemptState::Cleaned);
5102 assert!(matches!(
5103 cleaned.outcome(),
5104 Some(AttemptOutcome::Failed {
5105 reason: FailureReason::JitExpired
5106 })
5107 ));
5108 assert!(!runtime.exists());
5109 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
5110 assert!(harness.delay.0.lock().unwrap().is_empty());
5111 }
5112
5113 #[tokio::test]
5114 async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
5115 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5116 let id = AttemptId::new_random();
5117 let runtime = harness
5118 .launcher
5119 .app_paths
5120 .runtime_dir()
5121 .join("expired-with-demand");
5122 fs::create_dir_all(&runtime).unwrap();
5123 let mut attempt =
5124 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
5125 attempt.jit_received(harness.clock.now()).unwrap();
5126 harness.store.record_attempt(&attempt).unwrap();
5127 harness.clock.advance_secs(11);
5128 let replacements = harness
5129 .launcher
5130 .recover_startup(std::slice::from_ref(&harness.policy))
5131 .await
5132 .unwrap();
5133
5134 let attempts = harness.store.attempts().unwrap();
5135 assert_eq!(attempts.len(), 1);
5136 assert_eq!(
5137 attempts
5138 .iter()
5139 .find(|attempt| attempt.id == id)
5140 .unwrap()
5141 .state(),
5142 AttemptState::Cleaned
5143 );
5144 assert_eq!(
5145 replacements,
5146 vec![ReplacementIntent {
5147 policy: harness.policy.id,
5148 previous_attempt: id,
5149 operation: "jit_expired_replacement",
5150 }]
5151 );
5152 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
5153 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
5154 assert!(harness.delay.0.lock().unwrap().is_empty());
5155 }
5156
5157 #[tokio::test]
5158 async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
5159 let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5160 persistent.packages.fail_materializations(2);
5161 persistent.ready().await;
5162 persistent.launch().await;
5163 assert_eq!(
5164 persistent.packages.materializations.load(Ordering::SeqCst),
5165 3
5166 );
5167 assert_eq!(
5168 *persistent.delay.0.lock().unwrap(),
5169 vec![Duration::from_millis(10), Duration::from_millis(20)]
5170 );
5171
5172 let gone_before_wait = Harness::new(
5173 FakeGithubLifecycle::default(),
5174 Arc::new(FakeDemand::answering([false])),
5175 );
5176 gone_before_wait.packages.fail_materializations(3);
5177 gone_before_wait.ready().await;
5178 assert!(gone_before_wait.launch_result().await.is_err());
5179 assert_eq!(
5180 gone_before_wait
5181 .packages
5182 .materializations
5183 .load(Ordering::SeqCst),
5184 1
5185 );
5186 assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
5187
5188 let gone_during_wait = Harness::new(
5189 FakeGithubLifecycle::default(),
5190 Arc::new(FakeDemand::answering([true, false])),
5191 );
5192 gone_during_wait.packages.fail_materializations(3);
5193 gone_during_wait.ready().await;
5194 assert!(gone_during_wait.launch_result().await.is_err());
5195 assert_eq!(
5196 gone_during_wait
5197 .packages
5198 .materializations
5199 .load(Ordering::SeqCst),
5200 1
5201 );
5202 assert_eq!(
5203 *gone_during_wait.delay.0.lock().unwrap(),
5204 vec![Duration::from_millis(10)]
5205 );
5206 }
5207
5208 #[tokio::test]
5209 async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
5210 let harness = Harness::new(
5211 FakeGithubLifecycle::default(),
5212 Arc::new(FakeDemand::answering([true, false])),
5213 );
5214 harness.ready().await;
5215 let first = harness.launch().await;
5216 harness.processes.set_alive(false);
5217 harness
5218 .github
5219 .observe(GithubRunnerObservation::NotRegistered);
5220 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
5221
5222 assert_eq!(harness.store.attempts().unwrap().len(), 1);
5223 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
5224 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
5225 assert!(harness.delay.0.lock().unwrap().is_empty());
5226 assert_eq!(
5227 replacements,
5228 vec![ReplacementIntent {
5229 policy: harness.policy.id,
5230 previous_attempt: first.id,
5231 operation: "exit_before_acceptance_replacement",
5232 }]
5233 );
5234 assert_eq!(
5235 harness.store.attempt(first.id).unwrap().unwrap().state(),
5236 AttemptState::Cleaned
5237 );
5238 }
5239
5240 #[tokio::test]
5241 async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
5242 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5243 let before = harness.launch_result().await;
5244 assert!(before.is_err());
5245 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
5246
5247 let id = AttemptId::new_random();
5248 let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
5249 fs::create_dir_all(&runtime).unwrap();
5250 let mut attempt =
5251 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
5252 attempt.jit_received(harness.clock.now()).unwrap();
5253 attempt.started(4242, harness.clock.now()).unwrap();
5254 harness.store.record_attempt(&attempt).unwrap();
5255 harness.processes.set_alive(true);
5256 harness
5257 .github
5258 .observe(GithubRunnerObservation::NotRegistered);
5259 let replacements = harness
5260 .launcher
5261 .recover_startup(std::slice::from_ref(&harness.policy))
5262 .await
5263 .unwrap();
5264 assert!(replacements.is_empty());
5265 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
5266 assert!(
5267 harness
5268 .events
5269 .events()
5270 .contains(&AttemptEvent::Adopted { attempt: id })
5271 );
5272 }
5273
5274 #[tokio::test]
5275 async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
5276 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5277 let id = AttemptId::new_random();
5278 let runtime = harness
5279 .launcher
5280 .app_paths
5281 .runtime_dir()
5282 .join("spawn-before-starting");
5283 fs::create_dir_all(&runtime).unwrap();
5284 let mut attempt =
5285 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
5286 attempt.jit_received(harness.clock.now()).unwrap();
5287 harness.store.record_attempt(&attempt).unwrap();
5288 harness.processes.set_alive(true);
5289 harness
5290 .github
5291 .observe(GithubRunnerObservation::Registered { busy: true });
5292
5293 let replacements = harness
5294 .launcher
5295 .recover_startup(std::slice::from_ref(&harness.policy))
5296 .await
5297 .unwrap();
5298 assert!(replacements.is_empty());
5299 let recovered = harness.store.attempt(id).unwrap().unwrap();
5300 assert_eq!(recovered.state(), AttemptState::Busy);
5301 assert_eq!(recovered.process_id(), Some(4242));
5302 assert_eq!(recovered.github_runner_id(), Some(73));
5303 let events = harness.events.events();
5304 let starting = events
5305 .iter()
5306 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
5307 .unwrap();
5308 let busy = events
5309 .iter()
5310 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
5311 .unwrap();
5312 assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
5313
5314 harness.processes.finish_successfully();
5315 harness
5316 .github
5317 .observe(GithubRunnerObservation::NotRegistered);
5318 assert!(
5319 harness
5320 .launcher
5321 .supervise(&harness.policy)
5322 .await
5323 .unwrap()
5324 .is_empty()
5325 );
5326 let cleaned = harness.store.attempt(id).unwrap().unwrap();
5327 assert_eq!(cleaned.state(), AttemptState::Cleaned);
5328 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
5329 assert!(!runtime.exists());
5330 }
5331
5332 #[tokio::test]
5333 async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
5334 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5335 harness.processes.fail_spawn_with_live_child();
5336 harness.ready().await;
5337 assert!(harness.launch_result().await.is_err());
5338
5339 let attempt = harness.only_attempt();
5340 assert_eq!(attempt.state(), AttemptState::Starting);
5341 assert_eq!(attempt.process_id(), Some(4242));
5342 assert!(attempt.outcome().is_none());
5343 assert!(attempt.state().counts_against_capacity());
5344 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
5345 assert!(harness.delay.0.lock().unwrap().is_empty());
5346
5347 harness.processes.set_alive(false);
5348 harness
5349 .github
5350 .observe(GithubRunnerObservation::NotRegistered);
5351 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
5352 assert_eq!(replacements.len(), 1);
5353 assert_eq!(
5354 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
5355 AttemptState::Cleaned
5356 );
5357 }
5358
5359 #[tokio::test]
5360 async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
5361 for sidecar_already_present in [false, true] {
5362 let harness = Harness::new(
5363 FakeGithubLifecycle::default(),
5364 Arc::new(FakeDemand::answering([false])),
5365 );
5366 let id = AttemptId::new_random();
5367 let runtime =
5368 harness
5369 .launcher
5370 .app_paths
5371 .runtime_dir()
5372 .join(if sidecar_already_present {
5373 "after-id-sidecar"
5374 } else {
5375 "before-id-sidecar"
5376 });
5377 fs::create_dir_all(&runtime).unwrap();
5378 let mut attempt =
5379 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
5380 if sidecar_already_present {
5381 write_runner_id(&runtime, 73).unwrap();
5382 attempt.jit_received(harness.clock.now()).unwrap();
5383 }
5384 harness.store.record_attempt(&attempt).unwrap();
5385 harness.processes.set_alive(true);
5386 harness
5387 .github
5388 .observe(GithubRunnerObservation::Registered { busy: false });
5389 harness
5390 .launcher
5391 .recover_startup(std::slice::from_ref(&harness.policy))
5392 .await
5393 .unwrap();
5394
5395 assert_eq!(read_runner_id(&runtime), Some(73));
5396 assert!(
5397 harness
5398 .store
5399 .attempt(id)
5400 .unwrap()
5401 .unwrap()
5402 .outcome()
5403 .is_none()
5404 );
5405 let events = harness.events.events();
5406 let recovered = events.iter().position(|event| {
5407 matches!(
5408 event,
5409 AttemptEvent::RemoteIdentityRecovered {
5410 attempt,
5411 runner_id: 73
5412 } if *attempt == id
5413 )
5414 });
5415 assert_eq!(recovered.is_some(), !sidecar_already_present);
5416 if let Some(recovered) = recovered {
5417 let adopted = events
5418 .iter()
5419 .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
5420 .unwrap();
5421 assert!(
5422 recovered < adopted,
5423 "identity was not durable before adoption: {events:?}"
5424 );
5425 }
5426 assert!(runtime.exists());
5427 }
5428 }
5429
5430 #[tokio::test]
5431 async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
5432 let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5433 let unknown_attempt = RunnerAttempt::allocate(
5434 AttemptId::new_random(),
5435 PolicyId::from_u128(0xfeed),
5436 unknown
5437 .launcher
5438 .app_paths
5439 .runtime_dir()
5440 .join("unknown-policy"),
5441 unknown.clock.now(),
5442 );
5443 unknown.store.record_attempt(&unknown_attempt).unwrap();
5444 let expired_id = AttemptId::new_random();
5445 let expired_runtime = unknown
5446 .launcher
5447 .app_paths
5448 .runtime_dir()
5449 .join("expired-beside-unknown");
5450 fs::create_dir_all(&expired_runtime).unwrap();
5451 let mut expired = RunnerAttempt::allocate(
5452 expired_id,
5453 unknown.policy.id,
5454 expired_runtime,
5455 unknown.clock.now(),
5456 );
5457 expired.jit_received(unknown.clock.now()).unwrap();
5458 unknown.store.record_attempt(&expired).unwrap();
5459 unknown.clock.advance_secs(11);
5460 assert!(matches!(
5461 unknown
5462 .launcher
5463 .recover_startup(std::slice::from_ref(&unknown.policy))
5464 .await,
5465 Err(LifecycleError::RecoveryIncomplete)
5466 ));
5467 assert!(unknown.launch_result().await.is_err());
5468 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
5469 let recovered_policy = fixtures::policy()
5470 .id(PolicyId::from_u128(0xfeed))
5471 .repository("octo/repo")
5472 .autoscale("home", 2)
5473 .active()
5474 .build();
5475 let pending = unknown
5476 .launcher
5477 .recover_startup(&[unknown.policy.clone(), recovered_policy])
5478 .await
5479 .unwrap();
5480 assert_eq!(
5481 pending,
5482 vec![ReplacementIntent {
5483 policy: unknown.policy.id,
5484 previous_attempt: expired_id,
5485 operation: "jit_expired_replacement",
5486 }]
5487 );
5488 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
5489
5490 let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5491 let id = AttemptId::new_random();
5492 let runtime = unreachable
5493 .launcher
5494 .app_paths
5495 .runtime_dir()
5496 .join("unreachable");
5497 fs::create_dir_all(&runtime).unwrap();
5498 unreachable
5499 .store
5500 .record_attempt(&RunnerAttempt::allocate(
5501 id,
5502 unreachable.policy.id,
5503 runtime,
5504 unreachable.clock.now(),
5505 ))
5506 .unwrap();
5507 unreachable
5508 .github
5509 .observe(GithubRunnerObservation::Unreachable);
5510 assert!(matches!(
5511 unreachable
5512 .launcher
5513 .recover_startup(std::slice::from_ref(&unreachable.policy))
5514 .await,
5515 Err(LifecycleError::RecoveryIncomplete)
5516 ));
5517 assert!(unreachable.launch_result().await.is_err());
5518 assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
5519 }
5520
5521 #[tokio::test]
5522 async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
5523 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5524 let id = AttemptId::new_random();
5525 let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
5526 fs::create_dir_all(&runtime).unwrap();
5527 let mut attempt =
5528 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
5529 attempt.jit_received(harness.clock.now()).unwrap();
5530 attempt.started(4242, harness.clock.now()).unwrap();
5531 attempt.assigned_job(73, harness.clock.now()).unwrap();
5532 harness.store.record_attempt(&attempt).unwrap();
5533 harness.processes.set_alive(false);
5534 harness
5535 .github
5536 .observe(GithubRunnerObservation::NotRegistered);
5537 harness
5538 .launcher
5539 .recover_startup(std::slice::from_ref(&harness.policy))
5540 .await
5541 .unwrap();
5542 let cleaned = harness.store.attempt(id).unwrap().unwrap();
5543 assert_eq!(cleaned.state(), AttemptState::Cleaned);
5544 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
5545 assert!(!runtime.exists());
5546 }
5547
5548 #[tokio::test]
5549 async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
5550 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5551 harness.ready().await;
5552 let id = AttemptId::new_random();
5553 let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
5554 fs::create_dir_all(&runtime).unwrap();
5555 let mut attempt =
5556 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
5557 attempt.jit_received(harness.clock.now()).unwrap();
5558 attempt.started(4242, harness.clock.now()).unwrap();
5559 harness.store.record_attempt(&attempt).unwrap();
5560 harness.clock.advance_secs(11);
5561 harness.processes.set_alive(true);
5562 harness
5563 .github
5564 .observe(GithubRunnerObservation::NotRegistered);
5565 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
5566 assert_eq!(
5567 replacements,
5568 vec![ReplacementIntent {
5569 policy: harness.policy.id,
5570 previous_attempt: id,
5571 operation: "registration_timeout_replacement",
5572 }]
5573 );
5574
5575 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
5576 assert!(!harness.processes.alive.load(Ordering::SeqCst));
5577 let actions = harness.processes.actions.lock().unwrap().clone();
5578 let intent = actions
5579 .iter()
5580 .position(|action| *action == "terminate_intent")
5581 .unwrap();
5582 let signal = actions
5583 .iter()
5584 .position(|action| *action == "terminate")
5585 .unwrap();
5586 assert!(
5587 intent < signal,
5588 "intent was not durable before signal: {actions:?}"
5589 );
5590
5591 let cleaned = harness.store.attempt(id).unwrap().unwrap();
5592 assert!(matches!(
5593 cleaned.outcome(),
5594 Some(AttemptOutcome::Failed {
5595 reason: FailureReason::TerminatedAfterRegistrationTimeout
5596 })
5597 ));
5598 let events = harness.events.events();
5599 let intent = events
5600 .iter()
5601 .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
5602 .unwrap();
5603 let stopped = events
5604 .iter()
5605 .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
5606 .unwrap();
5607 let concluded = events
5608 .iter()
5609 .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
5610 .unwrap();
5611 assert!(intent < stopped && stopped < concluded, "{events:?}");
5612 }
5613
5614 #[tokio::test]
5615 async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
5616 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5617 let id = AttemptId::new_random();
5618 let runtime = harness
5619 .launcher
5620 .app_paths
5621 .runtime_dir()
5622 .join("timeout-after-crash");
5623 fs::create_dir_all(&runtime).unwrap();
5624 let mut attempt =
5625 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
5626 attempt.jit_received(harness.clock.now()).unwrap();
5627 attempt.started(4242, harness.clock.now()).unwrap();
5628 harness.store.record_attempt(&attempt).unwrap();
5629 harness.processes.intent.store(true, Ordering::SeqCst);
5630 harness.processes.set_alive(false);
5631 harness
5632 .github
5633 .observe(GithubRunnerObservation::NotRegistered);
5634
5635 let replacements = harness
5636 .launcher
5637 .recover_startup(std::slice::from_ref(&harness.policy))
5638 .await
5639 .unwrap();
5640 assert_eq!(
5641 replacements,
5642 vec![ReplacementIntent {
5643 policy: harness.policy.id,
5644 previous_attempt: id,
5645 operation: "registration_timeout_replacement",
5646 }]
5647 );
5648 let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
5649 .await
5650 .unwrap();
5651 assert_eq!(consumed, replacements);
5652 assert!(
5653 RunnerLauncher::supervise(&harness.launcher, &harness.policy)
5654 .await
5655 .unwrap()
5656 .is_empty(),
5657 "startup replacement evidence must be consumed exactly once by e1"
5658 );
5659 assert!(matches!(
5660 harness.store.attempt(id).unwrap().unwrap().outcome(),
5661 Some(AttemptOutcome::Failed {
5662 reason: FailureReason::TerminatedAfterRegistrationTimeout
5663 })
5664 ));
5665 }
5666
5667 #[tokio::test]
5668 async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
5669 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5670 let id = AttemptId::new_random();
5671 let runtime = harness
5672 .launcher
5673 .app_paths
5674 .runtime_dir()
5675 .join("timeout-sync-failure");
5676 fs::create_dir_all(&runtime).unwrap();
5677 let mut attempt =
5678 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
5679 attempt.jit_received(harness.clock.now()).unwrap();
5680 attempt.started(4242, harness.clock.now()).unwrap();
5681 harness.store.record_attempt(&attempt).unwrap();
5682 harness.clock.advance_secs(11);
5683 harness.processes.set_alive(true);
5684 harness.processes.fail_intent();
5685 harness
5686 .github
5687 .observe(GithubRunnerObservation::NotRegistered);
5688
5689 assert!(
5690 harness
5691 .launcher
5692 .recover_startup(std::slice::from_ref(&harness.policy))
5693 .await
5694 .is_err()
5695 );
5696 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
5697 assert!(harness.processes.alive.load(Ordering::SeqCst));
5698 assert_eq!(
5699 harness.store.attempt(id).unwrap().unwrap().state(),
5700 AttemptState::Starting
5701 );
5702 assert!(!harness.events.events().iter().any(|event| matches!(
5703 event,
5704 AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
5705 if *attempt == id
5706 )));
5707 }
5708
5709 #[tokio::test]
5710 async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
5711 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5712 harness.ready().await;
5713 let attempt = harness.launch().await;
5714 harness
5715 .github
5716 .observe(GithubRunnerObservation::Registered { busy: false });
5717 harness.launcher.supervise(&harness.policy).await.unwrap();
5718 harness.clock.advance_secs(11);
5719 harness.processes.set_alive(false);
5720 harness
5721 .github
5722 .observe(GithubRunnerObservation::NotRegistered);
5723 harness.launcher.supervise(&harness.policy).await.unwrap();
5724 let diagnostic = fs::read_to_string(
5725 harness
5726 .launcher
5727 .diagnostics_root
5728 .join(format!("{}.log", attempt.id)),
5729 )
5730 .unwrap();
5731 assert!(diagnostic.contains("exited_idle_without_work"));
5732 assert!(!diagnostic.contains(JIT));
5733 assert!(!diagnostic.contains("ghp_"));
5734 assert!(!attempt.runtime_path().exists());
5735 }
5736
5737 #[test]
5738 fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
5739 let root = tempfile::tempdir().unwrap();
5740 let policy = fixtures::policy()
5741 .repository("octo/repo")
5742 .autoscale("home", 1)
5743 .active()
5744 .build();
5745 let runtime = root.path().join("successful");
5746 fs::create_dir_all(&runtime).unwrap();
5747 let processes = NativeProcesses::new();
5748 let config = EncodedJitConfig::new(JIT);
5749 let handoff =
5750 RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
5751 .unwrap();
5752 let mut child = native_inspection_spec()
5753 .spawn_runner_with_handoff(&handoff)
5754 .expect("native child starts");
5755 let pid = child.pid();
5756 handoff.delete().unwrap();
5757 let command_line = native_command_line(pid);
5758 assert!(
5759 !command_line.contains(JIT),
5760 "the encoded JIT configuration appeared in the native process listing"
5761 );
5762 assert_no_jit_file(&runtime);
5763 child
5764 .stop(Duration::from_secs(1))
5765 .expect("native child stops");
5766
5767 let failed_runtime = root.path().join("failed");
5768 fs::create_dir_all(&failed_runtime).unwrap();
5769 let failed = RunnerAttempt::allocate(
5770 AttemptId::new_random(),
5771 policy.id,
5772 &failed_runtime,
5773 FakeClock::default().now(),
5774 );
5775 assert!(
5776 processes
5777 .spawn(&failed, &EncodedJitConfig::new(JIT))
5778 .is_err(),
5779 "a runtime with no runner executable must fail"
5780 );
5781 assert_no_jit_file(&failed_runtime);
5782 processes
5783 .record_terminate_intent(&failed)
5784 .expect("the intent file and its directory entry are durably synced");
5785 assert_eq!(
5786 fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
5787 b"registration-timeout\n"
5788 );
5789 }
5790
5791 #[test]
5792 fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
5793 let root = tempfile::tempdir().unwrap();
5794 let policy = fixtures::policy()
5795 .repository("octo/repo")
5796 .autoscale("home", 1)
5797 .active()
5798 .build();
5799 let processes = NativeProcesses::new();
5800 processes.use_long_lived_test_listener();
5801 for (index, boundary) in [
5802 PostSpawnBoundary::HandoffDelete,
5803 PostSpawnBoundary::IdentitySerialize,
5804 PostSpawnBoundary::IdentityWrite,
5805 PostSpawnBoundary::ChildMapInsert,
5806 ]
5807 .into_iter()
5808 .enumerate()
5809 {
5810 let runtime = root.path().join(format!("post-spawn-{index}"));
5811 let bin = runtime.join("bin");
5812 fs::create_dir_all(&bin).unwrap();
5813 #[cfg(windows)]
5814 let listener = bin.join("Runner.Listener.exe");
5815 #[cfg(not(windows))]
5816 let listener = bin.join("Runner.Listener");
5817 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5818 let attempt = RunnerAttempt::allocate(
5819 AttemptId::new_random(),
5820 policy.id,
5821 &runtime,
5822 FakeClock::default().now(),
5823 );
5824 processes.fail_post_spawn_at(boundary);
5825 let failure = processes
5826 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5827 .expect_err("fault must cross the post-spawn cleanup path");
5828 assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
5829 assert!(
5830 !processes.is_alive(&attempt).unwrap(),
5831 "{boundary:?} left a child"
5832 );
5833 assert!(!NativeProcesses::identity_path(&attempt).exists());
5834 assert_no_jit_file(&runtime);
5835 }
5836 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5837
5838 let runtime = root.path().join("identity-and-stop-fail");
5839 let bin = runtime.join("bin");
5840 fs::create_dir_all(&bin).unwrap();
5841 #[cfg(windows)]
5842 let listener = bin.join("Runner.Listener.exe");
5843 #[cfg(not(windows))]
5844 let listener = bin.join("Runner.Listener");
5845 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5846 let attempt = RunnerAttempt::allocate(
5847 AttemptId::new_random(),
5848 policy.id,
5849 &runtime,
5850 FakeClock::default().now(),
5851 );
5852 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
5856 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
5857 processes.fail_next_post_spawn_stop();
5858 let failure = processes
5859 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5860 .expect_err("the identity boundary must fail closed");
5861 assert!(failure.live_pid.is_some());
5862 assert_long_lived_listener_ready(&processes, &attempt);
5863 assert!(processes.is_alive(&attempt).unwrap());
5864 assert!(!NativeProcesses::identity_path(&attempt).exists());
5865 assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
5866 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5867 processes.terminate(&attempt).unwrap();
5868
5869 let runtime = root.path().join("persistent-stop-and-identity-failures");
5870 let bin = runtime.join("bin");
5871 fs::create_dir_all(&bin).unwrap();
5872 #[cfg(windows)]
5873 let listener = bin.join("Runner.Listener.exe");
5874 #[cfg(not(windows))]
5875 let listener = bin.join("Runner.Listener");
5876 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5877 let mut unresolved = RunnerAttempt::allocate(
5878 AttemptId::new_random(),
5879 policy.id,
5880 &runtime,
5881 FakeClock::default().now(),
5882 );
5883 for _ in 0..3 {
5884 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
5885 }
5886 processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
5887 let failure = processes
5888 .spawn(&unresolved, &EncodedJitConfig::new(JIT))
5889 .expect_err("bounded cleanup must return even when every stop errors");
5890 let pid = failure
5891 .live_pid
5892 .expect("the owned child remains supervised in this invocation");
5893 assert!(matches!(failure.reason, FailureReason::Other(_)));
5894 assert_long_lived_listener_ready(&processes, &unresolved);
5895 unresolved.jit_received(FakeClock::default().now()).unwrap();
5896 unresolved.started(pid, FakeClock::default().now()).unwrap();
5897 let journal = SqliteStore::open_in_memory().unwrap();
5898 journal.record_attempt(&unresolved).unwrap();
5899 let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
5900 assert_eq!(recovered.process_id(), Some(pid));
5901 assert_eq!(recovered.state(), AttemptState::Starting);
5902 assert!(processes.is_alive(&unresolved).unwrap());
5903 assert!(!NativeProcesses::identity_path(&unresolved).exists());
5904 assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
5905 assert_eq!(
5906 fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
5907 pid.to_string(),
5908 "bounded cleanup must leave durable unresolved-process evidence before returning"
5909 );
5910 assert!(
5911 NativeProcesses::new().is_alive(&recovered).is_err(),
5912 "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
5913 );
5914 processes.terminate(&unresolved).unwrap();
5915
5916 let runtime = root.path().join("post-spawn-stop-failed");
5917 let bin = runtime.join("bin");
5918 fs::create_dir_all(&bin).unwrap();
5919 #[cfg(windows)]
5920 let listener = bin.join("Runner.Listener.exe");
5921 #[cfg(not(windows))]
5922 let listener = bin.join("Runner.Listener");
5923 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5924 let attempt = RunnerAttempt::allocate(
5925 AttemptId::new_random(),
5926 policy.id,
5927 &runtime,
5928 FakeClock::default().now(),
5929 );
5930 processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
5931 processes.fail_next_post_spawn_stop();
5932 let failure = processes
5933 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5934 .expect_err("the injected stop failure must preserve supervision");
5935 let live_pid = failure
5936 .live_pid
5937 .expect("live PID is returned to the journal");
5938 assert!(!failure.retryable);
5939 assert_long_lived_listener_ready(&processes, &attempt);
5940 assert!(NativeProcesses::identity_path(&attempt).is_file());
5941 assert_eq!(
5942 NativeProcesses::read_identity(&attempt)
5943 .unwrap()
5944 .unwrap()
5945 .pid(),
5946 live_pid
5947 );
5948 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5949 processes.terminate(&attempt).unwrap();
5950 }
5951
5952 #[test]
5953 #[ignore = "spawned only as the platform-stable native listener fixture"]
5954 fn long_lived_native_listener_helper() {
5955 let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
5956 .map(PathBuf::from)
5957 .expect("the parent supplies the readiness path");
5958 fs::write(ready, b"ready\n").expect("the listener publishes readiness");
5959 std::thread::sleep(Duration::from_secs(30));
5960 }
5961
5962 fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
5963 let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
5964 let deadline = std::time::Instant::now() + Duration::from_secs(5);
5965 loop {
5966 if ready.is_file() {
5967 assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
5968 return;
5969 }
5970 assert!(
5971 processes.is_alive(attempt).unwrap(),
5972 "the native listener exited before publishing readiness"
5973 );
5974 assert!(
5975 std::time::Instant::now() < deadline,
5976 "the native listener stayed alive but never published readiness"
5977 );
5978 std::thread::sleep(Duration::from_millis(10));
5979 }
5980 }
5981
5982 #[tokio::test]
5983 async fn every_production_launch_prunes_under_the_same_allocation_guard() {
5984 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5985 harness.ready().await;
5986 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
5987 harness.launch().await;
5988 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
5989 assert_eq!(
5990 *harness.packages.prune_currents.lock().unwrap(),
5991 vec![harness.packages.version.clone()],
5992 "the leased current version is an exclusion, never the prune target"
5993 );
5994 }
5995
5996 fn assert_no_jit_file(runtime: &Path) {
5997 for entry in fs::read_dir(runtime).unwrap() {
5998 let path = entry.unwrap().path();
5999 if path.is_file() {
6000 let bytes = fs::read(&path).unwrap();
6001 assert!(
6002 !bytes
6003 .windows(JIT.len())
6004 .any(|window| window == JIT.as_bytes()),
6005 "a JIT payload survived in a runtime file"
6006 );
6007 }
6008 }
6009 }
6010
6011 #[test]
6012 fn production_listener_command_uses_the_supported_jit_contract() {
6013 let runtime = Path::new("runtime");
6014 let spec = runner_listener_spec(PathBuf::from("Runner.Listener"), runtime);
6015 let arguments: Vec<_> = spec
6016 .arguments()
6017 .iter()
6018 .map(|argument| argument.to_string_lossy().into_owned())
6019 .collect();
6020
6021 assert_eq!(arguments, ["run"]);
6022 assert!(
6023 !arguments
6024 .iter()
6025 .any(|argument| argument == "--jit-config-file"),
6026 "the obsolete file option would be rejected by Runner.Listener 2.336.0"
6027 );
6028 }
6029
6030 #[cfg(windows)]
6031 fn native_inspection_spec() -> SpawnSpec {
6032 SpawnSpec::new("powershell.exe").args([
6033 "-NoProfile",
6034 "-NonInteractive",
6035 "-Command",
6036 "Start-Sleep -Seconds 30",
6037 ])
6038 }
6039
6040 #[cfg(unix)]
6041 fn native_inspection_spec() -> SpawnSpec {
6042 SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
6043 }
6044
6045 #[cfg(windows)]
6046 fn native_command_line(pid: u32) -> String {
6047 let output = std::process::Command::new("powershell.exe")
6048 .args([
6049 "-NoProfile",
6050 "-NonInteractive",
6051 "-Command",
6052 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
6053 ])
6054 .output()
6055 .expect("PowerShell can inspect the native child");
6056 assert!(output.status.success(), "native process inspection failed");
6057 String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
6058 }
6059
6060 #[cfg(target_os = "linux")]
6061 fn native_command_line(pid: u32) -> String {
6062 fs::read(format!("/proc/{pid}/cmdline"))
6063 .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
6064 .expect("/proc exposes the native child command line")
6065 }
6066
6067 #[cfg(target_os = "macos")]
6068 fn native_command_line(pid: u32) -> String {
6069 let output = std::process::Command::new("ps")
6070 .args(["-o", "command=", "-p", &pid.to_string()])
6071 .output()
6072 .expect("ps can inspect the native child");
6073 assert!(output.status.success(), "native process inspection failed");
6074 String::from_utf8(output.stdout).expect("the command line is UTF-8")
6075 }
6076
6077 #[tokio::test]
6080 async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
6081 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6082 .with_persistent_workspace(2);
6083 harness.github.watch_journal(Arc::clone(&harness.store));
6084 harness.ready().await;
6085
6086 let attempt = harness.launch().await;
6087
6088 assert_eq!(
6089 attempt.workspace(),
6090 AttemptWorkspace::persistent_slot(nz(1)),
6091 "the lowest free slot is leased"
6092 );
6093 assert_eq!(attempt.runtime_path(), harness.slot_path(1));
6094 assert!(attempt.holds_slot_lease());
6095 assert_eq!(
6097 harness.attempt(attempt.id).runtime_path(),
6098 harness.slot_path(1)
6099 );
6100
6101 let facts = harness.github.registration_facts();
6105 assert_eq!(facts.len(), 1);
6106 assert_eq!(
6107 facts[0].leased_slots,
6108 vec![1],
6109 "the lease was journalled first"
6110 );
6111 assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
6112 }
6113
6114 #[tokio::test]
6115 async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
6116 let harness = Harness::new(
6117 FakeGithubLifecycle::default().fail(true),
6118 Arc::new(PersistentDemand),
6119 )
6120 .with_persistent_workspace(2);
6121 harness.ready().await;
6122
6123 harness.launch_result().await.unwrap_err();
6125 let first = harness.store.attempts().unwrap().remove(0);
6126 assert_eq!(first.state(), AttemptState::Failed);
6127 assert!(
6128 !first.state().counts_against_capacity(),
6129 "a concluded attempt is invisible to host capacity"
6130 );
6131 assert!(
6132 first.holds_slot_lease(),
6133 "and still owns its directory, so its slot is not free"
6134 );
6135
6136 let second = harness.launch().await;
6137 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
6138 assert_eq!(second.runtime_path(), harness.slot_path(2));
6139 assert_eq!(
6140 harness
6141 .store
6142 .slot_leases_for_policy(harness.policy.id)
6143 .unwrap()
6144 .len(),
6145 2
6146 );
6147 }
6148
6149 #[tokio::test]
6150 async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
6151 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6152 .with_persistent_workspace(1);
6153 harness.ready().await;
6154
6155 let first = harness.launch().await;
6156 assert_eq!(first.runtime_path(), harness.slot_path(1));
6157
6158 let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
6160 fs::create_dir_all(&checkout).unwrap();
6161 fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
6162
6163 harness.cleanup_retaining_work(first.id).await;
6164
6165 let second = harness.launch().await;
6166 assert_ne!(second.id, first.id);
6167 assert_eq!(
6168 second.workspace(),
6169 AttemptWorkspace::persistent_slot(nz(1)),
6170 "a released slot is leased again rather than skipped"
6171 );
6172 assert_eq!(
6173 second.runtime_path(),
6174 first.runtime_path(),
6175 "the same slot is the same exact path"
6176 );
6177 assert_eq!(
6178 fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
6179 "from the first job",
6180 "the retained job workspace survived the second allocation"
6181 );
6182 assert!(harness.slot_path(1).join("runner-package").exists());
6184 }
6185
6186 #[tokio::test]
6187 async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
6188 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6189 .with_persistent_workspace(2);
6190 harness.ready().await;
6191
6192 let first = harness.launch().await;
6193 let second = harness.launch().await;
6194 assert_eq!(second.runtime_path(), harness.slot_path(2));
6195 let kept = harness
6196 .slot_path(2)
6197 .join(DEFAULT_WORK_FOLDER)
6198 .join("kept.txt");
6199 fs::create_dir_all(kept.parent().unwrap()).unwrap();
6200 fs::write(&kept, b"s2 was here").unwrap();
6201 harness.cleanup_retaining_work(second.id).await;
6202
6203 harness.policy.set_max_capacity(nz(1)).unwrap();
6205 let refusal = harness.launch_result().await.unwrap_err().to_string();
6206 assert!(
6207 refusal.contains("s1 to s1"),
6208 "the refusal names the ceiling it reached: {refusal}"
6209 );
6210 assert!(
6211 harness.slot_path(2).exists() && kept.exists(),
6212 "lowering capacity deletes nothing; the higher slot is merely unusable"
6213 );
6214
6215 harness.policy.set_max_capacity(nz(2)).unwrap();
6217 let third = harness.launch().await;
6218 assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
6219 assert_eq!(third.runtime_path(), harness.slot_path(2));
6220 assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
6221 assert!(first.holds_slot_lease(), "s1 was never disturbed");
6222 }
6223
6224 #[tokio::test]
6225 async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
6226 for policy in [
6227 fixtures::policy()
6228 .organization("octo")
6229 .autoscale("home", 2)
6230 .active()
6231 .build(),
6232 fixtures::policy()
6233 .repository("octo/repo")
6234 .autoscale("home", 2)
6235 .active()
6236 .build(),
6237 ] {
6238 let mut harness =
6239 Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6240 .with_host_runner_root();
6241 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
6242 harness.policy = policy;
6243 harness.ready().await;
6244
6245 let attempt = harness.launch().await;
6246 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
6247 assert_eq!(attempt.workspace().slot_number(), None);
6248 assert!(!attempt.holds_slot_lease());
6249 assert_eq!(
6250 attempt.runtime_path().parent().unwrap(),
6251 harness.host_root(),
6252 "a disposable attempt is a child of the host root, never of a slot"
6253 );
6254 assert!(
6255 harness
6256 .store
6257 .slot_leases_for_policy(harness.policy.id)
6258 .unwrap()
6259 .is_empty()
6260 );
6261 }
6262 }
6263
6264 #[tokio::test]
6265 async fn two_concurrent_allocations_never_share_a_slot() {
6266 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6267 .with_persistent_workspace(2);
6268 harness.github.watch_journal(Arc::clone(&harness.store));
6269 harness.ready().await;
6270
6271 let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
6275 let first = first.unwrap();
6276 let second = second.unwrap();
6277
6278 let slots: BTreeSet<u16> = [&first, &second]
6279 .iter()
6280 .map(|attempt| {
6281 attempt
6282 .workspace()
6283 .slot_number()
6284 .expect("a persistent attempt leases a slot")
6285 })
6286 .collect();
6287 assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
6288 assert_ne!(first.runtime_path(), second.runtime_path());
6289 assert_eq!(
6290 harness
6291 .store
6292 .slot_leases_for_policy(harness.policy.id)
6293 .unwrap()
6294 .len(),
6295 2
6296 );
6297
6298 let facts = harness.github.registration_facts();
6303 assert_eq!(facts.len(), 2);
6304 for fact in facts {
6305 let attempt = [&first, &second]
6306 .into_iter()
6307 .find(|attempt| runner_name(attempt.id) == fact.runner_name)
6308 .expect("every registration belongs to one of the two attempts");
6309 let slot = attempt
6310 .workspace()
6311 .slot_number()
6312 .expect("a persistent attempt leases a slot");
6313 assert!(
6314 fact.leased_slots.contains(&slot),
6315 "a JIT request never precedes its own lease: s{slot} not in {:?}",
6316 fact.leased_slots
6317 );
6318 assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
6319 }
6320 }
6321
6322 #[tokio::test]
6323 async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
6324 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6325 .with_persistent_workspace(2);
6326 harness.ready().await;
6327 let first = harness.launch().await;
6328
6329 let clash = RunnerAttempt::allocate_in(
6333 AttemptId::new_random(),
6334 harness.policy.id,
6335 first.runtime_path(),
6336 AttemptWorkspace::persistent_slot(nz(1)),
6337 harness.clock.now(),
6338 );
6339 assert!(matches!(
6340 harness.store.record_attempt(&clash).unwrap_err(),
6341 StoreError::SlotAlreadyLeased { slot: 1, .. }
6342 ));
6343
6344 let error = harness.launcher.record_allocation(&clash).unwrap_err();
6347 let rendered = error.to_string();
6348 assert!(rendered.contains("slot s1"), "{rendered}");
6349 assert!(rendered.contains("nothing was written"), "{rendered}");
6350 assert_eq!(
6351 harness.store.attempts().unwrap().len(),
6352 1,
6353 "the losing allocator journalled nothing"
6354 );
6355 }
6356
6357 #[test]
6358 fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
6359 let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
6360 slots
6361 .iter()
6362 .map(|slot| {
6363 RunnerAttempt::allocate_in(
6364 AttemptId::new_random(),
6365 fixtures::POLICY_ID,
6366 format!("/srv/rman/acme/s{slot}"),
6367 AttemptWorkspace::persistent_slot(nz(*slot)),
6368 fixtures::created_at(),
6369 )
6370 })
6371 .collect()
6372 };
6373
6374 assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
6375 assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
6376 assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
6378 assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
6380 assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
6381 let ephemeral = vec![RunnerAttempt::allocate(
6383 AttemptId::new_random(),
6384 fixtures::POLICY_ID,
6385 "/srv/rman/host/abc",
6386 fixtures::created_at(),
6387 )];
6388 assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
6389 }
6390
6391 #[test]
6392 fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
6393 let root = tempfile::tempdir().unwrap();
6394 let slot = root.path().join("s1");
6395 fs::create_dir(&slot).unwrap();
6396 accept_reusable_slot(&slot).expect("an empty slot is reusable");
6397
6398 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6399 accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
6400
6401 fs::create_dir(slot.join("bin")).unwrap();
6404 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
6405 let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
6406 assert!(refusal.contains("bin"), "{refusal}");
6407 assert!(refusal.contains(".github-runner-id"), "{refusal}");
6408
6409 let file_work = root.path().join("s2");
6411 fs::create_dir(&file_work).unwrap();
6412 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
6413 assert!(accept_reusable_slot(&file_work).is_err());
6414 }
6415
6416 #[cfg(unix)]
6417 #[test]
6418 fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
6419 let root = tempfile::tempdir().unwrap();
6423 let elsewhere = root.path().join("elsewhere");
6424 fs::create_dir(&elsewhere).unwrap();
6425
6426 let slot = root.path().join("s1");
6427 fs::create_dir(&slot).unwrap();
6428 std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6429 assert!(accept_reusable_slot(&slot).is_err());
6430
6431 let linked_slot = root.path().join("s2");
6432 std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
6433 assert!(create_or_validate_slot(&linked_slot).is_err());
6434 }
6435
6436 #[test]
6437 fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
6438 let root = tempfile::tempdir().unwrap();
6439 let occupied = root.path().join("s1");
6440 fs::write(&occupied, b"an operator's file").unwrap();
6441 let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
6442 assert!(refusal.contains("is not a directory"), "{refusal}");
6443 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
6444
6445 let fresh = root.path().join("s2");
6446 create_or_validate_slot(&fresh).expect("a missing slot is created");
6447 assert!(fresh.is_dir());
6448 create_or_validate_slot(&fresh).expect("an existing directory is accepted");
6449 }
6450
6451 #[test]
6452 fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
6453 assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
6454 assert!(!is_work_folder(OsStr::new("_work2")));
6455 assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
6459 }
6460
6461 #[test]
6462 fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
6463 let root = tempfile::tempdir().unwrap();
6464 let package = root.path().join("package");
6465 fs::create_dir_all(package.join("bin")).unwrap();
6466 fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
6467 fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
6469
6470 let slot = root.path().join("s1");
6471 let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
6472 fs::create_dir_all(&retained).unwrap();
6473 fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
6474
6475 copy_package_tree(&package, &slot, &TrustedTrees::default())
6476 .expect("the package lays out around `_work`");
6477 assert!(slot.join("bin").join("Runner.Listener").exists());
6478 assert!(
6479 slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
6480 "the guard is top-level only"
6481 );
6482 assert_eq!(
6483 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
6484 "from the first job"
6485 );
6486
6487 fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
6489 let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
6490 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
6491 assert_eq!(
6492 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
6493 "from the first job"
6494 );
6495 }
6496
6497 #[test]
6498 fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
6499 let root = tempfile::tempdir().unwrap();
6500
6501 let slot = root.path().join("s1");
6502 let retained = slot.join(DEFAULT_WORK_FOLDER);
6503 fs::create_dir_all(retained.join("repo")).unwrap();
6504 fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
6505 fs::create_dir_all(slot.join("bin")).unwrap();
6506 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
6507 let persistent = RunnerAttempt::allocate_in(
6508 AttemptId::new_random(),
6509 fixtures::POLICY_ID,
6510 &slot,
6511 AttemptWorkspace::persistent_slot(nz(1)),
6512 fixtures::created_at(),
6513 );
6514
6515 remove_materialized_package(&persistent).unwrap();
6516 assert!(slot.is_dir(), "the slot itself is not removed");
6517 assert!(!slot.join("bin").exists());
6518 assert!(!slot.join(".github-runner-id").exists());
6519 assert_eq!(
6520 fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
6521 "kept"
6522 );
6523
6524 let disposable_path = root.path().join("abcdef012345");
6525 fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
6526 let disposable = RunnerAttempt::allocate(
6527 AttemptId::new_random(),
6528 fixtures::POLICY_ID,
6529 &disposable_path,
6530 fixtures::created_at(),
6531 );
6532 remove_materialized_package(&disposable).unwrap();
6533 assert!(
6534 !disposable_path.exists(),
6535 "a disposable directory is still removed whole"
6536 );
6537 }
6538
6539 fn litter_the_slot(slot: &Path) {
6549 for directory in ["bin", "externals", "_diag"] {
6550 fs::create_dir_all(slot.join(directory)).unwrap();
6551 }
6552 fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
6553 for file in SENSITIVE_SLOT_ENTRIES
6554 .iter()
6555 .filter(|entry| !slot.join(entry).is_dir())
6556 {
6557 fs::write(slot.join(file), b"runner state").unwrap();
6558 }
6559 fs::write(
6561 slot.join(format!(
6562 "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
6563 RestrictiveHandoff::NAME_PREFIX
6564 )),
6565 JIT.as_bytes(),
6566 )
6567 .unwrap();
6568 }
6569
6570 fn retain_under_work(slot: &Path) -> PathBuf {
6572 let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
6573 fs::create_dir_all(&checkout).unwrap();
6574 let marker = checkout.join("build-output.bin");
6575 fs::write(&marker, RETAINED).unwrap();
6576 marker
6577 }
6578
6579 const RETAINED: &str = "a Git-ignored build output the next job reuses";
6581
6582 fn entries_of(directory: &Path) -> Vec<String> {
6584 let mut names: Vec<String> = fs::read_dir(directory)
6585 .unwrap()
6586 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
6587 .collect();
6588 names.sort();
6589 names
6590 }
6591
6592 fn only_the_job_workspace() -> Vec<String> {
6594 vec![DEFAULT_WORK_FOLDER.to_owned()]
6595 }
6596
6597 #[cfg(unix)]
6598 #[test]
6599 fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
6600 use std::sync::mpsc;
6601
6602 let temporary = tempfile::tempdir().unwrap();
6603 let tree = temporary.path().join("attempt");
6604 let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
6605 fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
6606 assert!(
6607 std::process::Command::new("mkfifo")
6608 .arg(&diagnostic)
6609 .status()
6610 .unwrap()
6611 .success()
6612 );
6613
6614 let (finished, result) = mpsc::channel();
6615 std::thread::spawn(move || {
6616 let removed = remove_runtime_tree(&tree);
6617 let _ = finished.send(removed);
6618 });
6619
6620 result
6621 .recv_timeout(Duration::from_secs(2))
6622 .expect("runtime deletion must not wait for a FIFO peer")
6623 .unwrap();
6624 assert!(!diagnostic.exists());
6625 }
6626
6627 struct BlockedDeletion {
6643 directory: PathBuf,
6644 #[cfg(windows)]
6645 _handle: fs::File,
6646 }
6647
6648 impl BlockedDeletion {
6649 const HELD: &'static str = "held-open";
6650
6651 fn inject(directory: &Path) -> Option<Self> {
6654 #[cfg(unix)]
6655 if !Self::refusal_is_possible() {
6656 return None;
6657 }
6658 fs::create_dir_all(directory).unwrap();
6659 fs::write(
6660 directory.join(Self::HELD),
6661 b"a file the scrub cannot remove",
6662 )
6663 .unwrap();
6664 #[cfg(windows)]
6665 let handle = {
6666 use std::os::windows::fs::OpenOptionsExt;
6667
6668 fs::OpenOptions::new()
6669 .read(true)
6670 .share_mode(0)
6671 .open(directory.join(Self::HELD))
6672 .expect("the blocking handle opens")
6673 };
6674 #[cfg(unix)]
6675 Self::set_mode(directory, 0o555);
6676 Some(Self {
6677 directory: directory.to_path_buf(),
6678 #[cfg(windows)]
6679 _handle: handle,
6680 })
6681 }
6682
6683 fn release(self) {
6684 drop(self);
6685 }
6686
6687 #[cfg(unix)]
6688 fn refusal_is_possible() -> bool {
6689 let probe = tempfile::tempdir().unwrap();
6690 let directory = probe.path().join("probe");
6691 fs::create_dir(&directory).unwrap();
6692 fs::write(directory.join("file"), b"probe").unwrap();
6693 Self::set_mode(&directory, 0o555);
6694 let refused = fs::remove_dir_all(&directory).is_err();
6695 Self::set_mode(&directory, 0o755);
6696 refused
6697 }
6698
6699 #[cfg(unix)]
6700 fn set_mode(directory: &Path, mode: u32) {
6701 use std::os::unix::fs::PermissionsExt;
6702
6703 let mut permissions = fs::metadata(directory).unwrap().permissions();
6704 permissions.set_mode(mode);
6705 fs::set_permissions(directory, permissions).unwrap();
6706 }
6707 }
6708
6709 impl Drop for BlockedDeletion {
6710 fn drop(&mut self) {
6711 #[cfg(unix)]
6712 Self::set_mode(&self.directory, 0o755);
6713 #[cfg(not(unix))]
6714 let _ = &self.directory;
6715 }
6716 }
6717
6718 #[tokio::test]
6719 async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
6720 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6721 .with_persistent_workspace(1);
6722 harness.ready().await;
6723
6724 let first = harness.launch().await;
6725 let slot = harness.slot_path(1);
6726 assert_eq!(first.runtime_path(), slot);
6727 assert_eq!(
6728 read_runner_id(&slot),
6729 Some(73),
6730 "the attempt registered, so its identity is on disk"
6731 );
6732 let marker = retain_under_work(&slot);
6733 litter_the_slot(&slot);
6734
6735 harness.cleanup_retaining_work(first.id).await;
6736
6737 assert_eq!(entries_of(&slot), only_the_job_workspace());
6740 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
6741 assert_eq!(
6742 read_runner_id(&slot),
6743 None,
6744 "the first attempt's registration identity is gone before the second starts"
6745 );
6746 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
6747 assert!(!harness.attempt(first.id).holds_slot_lease());
6748
6749 let second = harness.launch().await;
6750 assert_ne!(second.id, first.id);
6751 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
6752 assert_eq!(
6753 second.runtime_path(),
6754 slot,
6755 "the same slot, so the same retained `_work`"
6756 );
6757 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
6758 }
6759
6760 #[tokio::test]
6761 async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
6762 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6763 .with_persistent_workspace(1);
6764 harness.ready().await;
6765 let attempt = harness.launch().await;
6766 let slot = harness.slot_path(1);
6767 let marker = retain_under_work(&slot);
6768 litter_the_slot(&slot);
6769 harness.conclude(attempt.id);
6770
6771 harness
6777 .store
6778 .remove_policy(harness.policy.id, harness.policy.revision())
6779 .unwrap();
6780 assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
6781
6782 harness
6783 .launcher
6784 .clean(attempt.id)
6785 .await
6786 .expect("journal facts alone are enough to clean the slot");
6787
6788 assert_eq!(entries_of(&slot), only_the_job_workspace());
6789 assert!(marker.exists());
6790 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
6791 }
6792
6793 #[tokio::test]
6794 async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
6795 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6796 .with_persistent_workspace(2);
6797 harness.ready().await;
6798 let first = harness.launch().await;
6799 let slot = harness.slot_path(1);
6800 let marker = retain_under_work(&slot);
6801 litter_the_slot(&slot);
6802 harness.conclude(first.id);
6803
6804 let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
6805 eprintln!(
6806 "skipped: this account cannot be refused a deletion, so no partial deletion can \
6807 be injected"
6808 );
6809 return;
6810 };
6811
6812 let refusal = harness
6813 .launcher
6814 .clean(first.id)
6815 .await
6816 .expect_err("a deletion that failed may not report a cleaned slot");
6817 let rendered = refusal.reason.to_string();
6818 assert!(rendered.contains("could not be removed"), "{rendered}");
6819
6820 let held = harness.attempt(first.id);
6821 assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
6822 assert!(held.holds_slot_lease(), "so the slot is still leased");
6823 assert!(
6824 !held.state().counts_against_capacity(),
6825 "and a concluded attempt still costs the host no capacity"
6826 );
6827
6828 let restarted = harness.restart();
6833 restarted
6834 .recover_startup(std::slice::from_ref(&harness.policy))
6835 .await
6836 .expect("one quarantined slot does not stop the host recovering");
6837 assert_eq!(
6838 harness.attempt(first.id).state(),
6839 AttemptState::Failed,
6840 "the quarantine survived the restart"
6841 );
6842 assert!(
6843 harness
6844 .reconcile_events
6845 .events()
6846 .iter()
6847 .any(|event| matches!(
6848 event,
6849 LifecycleEvent::AttemptCleanFailed {
6850 reason: "slot_entry_could_not_be_removed",
6851 ..
6852 }
6853 )),
6854 "the refusal is reported rather than retried in silence"
6855 );
6856
6857 let guard = harness.allocation_lock.acquire().await.unwrap();
6860 let second = restarted
6861 .launch(LaunchRequest {
6862 host: &harness.host,
6863 policy: &harness.policy,
6864 allocation_guard: &guard,
6865 lock_wait: Duration::ZERO,
6866 })
6867 .await
6868 .expect("the host can still launch");
6869 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
6870 drop(guard);
6871
6872 block.release();
6875 restarted
6876 .clean(first.id)
6877 .await
6878 .expect("the retried cleanup completes");
6879 assert_eq!(entries_of(&slot), only_the_job_workspace());
6880 assert!(marker.exists());
6881 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
6882 }
6883
6884 #[tokio::test]
6895 async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
6896 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6897 harness.ready().await;
6898 let attempt = harness.launch().await;
6899 let runtime = attempt.runtime_path().to_path_buf();
6900 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
6901 harness.conclude(attempt.id);
6902
6903 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
6904 eprintln!(
6905 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
6906 );
6907 return;
6908 };
6909
6910 let refusal = harness
6911 .launcher
6912 .clean(attempt.id)
6913 .await
6914 .expect_err("a deletion that failed may not report a removed workspace");
6915 let rendered = refusal.reason.to_string();
6916 assert!(
6917 rendered.contains("could not be removed"),
6918 "the refusal names what happened: {rendered}"
6919 );
6920 assert_ne!(
6921 harness.attempt(attempt.id).state(),
6922 AttemptState::Cleaned,
6923 "an attempt whose directory is still on disk is not cleaned"
6924 );
6925 assert!(
6926 runtime.is_dir(),
6927 "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
6928 );
6929
6930 let restarted = harness.restart();
6931 restarted
6932 .recover_startup(std::slice::from_ref(&harness.policy))
6933 .await
6934 .expect("one locked ephemeral workspace does not stop startup recovery");
6935 assert!(
6936 harness
6937 .reconcile_events
6938 .events()
6939 .iter()
6940 .any(|event| matches!(
6941 event,
6942 LifecycleEvent::AttemptCleanFailed {
6943 attempt: failed_attempt,
6944 reason: "ephemeral_workspace_could_not_be_removed",
6945 ..
6946 } if *failed_attempt == attempt.id
6947 )),
6948 "the deferred cleanup remains visible while the daemon keeps running"
6949 );
6950
6951 block.release();
6954 harness
6955 .launcher
6956 .clean(attempt.id)
6957 .await
6958 .expect("the retried cleanup completes");
6959 assert!(!runtime.exists(), "the whole attempt directory goes");
6960 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
6961 }
6962
6963 #[tokio::test]
6964 async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
6965 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6966 .with_persistent_workspace(1);
6967 harness.ready().await;
6968 let first = harness.launch().await;
6969 let slot = harness.slot_path(1);
6970 let marker = retain_under_work(&slot);
6971 harness.cleanup_retaining_work(first.id).await;
6972
6973 harness
6977 .policy
6978 .set_workspace_policy(WorkspacePolicy::Ephemeral)
6979 .unwrap();
6980
6981 let second = harness.launch().await;
6982 assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
6983 assert_eq!(
6984 second.runtime_path().parent().unwrap(),
6985 harness.host_root(),
6986 "a disposable attempt is a child of the host root"
6987 );
6988 assert!(slot.is_dir(), "the old slot is left where it stands");
6989 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
6990
6991 harness.conclude(second.id);
6994 harness.launcher.clean(second.id).await.unwrap();
6995 assert!(!second.runtime_path().exists());
6996 assert!(marker.exists());
6997 }
6998
6999 #[tokio::test]
7000 async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
7001 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7002 .with_persistent_workspace(1);
7003 harness.ready().await;
7004 let slot = harness.slot_path(1);
7005 fs::create_dir_all(&slot).unwrap();
7006 let marker = retain_under_work(&slot);
7007 litter_the_slot(&slot);
7008
7009 let id = AttemptId::new_random();
7010 let mut attempt = RunnerAttempt::allocate_in(
7011 id,
7012 harness.policy.id,
7013 &slot,
7014 AttemptWorkspace::persistent_slot(nz(1)),
7015 harness.clock.now(),
7016 );
7017 attempt.jit_received(harness.clock.now()).unwrap();
7018 attempt.started(4242, harness.clock.now()).unwrap();
7019 harness.store.record_attempt(&attempt).unwrap();
7020 harness.clock.advance_secs(11);
7021 harness.processes.set_alive(true);
7022 harness
7023 .github
7024 .observe(GithubRunnerObservation::NotRegistered);
7025
7026 harness.launcher.supervise(&harness.policy).await.unwrap();
7027
7028 let actions = harness.processes.actions.lock().unwrap().clone();
7032 let intent = actions
7033 .iter()
7034 .position(|action| *action == "terminate_intent")
7035 .unwrap();
7036 let signal = actions
7037 .iter()
7038 .position(|action| *action == "terminate")
7039 .unwrap();
7040 assert!(intent < signal, "{actions:?}");
7041 assert!(!harness.processes.alive.load(Ordering::SeqCst));
7042
7043 let cleaned = harness.attempt(id);
7044 assert_eq!(cleaned.state(), AttemptState::Cleaned);
7045 assert!(matches!(
7046 cleaned.outcome(),
7047 Some(AttemptOutcome::Failed {
7048 reason: FailureReason::TerminatedAfterRegistrationTimeout
7049 })
7050 ));
7051 assert_eq!(entries_of(&slot), only_the_job_workspace());
7052 assert!(marker.exists());
7053 }
7054
7055 #[test]
7056 fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
7057 let root = tempfile::tempdir().unwrap();
7058 let slot = root.path().join("s1");
7059 fs::create_dir(&slot).unwrap();
7060 let marker = retain_under_work(&slot);
7061 litter_the_slot(&slot);
7062 fs::write(slot.join("runner-package"), b"verified").unwrap();
7063
7064 scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
7065 verify_slot_scrubbed(&slot).expect("and proves it afterwards");
7066
7067 assert_eq!(entries_of(&slot), only_the_job_workspace());
7068 assert!(marker.exists());
7069 }
7070
7071 #[test]
7072 fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
7073 let slot = Path::new("/runners/s1");
7074
7075 let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
7078 assert!(counted.contains("2 entries other than"), "{counted}");
7079 assert!(counted.contains("including `bin`"), "{counted}");
7080 assert_eq!(
7081 residue_detail(slot, 1, &[]),
7082 format!(
7083 "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
7084 slot.display()
7085 )
7086 );
7087
7088 let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
7093 assert!(!raced.contains('0'), "{raced}");
7094 assert!(raced.contains("reported nothing but"), "{raced}");
7095 assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
7096 }
7097
7098 #[test]
7099 fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
7100 let root = tempfile::tempdir().unwrap();
7101 let slot = root.path().join("s1");
7102 fs::create_dir(&slot).unwrap();
7103 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
7104 verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
7105
7106 for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
7110 fs::write(slot.join(survivor), b"left behind").unwrap();
7111 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
7112 assert_eq!(quarantine.refusal, SlotRefusal::Residue);
7113 assert!(
7114 quarantine.detail.contains(&format!("`{survivor}`")),
7115 "{quarantine}"
7116 );
7117 fs::remove_file(slot.join(survivor)).unwrap();
7118 }
7119
7120 let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
7123 fs::write(&handoff, JIT.as_bytes()).unwrap();
7124 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
7125 assert!(
7126 quarantine.detail.contains("an encoded JIT handoff"),
7127 "{quarantine}"
7128 );
7129 assert!(!quarantine.detail.contains(JIT), "{quarantine}");
7130 fs::remove_file(&handoff).unwrap();
7131
7132 fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
7136 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
7137 assert!(
7138 quarantine.detail.contains("1 entry other than"),
7139 "{quarantine}"
7140 );
7141 assert!(
7142 !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
7143 "{quarantine}"
7144 );
7145 }
7146
7147 #[test]
7148 fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
7149 let root = tempfile::tempdir().unwrap();
7150 let configured =
7151 LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
7152 let slot = configured.as_path().join("s1");
7153 fs::create_dir(&slot).unwrap();
7154
7155 verify_journalled_slot(&slot, nz(1), Some(&configured))
7156 .expect("the journalled slot agrees");
7157 verify_journalled_slot(&slot, nz(1), None)
7158 .expect("and a policy that is gone removes a check, not the ability to clean");
7159
7160 assert_eq!(
7163 verify_journalled_slot(&slot, nz(2), None)
7164 .unwrap_err()
7165 .refusal,
7166 SlotRefusal::NotTheJournalledSlot
7167 );
7168 for stray in ["s1/nested", "not-a-slot", "s01"] {
7169 let path = configured.as_path().join(stray);
7170 assert_eq!(
7171 verify_journalled_slot(&path, nz(1), None)
7172 .unwrap_err()
7173 .refusal,
7174 SlotRefusal::NotTheJournalledSlot,
7175 "{}",
7176 path.display()
7177 );
7178 }
7179
7180 let elsewhere = tempfile::tempdir().unwrap();
7183 let other =
7184 LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
7185 assert_eq!(
7186 verify_journalled_slot(&slot, nz(1), Some(&other))
7187 .unwrap_err()
7188 .refusal,
7189 SlotRefusal::PolicyRootDisagrees
7190 );
7191 }
7192
7193 #[cfg(unix)]
7194 #[test]
7195 fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
7196 let root = tempfile::tempdir().unwrap();
7200 let outside = root.path().join("operator-data");
7201 fs::create_dir(&outside).unwrap();
7202 let sentinel = outside.join("do-not-delete.txt");
7203 fs::write(
7204 &sentinel,
7205 b"an operator's data, outside every approved root",
7206 )
7207 .unwrap();
7208
7209 let slot = root.path().join("s1");
7210 fs::create_dir(&slot).unwrap();
7211 fs::create_dir(slot.join("bin")).unwrap();
7212 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
7213
7214 let quarantine = scrub_slot_entries(&slot).unwrap_err();
7215 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
7216 assert!(
7217 sentinel.exists(),
7218 "the deletion followed the link out of the slot"
7219 );
7220 assert!(outside.is_dir());
7221 assert!(
7222 slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
7223 "the substituted link is left for the operator, never unlinked as if it were ours"
7224 );
7225
7226 let file_work = root.path().join("s2");
7230 fs::create_dir(&file_work).unwrap();
7231 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
7232 assert_eq!(
7233 scrub_slot_entries(&file_work).unwrap_err().refusal,
7234 SlotRefusal::WorkNotADirectory
7235 );
7236 }
7237
7238 #[cfg(unix)]
7239 #[test]
7240 fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
7241 let root = tempfile::tempdir().unwrap();
7242 let outside = root.path().join("operator-data");
7243 fs::create_dir(&outside).unwrap();
7244 let sentinel = outside.join("do-not-delete.txt");
7245 fs::write(
7246 &sentinel,
7247 b"an operator's data, outside every approved root",
7248 )
7249 .unwrap();
7250
7251 let inside = root.path().join("inside");
7254 fs::create_dir(&inside).unwrap();
7255 let slot = inside.join("s1");
7256 std::os::unix::fs::symlink(&outside, &slot).unwrap();
7257
7258 assert_eq!(
7259 verify_journalled_slot(&slot, nz(1), None)
7260 .unwrap_err()
7261 .refusal,
7262 SlotRefusal::Containment
7263 );
7264 assert!(sentinel.exists());
7265 assert!(
7266 slot.symlink_metadata().is_ok(),
7267 "the link is left for the operator rather than removed as if it were ours"
7268 );
7269 }
7270
7271 #[cfg(windows)]
7281 #[test]
7282 fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
7283 let root = tempfile::tempdir().unwrap();
7284 let outside = root.path().join("operator-data");
7285 fs::create_dir(&outside).unwrap();
7286 let sentinel = outside.join("do-not-delete.txt");
7287 fs::write(
7288 &sentinel,
7289 b"an operator's data, outside every approved root",
7290 )
7291 .unwrap();
7292
7293 let inside = root.path().join("inside");
7296 fs::create_dir(&inside).unwrap();
7297 let slot = inside.join("s1");
7298 let Some(()) = plant_junction(&slot, &outside) else {
7299 eprintln!("skipped: this machine would not create a directory junction");
7300 return;
7301 };
7302
7303 assert_eq!(
7304 verify_journalled_slot(&slot, nz(1), None)
7305 .unwrap_err()
7306 .refusal,
7307 SlotRefusal::Containment
7308 );
7309 assert!(
7310 sentinel.exists(),
7311 "the refusal resolved the junction and reached the operator's data"
7312 );
7313 assert!(
7314 slot.symlink_metadata().is_ok(),
7315 "the junction is left for the operator rather than removed as if it were ours"
7316 );
7317 }
7318
7319 #[cfg(windows)]
7328 fn plant_junction(link: &Path, target: &Path) -> Option<()> {
7329 let made = std::process::Command::new("cmd")
7330 .arg("/C")
7331 .arg("mklink")
7332 .arg("/J")
7333 .arg(link)
7334 .arg(target)
7335 .output()
7336 .ok()?;
7337 (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
7338 }
7339
7340 #[cfg(windows)]
7341 #[test]
7342 fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
7343 let root = tempfile::tempdir().unwrap();
7344 let outside = root.path().join("operator-data");
7345 fs::create_dir(&outside).unwrap();
7346 let sentinel = outside.join("do-not-delete.txt");
7347 fs::write(
7348 &sentinel,
7349 b"an operator's data, outside every approved root",
7350 )
7351 .unwrap();
7352
7353 let slot = root.path().join("s1");
7354 fs::create_dir(&slot).unwrap();
7355 fs::create_dir(slot.join("bin")).unwrap();
7356 let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
7357 eprintln!("skipped: this machine would not create a directory junction");
7358 return;
7359 };
7360
7361 let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
7365 assert!(is_link_like(&work), "a junction is a reparse point");
7366 let quarantine = scrub_slot_entries(&slot).unwrap_err();
7367 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
7368 assert!(
7369 sentinel.exists(),
7370 "the deletion followed the junction out of the slot"
7371 );
7372 assert!(outside.is_dir());
7373
7374 let elsewhere = root.path().join("s2");
7377 fs::create_dir(&elsewhere).unwrap();
7378 fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
7379 if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
7380 scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
7381 verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
7382 assert!(sentinel.exists(), "the junction was followed, not unlinked");
7383 assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
7384 }
7385 }
7386
7387 #[cfg(unix)]
7388 #[tokio::test]
7389 async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
7390 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7391 .with_persistent_workspace(2);
7392 harness.ready().await;
7393 let first = harness.launch().await;
7394 let slot = harness.slot_path(1);
7395 harness.conclude(first.id);
7396
7397 let outside = harness._root.path().join("operator-data");
7398 fs::create_dir_all(&outside).unwrap();
7399 let sentinel = outside.join("do-not-delete.txt");
7400 fs::write(&sentinel, b"outside every approved root").unwrap();
7401 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
7402
7403 harness
7404 .launcher
7405 .clean(first.id)
7406 .await
7407 .expect_err("a slot whose `_work` was substituted is quarantined");
7408 assert!(sentinel.exists());
7409
7410 let held = harness.attempt(first.id);
7411 assert_eq!(held.state(), AttemptState::Failed);
7412 assert!(held.holds_slot_lease());
7413
7414 let second = harness.launch().await;
7416 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
7417 }
7418
7419 #[test]
7420 fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
7421 let root = tempfile::tempdir().unwrap();
7422 let occupied = root.path().join("s1");
7423 fs::write(&occupied, b"an operator's file").unwrap();
7424 verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
7427 assert_eq!(
7428 slot_is_present(&occupied).unwrap_err().refusal,
7429 SlotRefusal::SlotNotADirectory
7430 );
7431 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
7432
7433 assert!(!slot_is_present(&root.path().join("s2")).unwrap());
7436 let present = root.path().join("s3");
7437 fs::create_dir(&present).unwrap();
7438 assert!(slot_is_present(&present).unwrap());
7439 }
7440
7441 #[test]
7442 fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
7443 let root = tempfile::tempdir().unwrap();
7444
7445 let disposable = root.path().join("abcdef012345");
7449 fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
7450 let ephemeral = RunnerAttempt::allocate(
7451 AttemptId::new_random(),
7452 fixtures::POLICY_ID,
7453 &disposable,
7454 fixtures::created_at(),
7455 );
7456 remove_materialized_package(&ephemeral).unwrap();
7457 assert!(!disposable.exists());
7458
7459 let slot = root.path().join("s1");
7461 fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
7462 fs::create_dir_all(slot.join("bin")).unwrap();
7463 let persistent = RunnerAttempt::allocate_in(
7464 AttemptId::new_random(),
7465 fixtures::POLICY_ID,
7466 &slot,
7467 AttemptWorkspace::persistent_slot(nz(1)),
7468 fixtures::created_at(),
7469 );
7470 remove_materialized_package(&persistent).unwrap();
7471 assert_eq!(entries_of(&slot), only_the_job_workspace());
7472 assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
7473 }
7474
7475 #[test]
7476 fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
7477 let refusals = [
7478 SlotRefusal::NotTheJournalledSlot,
7479 SlotRefusal::PolicyRootDisagrees,
7480 SlotRefusal::Containment,
7481 SlotRefusal::SlotNotADirectory,
7482 SlotRefusal::Enumeration,
7483 SlotRefusal::WorkNotADirectory,
7484 SlotRefusal::Deletion,
7485 SlotRefusal::Residue,
7486 ];
7487 let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
7488 assert_eq!(
7489 classes.len(),
7490 refusals.len(),
7491 "an event class shared by two refusals tells an operator less than it appears to"
7492 );
7493 for refusal in refusals {
7494 assert!(
7497 refusal
7498 .class()
7499 .chars()
7500 .all(|c| c.is_ascii_lowercase() || c == '_'),
7501 "{}",
7502 refusal.class()
7503 );
7504 assert!(
7505 refusal.remediation().contains("slot lease"),
7506 "every refusal has to say the lease is still held: {}",
7507 refusal.class()
7508 );
7509 }
7510 }
7511
7512 #[test]
7513 fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
7514 let root = tempfile::tempdir().unwrap();
7515 let source = root.path().join("source with spaces");
7516 let dest = root.path().join("dest with spaces");
7517
7518 fs::create_dir_all(&source).unwrap();
7519 fs::write(source.join("file1.txt"), b"hello").unwrap();
7520
7521 let nested = source.join("nested dir");
7522 fs::create_dir_all(&nested).unwrap();
7523 fs::write(nested.join("file2.txt"), b"world").unwrap();
7524
7525 let nested_work = nested.join(DEFAULT_WORK_FOLDER);
7527 fs::create_dir_all(&nested_work).unwrap();
7528 fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
7529
7530 copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
7531
7532 assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
7533 assert_eq!(
7534 fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
7535 "world"
7536 );
7537 assert_eq!(
7538 fs::read_to_string(
7539 dest.join("nested dir")
7540 .join(DEFAULT_WORK_FOLDER)
7541 .join("allowed.txt")
7542 )
7543 .unwrap(),
7544 "allowed"
7545 );
7546 }
7547
7548 #[test]
7549 fn every_field_name_the_attempt_sink_emits_is_allowed() {
7550 use runner_manager_platform::logging::is_field_allowed;
7551
7552 for field in [
7554 "event",
7555 "attempt_id",
7556 "reason",
7557 "retry_in_ms",
7558 "duration_ms",
7559 "lock_wait_ms",
7560 "materialize_ms",
7561 "register_ms",
7562 "spawn_ms",
7563 ] {
7564 assert!(
7565 is_field_allowed(field),
7566 "`{field}` would be logged as `[redacted]`"
7567 );
7568 }
7569 }
7570
7571 #[cfg(unix)]
7572 fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
7573 let entry = root.join("packages").join(version);
7574 fs::create_dir_all(entry.join("bin")).unwrap();
7575 fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
7576 fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
7577 entry
7578 }
7579
7580 #[cfg(unix)]
7582 fn listener_inode(seed: &Path) -> u64 {
7583 use std::os::unix::fs::MetadataExt as _;
7584 fs::metadata(seed.join("bin").join("Runner.Listener"))
7585 .unwrap()
7586 .ino()
7587 }
7588
7589 #[cfg(unix)]
7590 #[test]
7591 fn a_package_seed_is_built_once_and_reused_while_untouched() {
7592 let root = tempfile::tempdir().unwrap();
7593 let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
7594 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
7595 let trusted = TrustedTrees::default();
7596
7597 let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
7598 assert_eq!(seed, seeds.join("2.336.0"));
7599 assert_eq!(
7600 fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
7601 "2.336.0"
7602 );
7603
7604 let built = listener_inode(&seed);
7605 assert_eq!(
7606 refresh_package_seed(&source, &seeds, &trusted).unwrap(),
7607 seed
7608 );
7609 assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
7610 }
7611
7612 #[cfg(unix)]
7613 #[test]
7614 fn a_tampered_or_unrecorded_seed_is_rebuilt() {
7615 let root = tempfile::tempdir().unwrap();
7616 let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
7617 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
7618 let trusted = TrustedTrees::default();
7619 let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
7620
7621 fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
7623 refresh_package_seed(&source, &seeds, &trusted).unwrap();
7624 assert_eq!(
7625 fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
7626 "2.336.0",
7627 "a seed whose print moved is rebuilt from the cache entry"
7628 );
7629
7630 let built = listener_inode(&seed);
7632 refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
7633 assert_ne!(
7634 listener_inode(&seed),
7635 built,
7636 "an unrecorded seed is rebuilt"
7637 );
7638 }
7639
7640 #[cfg(unix)]
7641 #[test]
7642 fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
7643 let root = tempfile::tempdir().unwrap();
7644 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
7645 let trusted = TrustedTrees::default();
7646 let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
7647 refresh_package_seed(&old, &seeds, &trusted).unwrap();
7648 fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
7649 fs::write(seeds.join("stray"), b"").unwrap();
7650
7651 let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
7652 let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
7653
7654 let mut left: Vec<_> = fs::read_dir(&seeds)
7655 .unwrap()
7656 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
7657 .collect();
7658 left.sort();
7659 assert_eq!(left, ["2.336.0"]);
7660
7661 fs::write(
7663 new.join(crate::package::MANIFEST_FILE),
7664 "{\"digest\":\"again\"}",
7665 )
7666 .unwrap();
7667 let built = listener_inode(&seed);
7668 refresh_package_seed(&new, &seeds, &trusted).unwrap();
7669 assert_ne!(listener_inode(&seed), built, "a stale seed is rebuilt");
7670 }
7671
7672 #[cfg(unix)]
7673 #[test]
7674 fn a_package_on_the_runtime_volume_is_its_own_seed() {
7675 let root = tempfile::tempdir().unwrap();
7676 let source = package_entry(root.path(), "2.336.0", "{}");
7677 let runtime = root.path().join("runners").join("attempt");
7678 fs::create_dir_all(&runtime).unwrap();
7679
7680 let trusted = TrustedTrees::default();
7681 assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
7682 copy_package_tree(&source, &runtime, &trusted).unwrap();
7683 assert!(runtime.join("bin").join("Runner.Listener").exists());
7684 assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
7685 }
7686
7687 #[test]
7688 fn copy_package_tree_refuses_top_level_work_folder() {
7689 let root = tempfile::tempdir().unwrap();
7690 let source = root.path().join("source");
7691 let dest = root.path().join("dest");
7692
7693 fs::create_dir_all(&source).unwrap();
7694 fs::write(source.join("file1.txt"), b"hello").unwrap();
7695
7696 let top_work = source.join(DEFAULT_WORK_FOLDER);
7698 fs::create_dir_all(&top_work).unwrap();
7699
7700 let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
7701 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
7702 }
7703}