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::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 Adopted {
151 attempt: AttemptId,
152 },
153 RemoteIdentityRecovered {
154 attempt: AttemptId,
155 runner_id: u64,
156 },
157 TerminateIntent {
158 attempt: AttemptId,
159 },
160 Terminated {
161 attempt: AttemptId,
162 },
163 Deregistered {
167 attempt: AttemptId,
168 runner_id: u64,
169 },
170 Concluded {
171 attempt: AttemptId,
172 outcome: OutcomeKind,
173 },
174 Cleaned {
175 attempt: AttemptId,
176 outcome: OutcomeKind,
177 },
178}
179
180pub trait AttemptEventSink: fmt::Debug + Send + Sync {
181 fn emit(&self, event: AttemptEvent);
182}
183
184#[derive(Debug, Default)]
185pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
186
187impl AttemptEventLog {
188 #[must_use]
189 pub fn events(&self) -> Vec<AttemptEvent> {
190 self.0
191 .lock()
192 .map(|events| events.clone())
193 .unwrap_or_default()
194 }
195}
196
197impl AttemptEventSink for AttemptEventLog {
198 fn emit(&self, event: AttemptEvent) {
199 if let Ok(mut events) = self.0.lock() {
200 events.push(event);
201 }
202 }
203}
204
205#[derive(Debug, Clone, Copy, Default)]
206pub struct NoAttemptEvents;
207
208impl AttemptEventSink for NoAttemptEvents {
209 fn emit(&self, _event: AttemptEvent) {}
210}
211
212#[async_trait]
214pub trait DemandPersistence: fmt::Debug + Send + Sync {
215 async fn persists(&self, policy: PolicyId) -> bool;
216}
217
218#[derive(Debug, Clone, Copy, Default)]
219pub struct PersistentDemand;
220
221#[async_trait]
222impl DemandPersistence for PersistentDemand {
223 async fn persists(&self, _policy: PolicyId) -> bool {
224 true
225 }
226}
227
228#[async_trait]
229pub trait RetryDelay: fmt::Debug + Send + Sync {
230 async fn wait(&self, duration: Duration);
231}
232
233#[derive(Debug, Clone, Copy, Default)]
234pub struct TokioRetryDelay;
235
236#[async_trait]
237impl RetryDelay for TokioRetryDelay {
238 async fn wait(&self, duration: Duration) {
239 tokio::time::sleep(duration).await;
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct JitRequestFailure {
245 pub terminal: bool,
246 pub reason: FailureReason,
247 pub retry_after: Option<Duration>,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub struct LifecycleGithubObservation {
255 pub status: GithubRunnerObservation,
256 pub runner_id: Option<u64>,
257}
258
259impl LifecycleGithubObservation {
260 #[must_use]
261 pub const fn unreachable() -> Self {
262 Self {
263 status: GithubRunnerObservation::Unreachable,
264 runner_id: None,
265 }
266 }
267
268 #[must_use]
269 pub const fn not_registered() -> Self {
270 Self {
271 status: GithubRunnerObservation::NotRegistered,
272 runner_id: None,
273 }
274 }
275
276 #[must_use]
277 pub const fn registered(runner_id: u64, busy: bool) -> Self {
278 Self {
279 status: GithubRunnerObservation::Registered { busy },
280 runner_id: Some(runner_id),
281 }
282 }
283}
284
285#[async_trait]
288pub trait LifecycleGithub: fmt::Debug + Send + Sync {
289 async fn register(
290 &self,
291 target: &ScaleTarget,
292 request: &JitRunnerRequest,
293 cancel: &CancelToken,
294 ) -> Result<JitRegistration, JitRequestFailure>;
295
296 async fn observe(
297 &self,
298 target: &ScaleTarget,
299 attempt: AttemptId,
300 cancel: &CancelToken,
301 ) -> LifecycleGithubObservation;
302
303 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
310}
311
312#[async_trait]
313impl<T> LifecycleGithub for T
314where
315 T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
316{
317 async fn register(
318 &self,
319 target: &ScaleTarget,
320 request: &JitRunnerRequest,
321 cancel: &CancelToken,
322 ) -> Result<JitRegistration, JitRequestFailure> {
323 self.generate_jit_config(target, request, cancel)
324 .await
325 .map_err(|error| {
326 let reason = if matches!(&error, JitError::Forbidden { .. }) {
327 FailureReason::Other(
328 "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
329 .into(),
330 )
331 } else {
332 FailureReason::JitRequestFailed
333 };
334 JitRequestFailure {
335 terminal: error.is_terminal(),
336 reason,
337 retry_after: error
338 .rate_limited()
339 .map(|limit| limit.delay_from(self.now())),
340 }
341 })
342 }
343
344 async fn observe(
345 &self,
346 target: &ScaleTarget,
347 attempt: AttemptId,
348 cancel: &CancelToken,
349 ) -> LifecycleGithubObservation {
350 let expected_name = runner_name(attempt);
351 match self.list_runners(target, cancel).await {
352 Ok(inventory) => inventory
353 .runners()
354 .iter()
355 .find(|runner| runner.name == expected_name)
356 .map_or(LifecycleGithubObservation::not_registered(), |runner| {
357 LifecycleGithubObservation::registered(runner.id, runner.busy)
358 }),
359 Err(_) => LifecycleGithubObservation::unreachable(),
360 }
361 }
362
363 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
364 self.remove_runner(target, runner_id, cancel).await.is_ok()
365 }
366}
367
368#[async_trait]
370pub trait RuntimePackages: fmt::Debug + Send + Sync {
371 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason>;
372 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
373 fn prune_obsolete_guarded(
374 &self,
375 authority: PruneAuthority<'_>,
376 current: &RunnerVersion,
377 attempts: &[RunnerAttempt],
378 ) -> Result<(), FailureReason>;
379}
380
381pub struct PruneAuthority<'a> {
386 _guard: &'a AllocationGuard,
387}
388
389impl fmt::Debug for PruneAuthority<'_> {
390 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391 f.write_str("PruneAuthority")
392 }
393}
394
395impl<'a> PruneAuthority<'a> {
396 fn from_launch_request(guard: &'a AllocationGuard) -> Self {
397 Self { _guard: guard }
398 }
399}
400
401#[derive(Debug)]
404pub struct CachedRuntimePackages {
405 cache: Arc<PackageCache>,
406}
407
408impl CachedRuntimePackages {
409 #[must_use]
410 pub fn new(cache: Arc<PackageCache>) -> Self {
411 Self { cache }
412 }
413}
414
415#[async_trait]
416impl RuntimePackages for CachedRuntimePackages {
417 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason> {
418 let installed = self
419 .cache
420 .ensure_installed()
421 .await
422 .map_err(package_failure)?;
423 copy_package_tree(installed.root(), attempt.runtime_path())
424 .map_err(|_| FailureReason::ProcessStartFailed)?;
425 if let Err(error) = self.cache.lease(attempt, installed.version()) {
426 let _ = remove_materialized_package(attempt);
430 return Err(package_failure(error));
431 }
432 Ok(installed.version().clone())
433 }
434
435 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
436 self.cache.release(attempt).map_err(package_failure)
437 }
438
439 fn prune_obsolete_guarded(
440 &self,
441 _authority: PruneAuthority<'_>,
442 current: &RunnerVersion,
443 attempts: &[RunnerAttempt],
444 ) -> Result<(), FailureReason> {
445 for installed in self.cache.installed().map_err(package_failure)? {
446 if installed.version() != current {
447 match self.cache.prune(installed.version(), attempts) {
448 Ok(()) | Err(PackageError::VersionInUse { .. }) => {}
449 Err(error) => return Err(package_failure(error)),
450 }
451 }
452 }
453 Ok(())
454 }
455}
456
457fn package_failure(error: PackageError) -> FailureReason {
458 error.failure_reason().unwrap_or(FailureReason::Other(
459 "runner package cache operation failed".into(),
460 ))
461}
462
463fn package_failure_is_terminal(reason: &FailureReason) -> bool {
464 matches!(
465 reason,
466 FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
467 )
468}
469
470const WORKSPACE_NAME_LEN: usize = 12;
493
494fn workspace_name(id: AttemptId) -> String {
496 let full = id.to_string();
497 full.chars()
498 .filter(|c| *c != '-')
499 .take(WORKSPACE_NAME_LEN)
500 .collect()
501}
502
503#[derive(Debug, Clone)]
510struct Placement {
511 runtime: PathBuf,
512 workspace: AttemptWorkspace,
513}
514
515fn root_failure(error: RunnerRootError) -> LifecycleError {
522 tracing::warn!(
539 error_kind = error.kind(),
540 "the runner root refused this launch, so no attempt was created; the host will \
541 retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
542 with the same path re-runs this check and prints the directory and the \
543 remediation in full"
544 );
545 LifecycleError::Failed(FailureReason::Other(error.to_string()))
546}
547
548fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
557 let held: BTreeSet<u16> = leases
558 .iter()
559 .filter_map(|attempt| attempt.workspace().slot_number())
560 .collect();
561 (1..=ceiling.get())
562 .find(|slot| !held.contains(slot))
563 .and_then(NonZeroU16::new)
564}
565
566fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
574 match fs::symlink_metadata(slot) {
575 Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
581 slot,
582 "is a symbolic link, junction or other reparse point, which could place runner \
583 files outside the configured root",
584 )),
585 Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
586 Ok(_) => Ok(()),
587 Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
588 .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
589 Err(source) => Err(slot_refusal(
590 slot,
591 format!("could not be inspected: {source}"),
592 )),
593 }
594}
595
596fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
608 let unreadable =
609 |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
610 let entries = fs::read_dir(slot).map_err(unreadable)?;
611 let mut refused: Vec<String> = Vec::new();
612 for entry in entries {
613 let entry = entry.map_err(unreadable)?;
614 let name = entry.file_name();
615 let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
616 slot_refusal(
617 slot,
618 format!("entry {name:?} could not be inspected: {source}"),
619 )
620 })?;
621 if is_retainable_work_folder(&name, &metadata) {
626 continue;
627 }
628 refused.push(name.to_string_lossy().into_owned());
629 }
630 if refused.is_empty() {
631 return Ok(());
632 }
633 refused.sort();
634 Err(slot_refusal(
635 slot,
636 format!(
637 "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
638 one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
639 entries listed, or let cleanup and recovery resolve them",
640 if refused.len() == 1 {
641 "an entry"
642 } else {
643 "entries"
644 },
645 refused.join(", ")
646 ),
647 ))
648}
649
650fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
651 LifecycleError::Failed(FailureReason::Other(format!(
652 "the persistent slot {} {detail}",
653 slot.display()
654 )))
655}
656
657fn is_work_folder(name: &OsStr) -> bool {
667 if cfg!(windows) {
668 name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
669 } else {
670 name == OsStr::new(DEFAULT_WORK_FOLDER)
671 }
672}
673
674fn is_link_like(metadata: &fs::Metadata) -> bool {
683 if metadata.file_type().is_symlink() {
684 return true;
685 }
686 #[cfg(windows)]
687 {
688 use std::os::windows::fs::MetadataExt;
689
690 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
691 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
692 }
693 #[cfg(not(windows))]
694 false
695}
696
697fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
705 is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
706}
707
708fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
715 match attempt.workspace() {
716 AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
717 AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
718 .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
719 }
720}
721
722fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
730 #[cfg(windows)]
731 {
732 remove_dir_all::remove_dir_all(path)
733 }
734 #[cfg(not(windows))]
735 {
736 fs::remove_dir_all(path)
737 }
738}
739
740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
755enum SlotRefusal {
756 NotTheJournalledSlot,
758 PolicyRootDisagrees,
760 Containment,
762 SlotNotADirectory,
764 Enumeration,
766 WorkNotADirectory,
768 Deletion,
770 Residue,
772}
773
774impl SlotRefusal {
775 const fn class(self) -> &'static str {
777 match self {
778 Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
779 Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
780 Self::Containment => "slot_escapes_its_root",
781 Self::SlotNotADirectory => "slot_is_not_a_directory",
782 Self::Enumeration => "slot_could_not_be_enumerated",
783 Self::WorkNotADirectory => "retained_work_is_not_a_directory",
784 Self::Deletion => "slot_entry_could_not_be_removed",
785 Self::Residue => "slot_still_holds_runner_state",
786 }
787 }
788
789 const fn remediation(self) -> &'static str {
791 match self {
792 Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
793 "the attempt keeps its slot lease and nothing was removed; correct the \
794 repository's persistent workspace path, or remove the slot directory by hand \
795 once you have confirmed what is in it"
796 }
797 Self::SlotNotADirectory | Self::WorkNotADirectory => {
798 "the attempt keeps its slot lease and nothing was removed; a job replaced the \
799 slot or its `_work` with a link, so inspect it before deleting anything and \
800 treat the retained workspace as untrusted"
801 }
802 Self::Enumeration | Self::Deletion | Self::Residue => {
803 "the attempt keeps its slot lease and will be cleaned again on the next pass; \
804 release whatever is holding the files open, or remove the slot's contents by \
805 hand leaving only `_work`"
806 }
807 }
808 }
809}
810
811#[derive(Debug, Clone, PartialEq, Eq)]
818struct SlotQuarantine {
819 refusal: SlotRefusal,
820 detail: String,
821}
822
823impl SlotQuarantine {
824 fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
825 Self {
826 refusal,
827 detail: detail.into(),
828 }
829 }
830}
831
832impl fmt::Display for SlotQuarantine {
833 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
834 write!(f, "{}; {}", self.detail, self.refusal.remediation())
835 }
836}
837
838fn verify_journalled_slot(
852 runtime: &Path,
853 slot: NonZeroU16,
854 configured: Option<&LocalAbsolutePath>,
855) -> Result<(), SlotQuarantine> {
856 let mislaid = || {
857 SlotQuarantine::new(
858 SlotRefusal::NotTheJournalledSlot,
859 format!(
860 "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
861 runtime.display()
862 ),
863 )
864 };
865 let local = |path: &Path| {
866 path.to_str()
867 .and_then(|raw| LocalAbsolutePath::new(raw).ok())
868 .ok_or_else(mislaid)
869 };
870
871 let runtime_path = local(runtime)?;
872 let root = local(runtime.parent().ok_or_else(mislaid)?)?;
873 let name = AttemptWorkspace::persistent_slot(slot)
880 .slot_directory_name()
881 .expect("a persistent workspace names its slot directory");
882 let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
883 if derived != runtime_path {
884 return Err(mislaid());
885 }
886 if let Some(configured) = configured
887 && configured != &root
888 {
889 return Err(SlotQuarantine::new(
890 SlotRefusal::PolicyRootDisagrees,
891 format!(
892 "the journalled slot {} is not under the repository's configured persistent root \
893 {}",
894 runtime.display(),
895 configured.as_str()
896 ),
897 ));
898 }
899 runner_root::verify_containment(&root, &derived).map_err(|source| {
902 SlotQuarantine::new(
903 SlotRefusal::Containment,
904 format!("the journalled slot is not inside the root it was allocated from: {source}"),
905 )
906 })
907}
908
909fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
920 match fs::symlink_metadata(slot) {
921 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
922 Err(source) => Err(SlotQuarantine::new(
923 SlotRefusal::SlotNotADirectory,
924 format!(
925 "the slot {} could not be inspected: {:?}",
926 slot.display(),
927 source.kind()
928 ),
929 )),
930 Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
931 SlotRefusal::SlotNotADirectory,
932 format!(
933 "the slot {} is a link or a file rather than a real directory",
934 slot.display()
935 ),
936 )),
937 Ok(_) => Ok(true),
938 }
939}
940
941fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
956 let unreadable = |source: std::io::Error| {
957 SlotQuarantine::new(
958 SlotRefusal::Enumeration,
959 format!(
960 "the entries of {} could not be listed: {:?}",
961 slot.display(),
962 source.kind()
963 ),
964 )
965 };
966 for entry in fs::read_dir(slot).map_err(unreadable)? {
967 let name = entry.map_err(unreadable)?.file_name();
968 let path = slot.join(&name);
969 let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
972 continue;
973 };
974 if is_work_folder(&name) {
975 if is_retainable_work_folder(&name, &metadata) {
976 continue;
977 }
978 return Err(SlotQuarantine::new(
979 SlotRefusal::WorkNotADirectory,
980 format!(
981 "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
982 real directory",
983 slot.display()
984 ),
985 ));
986 }
987 remove_slot_entry(&path, &metadata).map_err(|source| {
988 SlotQuarantine::new(
989 SlotRefusal::Deletion,
990 format!(
991 "an entry of {} could not be removed: {:?}",
992 slot.display(),
993 source.kind()
994 ),
995 )
996 })?;
997 }
998 Ok(())
999}
1000
1001fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1007 match fs::symlink_metadata(path) {
1008 Ok(metadata) => Ok(Some(metadata)),
1009 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1010 Err(source) => Err(source),
1011 }
1012}
1013
1014fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1019 let removed = if is_link_like(metadata) {
1020 fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1023 } else if metadata.is_dir() {
1024 remove_runtime_tree(path)
1025 } else {
1026 fs::remove_file(path)
1027 };
1028 match removed {
1029 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1030 other => other,
1031 }
1032}
1033
1034fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1051 let unreadable = |source: std::io::Error| {
1052 SlotQuarantine::new(
1053 SlotRefusal::Enumeration,
1054 format!(
1055 "the entries of {} could not be listed to verify the scrub: {:?}",
1056 slot.display(),
1057 source.kind()
1058 ),
1059 )
1060 };
1061 let mut residue = 0_usize;
1062 let mut named: Vec<String> = Vec::new();
1063 for entry in fs::read_dir(slot).map_err(unreadable)? {
1064 let name = entry.map_err(unreadable)?.file_name();
1065 let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1069 continue;
1070 };
1071 if is_retainable_work_folder(&name, &metadata) {
1072 continue;
1073 }
1074 residue = residue.saturating_add(1);
1075 if name
1076 .to_string_lossy()
1077 .starts_with(RestrictiveHandoff::NAME_PREFIX)
1078 {
1079 named.push("an encoded JIT handoff".to_owned());
1080 }
1081 }
1082 named.extend(
1083 SENSITIVE_SLOT_ENTRIES
1084 .iter()
1085 .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1086 .map(|entry| format!("`{entry}`")),
1087 );
1088 if residue == 0 && named.is_empty() {
1089 return Ok(());
1090 }
1091 named.sort_unstable();
1092 named.dedup();
1093 Err(SlotQuarantine::new(
1094 SlotRefusal::Residue,
1095 residue_detail(slot, residue, &named),
1096 ))
1097}
1098
1099fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1109 if residue == 0 {
1110 format!(
1114 "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1115 cleanup",
1116 slot.display(),
1117 named.join(", ")
1118 )
1119 } else {
1120 format!(
1121 "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1122 if residue == 1 { "y" } else { "ies" },
1123 slot.display(),
1124 if named.is_empty() {
1125 String::new()
1126 } else {
1127 format!(", including {}", named.join(", "))
1128 }
1129 )
1130 }
1131}
1132
1133fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1134 match outcome {
1135 AttemptOutcome::Failed {
1136 reason: FailureReason::JitExpired,
1137 } => Some("jit_expired_replacement"),
1138 AttemptOutcome::Failed {
1139 reason: FailureReason::ProcessExitedUnexpectedly,
1140 } => Some("exit_before_acceptance_replacement"),
1141 _ => None,
1142 }
1143}
1144
1145fn copy_package_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
1160 if source.join(DEFAULT_WORK_FOLDER).exists() {
1161 return Err(std::io::Error::new(
1162 std::io::ErrorKind::InvalidData,
1163 "a cached runner package contains a _work folder, which means it was used \
1164 to run a job before it was archived; the cache must only contain clean \
1165 extracts to prevent data leakage",
1166 ));
1167 }
1168
1169 #[cfg(unix)]
1170 {
1171 let status = std::process::Command::new("cp")
1172 .arg("-a")
1173 .arg(format!("{}/.", source.display()))
1174 .arg(destination)
1175 .status()?;
1176 if status.success() {
1177 Ok(())
1178 } else {
1179 Err(std::io::Error::other("cp failed"))
1180 }
1181 }
1182 #[cfg(not(unix))]
1183 copy_package_entries(source, destination, true)
1184}
1185
1186#[cfg(not(unix))]
1187fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
1188 fs::create_dir_all(destination)?;
1189 for entry in fs::read_dir(source)? {
1190 let entry = entry?;
1191 if top_level && is_work_folder(&entry.file_name()) {
1192 return Err(std::io::Error::new(
1193 std::io::ErrorKind::InvalidData,
1194 format!(
1195 "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
1196 it would overwrite the job workspace a persistent slot retains"
1197 ),
1198 ));
1199 }
1200 let target = destination.join(entry.file_name());
1201 if entry.file_type()?.is_dir() {
1202 copy_package_entries(&entry.path(), &target, false)?;
1203 } else {
1204 fs::copy(entry.path(), target)?;
1205 }
1206 }
1207 Ok(())
1208}
1209
1210#[derive(Debug, Clone, PartialEq, Eq)]
1213pub struct ProcessStartFailure {
1214 pub reason: FailureReason,
1215 pub retryable: bool,
1218 pub live_pid: Option<u32>,
1221}
1222
1223impl ProcessStartFailure {
1224 fn before_spawn(reason: FailureReason) -> Self {
1225 Self {
1226 reason,
1227 retryable: true,
1228 live_pid: None,
1229 }
1230 }
1231
1232 fn after_spawn_stopped() -> Self {
1233 Self {
1234 reason: FailureReason::ProcessStartFailed,
1235 retryable: false,
1236 live_pid: None,
1237 }
1238 }
1239
1240 fn after_spawn_live(pid: u32) -> Self {
1241 Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
1242 }
1243
1244 fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
1245 Self {
1246 reason,
1247 retryable: false,
1248 live_pid: Some(pid),
1249 }
1250 }
1251}
1252
1253pub trait ProcessSupervisor: fmt::Debug + Send + Sync {
1254 fn spawn(
1255 &self,
1256 attempt: &RunnerAttempt,
1257 config: &EncodedJitConfig,
1258 ) -> Result<u32, ProcessStartFailure>;
1259 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
1260 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
1263 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
1266 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1267 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
1268 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1269}
1270
1271#[derive(Debug, Default)]
1274pub struct NativeProcesses {
1275 children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
1276 successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
1277 #[cfg(test)]
1278 post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
1279 #[cfg(test)]
1280 post_spawn_reaps: std::sync::atomic::AtomicUsize,
1281 #[cfg(test)]
1282 post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
1283 #[cfg(test)]
1284 use_long_lived_test_listener: std::sync::atomic::AtomicBool,
1285}
1286
1287#[cfg(test)]
1288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1289enum PostSpawnBoundary {
1290 HandoffDelete,
1291 IdentitySerialize,
1292 IdentityWrite,
1293 ChildMapInsert,
1294}
1295
1296impl NativeProcesses {
1297 #[must_use]
1298 pub fn new() -> Self {
1299 Self::default()
1300 }
1301
1302 #[cfg(test)]
1303 fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
1304 self.post_spawn_faults.lock().unwrap().push_back(boundary);
1305 }
1306
1307 #[cfg(test)]
1308 fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
1309 let mut faults = self.post_spawn_faults.lock().unwrap();
1310 if faults.front() == Some(&boundary) {
1311 faults.pop_front();
1312 true
1313 } else {
1314 false
1315 }
1316 }
1317
1318 #[cfg(test)]
1319 fn fail_post_spawn_stops(&self, count: usize) {
1320 self.post_spawn_stop_failures
1321 .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
1322 }
1323
1324 #[cfg(test)]
1325 fn fail_next_post_spawn_stop(&self) {
1326 self.fail_post_spawn_stops(1);
1327 }
1328
1329 #[cfg(test)]
1330 fn use_long_lived_test_listener(&self) {
1331 self.use_long_lived_test_listener
1332 .store(true, std::sync::atomic::Ordering::SeqCst);
1333 }
1334
1335 fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
1336 #[cfg(test)]
1337 if self
1338 .post_spawn_stop_failures
1339 .fetch_update(
1340 std::sync::atomic::Ordering::SeqCst,
1341 std::sync::atomic::Ordering::SeqCst,
1342 |left| if left > 0 { Some(left - 1) } else { None },
1343 )
1344 .is_ok()
1345 {
1346 return Err(FailureReason::Other("injected runner stop failure".into()));
1347 }
1348 child
1349 .stop(Duration::from_secs(1))
1350 .map(|_| ())
1351 .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
1352 }
1353
1354 fn abort_spawned_child(
1355 &self,
1356 mut child: ChildProcess,
1357 attempt: &RunnerAttempt,
1358 remove_identity: bool,
1359 ) -> ProcessStartFailure {
1360 let mut reaped = self.stop_spawned_child(&mut child).is_ok();
1361 if reaped {
1362 if remove_identity {
1363 Self::remove_identity_files(attempt);
1364 }
1365 } else {
1366 let identity_durable =
1369 serde_json::to_vec(child.identity())
1370 .ok()
1371 .is_some_and(|identity| {
1372 self.persist_identity(attempt, &identity).is_ok()
1373 || self.persist_fallback_identity(attempt, &identity).is_ok()
1374 });
1375 if !identity_durable {
1376 for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
1381 if self.stop_spawned_child(&mut child).is_ok() {
1382 reaped = true;
1383 break;
1384 }
1385 }
1386 if !reaped {
1387 let pid = child.pid();
1392 let marker = write_durable_file(
1393 &Self::unresolved_process_path(attempt),
1394 pid.to_string().as_bytes(),
1395 );
1396 self.children
1397 .lock()
1398 .unwrap_or_else(std::sync::PoisonError::into_inner)
1399 .insert(attempt.id, child);
1400 let reason = if marker.is_ok() {
1401 FailureReason::Other(
1402 "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
1403 .into(),
1404 )
1405 } else {
1406 FailureReason::Other(
1407 "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
1408 .into(),
1409 )
1410 };
1411 return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
1412 }
1413 } else {
1414 let pid = child.pid();
1415 self.children
1416 .lock()
1417 .unwrap_or_else(std::sync::PoisonError::into_inner)
1418 .insert(attempt.id, child);
1419 return ProcessStartFailure::after_spawn_live(pid);
1420 }
1421 }
1422 #[cfg(test)]
1423 if reaped {
1424 self.post_spawn_reaps
1425 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1426 }
1427 #[cfg(not(test))]
1428 let _ = reaped;
1429 ProcessStartFailure::after_spawn_stopped()
1430 }
1431
1432 fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
1433 attempt.runtime_path().join(IDENTITY_FILE)
1434 }
1435
1436 fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
1437 attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
1438 }
1439
1440 fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
1441 attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
1442 }
1443
1444 fn remove_identity_files(attempt: &RunnerAttempt) {
1445 let _ = fs::remove_file(Self::identity_path(attempt));
1446 let _ = fs::remove_file(Self::fallback_identity_path(attempt));
1447 let _ = fs::remove_file(Self::unresolved_process_path(attempt));
1448 }
1449
1450 fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
1451 self.persist_identity_at(&Self::identity_path(attempt), bytes)
1452 }
1453
1454 fn persist_fallback_identity(
1455 &self,
1456 attempt: &RunnerAttempt,
1457 bytes: &[u8],
1458 ) -> std::io::Result<()> {
1459 self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
1460 }
1461
1462 fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1463 #[cfg(test)]
1464 if self.faults_at(PostSpawnBoundary::IdentityWrite) {
1465 return Err(std::io::Error::other("injected identity write failure"));
1466 }
1467 write_durable_file(path, bytes)
1468 }
1469
1470 fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
1471 attempt.runtime_path().join(TERMINATE_INTENT_FILE)
1472 }
1473
1474 fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
1475 match Self::read_identity_at(&Self::identity_path(attempt))? {
1476 Some(identity) => Ok(Some(identity)),
1477 None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
1478 }
1479 }
1480
1481 fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
1482 match fs::read(path) {
1483 Ok(bytes) => serde_json::from_slice(&bytes)
1484 .map(Some)
1485 .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
1486 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1487 Err(_) => Err(FailureReason::Other(
1488 "process identity journal could not be read".into(),
1489 )),
1490 }
1491 }
1492}
1493
1494impl ProcessSupervisor for NativeProcesses {
1495 fn spawn(
1496 &self,
1497 attempt: &RunnerAttempt,
1498 config: &EncodedJitConfig,
1499 ) -> Result<u32, ProcessStartFailure> {
1500 let handoff = RestrictiveHandoff::create(
1501 attempt.runtime_path(),
1502 SecretString::from(config.expose().to_owned()),
1503 )
1504 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1505 #[cfg(windows)]
1506 let program = attempt
1507 .runtime_path()
1508 .join("bin")
1509 .join("Runner.Listener.exe");
1510 #[cfg(not(windows))]
1511 let program = attempt.runtime_path().join("bin").join("Runner.Listener");
1512 if !program.is_file() {
1515 return Err(ProcessStartFailure::before_spawn(
1516 FailureReason::ProcessStartFailed,
1517 ));
1518 }
1519 #[cfg(test)]
1520 let spec = if self
1521 .use_long_lived_test_listener
1522 .load(std::sync::atomic::Ordering::SeqCst)
1523 {
1524 SpawnSpec::new(program)
1525 .args([
1526 "--ignored",
1527 "--exact",
1528 "lifecycle::tests::long_lived_native_listener_helper",
1529 "--nocapture",
1530 ])
1531 .env(
1532 "RUNNER_MANAGER_TEST_LISTENER_READY",
1533 attempt.runtime_path().join(TEST_LISTENER_READY),
1534 )
1535 .working_dir(attempt.runtime_path())
1536 } else {
1537 runner_listener_spec(program, attempt.runtime_path())
1538 };
1539 #[cfg(not(test))]
1540 let spec = runner_listener_spec(program, attempt.runtime_path());
1541 let child = spec
1542 .spawn_runner_with_handoff(&handoff)
1543 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1544 #[cfg(test)]
1546 if self.faults_at(PostSpawnBoundary::HandoffDelete) {
1547 drop(handoff);
1548 return Err(self.abort_spawned_child(child, attempt, false));
1549 }
1550 if handoff.delete().is_err() {
1551 return Err(self.abort_spawned_child(child, attempt, false));
1552 }
1553 #[cfg(test)]
1554 if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
1555 return Err(self.abort_spawned_child(child, attempt, false));
1556 }
1557 let identity = match serde_json::to_vec(child.identity()) {
1558 Ok(identity) => identity,
1559 Err(_) => {
1560 return Err(self.abort_spawned_child(child, attempt, false));
1561 }
1562 };
1563 if self.persist_identity(attempt, &identity).is_err() {
1564 return Err(self.abort_spawned_child(child, attempt, true));
1565 }
1566 let pid = child.pid();
1567 #[cfg(test)]
1568 if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
1569 return Err(self.abort_spawned_child(child, attempt, true));
1570 }
1571 let mut children = self
1572 .children
1573 .lock()
1574 .unwrap_or_else(std::sync::PoisonError::into_inner);
1575 children.insert(attempt.id, child);
1576 Ok(pid)
1577 }
1578
1579 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
1580 let mut children = self
1581 .children
1582 .lock()
1583 .unwrap_or_else(std::sync::PoisonError::into_inner);
1584 if let Some(child) = children.get_mut(&attempt.id) {
1585 return match child
1586 .try_exit_status()
1587 .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
1588 {
1589 None => Ok(true),
1590 Some(status) => {
1591 if let Ok(mut exits) = self.successful_exits.lock() {
1592 exits.insert(attempt.id, status.success());
1593 }
1594 Ok(false)
1595 }
1596 };
1597 }
1598 let Some(identity) = Self::read_identity(attempt)? else {
1599 if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
1600 return Err(FailureReason::Other(
1601 "runner process identity is missing; refusing recovery until the process is resolved"
1602 .into(),
1603 ));
1604 }
1605 return Ok(false);
1606 };
1607 match identity.recheck() {
1608 Ok(Adoption::Live) => Ok(true),
1609 Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
1610 Err(_) => Ok(false),
1611 }
1612 }
1613
1614 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
1615 Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
1616 }
1617
1618 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
1619 self.successful_exits
1620 .lock()
1621 .ok()
1622 .and_then(|exits| exits.get(&attempt.id).copied())
1623 .unwrap_or(false)
1624 }
1625
1626 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
1627 let path = Self::intent_path(attempt);
1628 write_durable_file(&path, b"registration-timeout\n")
1629 .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
1630 }
1631
1632 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
1633 Self::intent_path(attempt).is_file()
1634 }
1635
1636 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
1637 let mut children = self
1638 .children
1639 .lock()
1640 .unwrap_or_else(std::sync::PoisonError::into_inner);
1641 if let Some(child) = children.get_mut(&attempt.id) {
1642 child
1643 .stop(Duration::from_secs(10))
1644 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
1645 return Ok(());
1646 }
1647 let Some(identity) = Self::read_identity(attempt)? else {
1648 return Ok(());
1649 };
1650 match identity
1651 .terminate(Duration::from_secs(10))
1652 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
1653 {
1654 Termination::Terminated | Termination::AlreadyGone => Ok(()),
1655 Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
1656 "runner PID was recycled; refusing to signal it".into(),
1657 )),
1658 }
1659 }
1660}
1661
1662pub struct LifecyclePorts {
1663 pub store: Arc<dyn Store>,
1664 pub github: Arc<dyn LifecycleGithub>,
1665 pub packages: Arc<dyn RuntimePackages>,
1666 pub processes: Arc<dyn ProcessSupervisor>,
1667 pub clock: Arc<dyn Clock>,
1668 pub demand: Arc<dyn DemandPersistence>,
1669 pub delay: Arc<dyn RetryDelay>,
1670 pub events: Arc<dyn AttemptEventSink>,
1671 pub reconcile_events: Arc<dyn EventSink>,
1672}
1673
1674impl fmt::Debug for LifecyclePorts {
1675 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1676 f.debug_struct("LifecyclePorts")
1677 .field("store", &self.store)
1678 .field("github", &self.github)
1679 .field("packages", &self.packages)
1680 .field("processes", &self.processes)
1681 .finish_non_exhaustive()
1682 }
1683}
1684
1685#[derive(Debug, thiserror::Error)]
1686pub enum LifecycleError {
1687 #[error("attempt journal operation failed")]
1688 Journal,
1689 #[error("attempt {0} is not in the journal")]
1690 Missing(AttemptId),
1691 #[error("attempt lifecycle transition was refused")]
1692 Transition,
1693 #[error("startup recovery has not completed")]
1694 RecoveryIncomplete,
1695 #[error("the persistent slot was not cleaned: {detail}")]
1705 SlotQuarantined {
1706 class: &'static str,
1708 detail: String,
1709 },
1710 #[error("runner lifecycle failed: {0}")]
1711 Failed(FailureReason),
1712}
1713
1714impl LifecycleError {
1715 fn reason(&self) -> FailureReason {
1716 match self {
1717 Self::Failed(reason) => reason.clone(),
1718 Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
1719 Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
1720 Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
1721 Self::Transition => FailureReason::Other("attempt transition was refused".into()),
1722 Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
1725 }
1726 }
1727}
1728
1729#[derive(Debug)]
1731pub struct LifecycleLauncher {
1732 host_id: HostId,
1733 app_paths: runner_manager_platform::paths::AppPaths,
1734 diagnostics_root: PathBuf,
1735 runner_group_id: u64,
1736 timeouts: RecoveryTimeouts,
1737 retry: RetryPolicy,
1738 cancel: CancelToken,
1739 ports: LifecyclePorts,
1740 recovery_complete: Mutex<bool>,
1741 versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
1742 pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
1743}
1744
1745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1746enum ReconcileProgress {
1747 Reconciled,
1748 Deferred,
1749 Replacement {
1750 attempt: AttemptId,
1751 operation: &'static str,
1752 },
1753}
1754
1755impl LifecycleLauncher {
1756 #[must_use]
1757 pub fn new(
1758 host_id: HostId,
1759 app_paths: runner_manager_platform::paths::AppPaths,
1760 diagnostics_root: impl Into<PathBuf>,
1761 runner_group_id: u64,
1762 timeouts: RecoveryTimeouts,
1763 retry: RetryPolicy,
1764 ports: LifecyclePorts,
1765 ) -> Self {
1766 Self {
1767 host_id,
1768 app_paths,
1769 diagnostics_root: diagnostics_root.into(),
1770 runner_group_id,
1771 timeouts,
1772 retry,
1773 cancel: CancelToken::new(),
1774 ports,
1775 recovery_complete: Mutex::new(false),
1776 versions: Mutex::new(BTreeMap::new()),
1777 pending_replacements: Mutex::new(BTreeMap::new()),
1778 }
1779 }
1780
1781 pub async fn recover_startup(
1785 &self,
1786 policies: &[ScalePolicy],
1787 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
1788 let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
1789 let attempts = self
1790 .ports
1791 .store
1792 .attempts()
1793 .map_err(|_| LifecycleError::Journal)?;
1794 let mut unresolved = false;
1795 for attempt in attempts {
1796 let Some(policy) = by_id.get(&attempt.policy_id) else {
1797 if !attempt.is_terminal() && attempt.state() != AttemptState::Cleaned {
1798 unresolved = true;
1799 }
1800 continue;
1801 };
1802 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
1803 match self.reconcile_one(policy, attempt).await? {
1804 ReconcileProgress::Deferred => unresolved = true,
1805 ReconcileProgress::Replacement { attempt, operation } => {
1806 self.pending_replacements
1807 .lock()
1808 .map_err(|_| LifecycleError::Journal)?
1809 .insert(
1810 attempt,
1811 ReplacementIntent {
1812 policy: policy.id,
1813 previous_attempt: attempt,
1814 operation,
1815 },
1816 );
1817 }
1818 ReconcileProgress::Reconciled => {}
1819 }
1820 }
1821 if unresolved {
1822 return Err(LifecycleError::RecoveryIncomplete);
1823 }
1824 *self
1825 .recovery_complete
1826 .lock()
1827 .map_err(|_| LifecycleError::Journal)? = true;
1828 Ok(self
1829 .pending_replacements
1830 .lock()
1831 .map_err(|_| LifecycleError::Journal)?
1832 .values()
1833 .copied()
1834 .collect())
1835 }
1836
1837 pub async fn supervise(
1839 &self,
1840 policy: &ScalePolicy,
1841 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
1842 let mut replacements = Vec::new();
1843 self.pending_replacements
1844 .lock()
1845 .map_err(|_| LifecycleError::Journal)?
1846 .retain(|_, intent| {
1847 if intent.policy == policy.id {
1848 replacements.push(*intent);
1849 false
1850 } else {
1851 true
1852 }
1853 });
1854 let attempts = self
1855 .ports
1856 .store
1857 .attempts_for_policy(policy.id)
1858 .map_err(|_| LifecycleError::Journal)?;
1859 for attempt in attempts {
1860 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
1861 if let ReconcileProgress::Replacement { attempt, operation } =
1862 self.reconcile_one(policy, attempt).await?
1863 {
1864 replacements.push(ReplacementIntent {
1865 policy: policy.id,
1866 previous_attempt: attempt,
1867 operation,
1868 });
1869 }
1870 }
1871 Ok(replacements)
1872 }
1873
1874 async fn reconcile_one(
1875 &self,
1876 policy: &ScalePolicy,
1877 mut attempt: RunnerAttempt,
1878 ) -> Result<ReconcileProgress, LifecycleError> {
1879 if attempt.state() == AttemptState::Cleaned {
1880 if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
1889 && attempt.runtime_path().exists()
1890 {
1891 self.scrub_workspace(&attempt)?;
1892 }
1893 return Ok(ReconcileProgress::Reconciled);
1894 }
1895 if attempt.is_terminal() {
1896 self.clean_or_quarantine(&mut attempt)?;
1897 return Ok(ReconcileProgress::Reconciled);
1898 }
1899 let process_alive = self
1900 .ports
1901 .processes
1902 .is_alive(&attempt)
1903 .map_err(LifecycleError::Failed)?;
1904 let github = self
1905 .ports
1906 .github
1907 .observe(&policy.target, attempt.id, &self.cancel)
1908 .await;
1909
1910 if let Some(runner_id) = github.runner_id
1915 && read_runner_id(attempt.runtime_path()).is_none()
1916 {
1917 write_runner_id(attempt.runtime_path(), runner_id)?;
1918 self.ports
1919 .events
1920 .emit(AttemptEvent::RemoteIdentityRecovered {
1921 attempt: attempt.id,
1922 runner_id,
1923 });
1924 }
1925
1926 if attempt.state() == AttemptState::JitReceived
1931 && process_alive
1932 && let Some(pid) = self
1933 .ports
1934 .processes
1935 .recovered_pid(&attempt)
1936 .map_err(LifecycleError::Failed)?
1937 {
1938 attempt
1939 .started(pid, self.ports.clock.now())
1940 .map_err(|_| LifecycleError::Transition)?;
1941 self.record(&attempt)?;
1942 }
1943
1944 if attempt.state() == AttemptState::Busy
1949 && !process_alive
1950 && github.status == GithubRunnerObservation::NotRegistered
1951 && self.ports.processes.completed_successfully(&attempt)
1952 {
1953 self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
1954 self.clean_or_quarantine(&mut attempt)?;
1955 return Ok(ReconcileProgress::Reconciled);
1956 }
1957
1958 if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
1961 self.deregister_runner(policy, &attempt).await;
1962 self.conclude(
1963 &mut attempt,
1964 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
1965 )?;
1966 self.clean_or_quarantine(&mut attempt)?;
1967 return Ok(ReconcileProgress::Replacement {
1968 attempt: attempt.id,
1969 operation: "registration_timeout_replacement",
1970 });
1971 }
1972
1973 if matches!(
1979 attempt.state(),
1980 AttemptState::Allocated | AttemptState::JitReceived
1981 ) && !process_alive
1982 && matches!(github.status, GithubRunnerObservation::Registered { .. })
1983 {
1984 if attempt.state() == AttemptState::Allocated {
1985 attempt
1986 .jit_received(self.ports.clock.now())
1987 .map_err(|_| LifecycleError::Transition)?;
1988 self.record(&attempt)?;
1989 }
1990 self.deregister_runner(policy, &attempt).await;
1991 self.conclude(
1992 &mut attempt,
1993 AttemptOutcome::failed(FailureReason::JitExpired),
1994 )?;
1995 self.clean_or_quarantine(&mut attempt)?;
1996 return Ok(ReconcileProgress::Replacement {
1997 attempt: attempt.id,
1998 operation: "jit_expired_replacement",
1999 });
2000 }
2001
2002 match recovery_decision(
2003 &attempt,
2004 RecoveryObservation {
2005 process_alive,
2006 github: github.status,
2007 },
2008 self.timeouts,
2009 self.ports.clock.as_ref(),
2010 ) {
2011 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
2012 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
2013 RecoveryDecision::Adopt => {
2014 self.ports.events.emit(AttemptEvent::Adopted {
2015 attempt: attempt.id,
2016 });
2017 Ok(ReconcileProgress::Reconciled)
2018 }
2019 RecoveryDecision::Clean => {
2020 self.clean_or_quarantine(&mut attempt)?;
2021 Ok(ReconcileProgress::Reconciled)
2022 }
2023 RecoveryDecision::Observe(state) => {
2024 let runner_id = attempt
2025 .github_runner_id()
2026 .or(github.runner_id)
2027 .or_else(|| read_runner_id(attempt.runtime_path()))
2028 .ok_or(LifecycleError::Transition)?;
2029 match state {
2030 AttemptState::JitReceived => attempt
2031 .jit_received(self.ports.clock.now())
2032 .map_err(|_| LifecycleError::Transition)?,
2033 AttemptState::Starting => {
2034 let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
2035 attempt
2036 .started(pid, self.ports.clock.now())
2037 .map_err(|_| LifecycleError::Transition)?;
2038 }
2039 AttemptState::Idle => attempt
2040 .registered_idle(runner_id, self.ports.clock.now())
2041 .map_err(|_| LifecycleError::Transition)?,
2042 AttemptState::Busy => attempt
2043 .assigned_job(runner_id, self.ports.clock.now())
2044 .map_err(|_| LifecycleError::Transition)?,
2045 _ => return Err(LifecycleError::Transition),
2046 }
2047 self.record(&attempt)?;
2048 Ok(ReconcileProgress::Reconciled)
2049 }
2050 RecoveryDecision::Conclude(outcome) => {
2051 let replacement = replacement_operation(&outcome);
2052 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
2057 self.deregister_runner(policy, &attempt).await;
2058 }
2059 self.conclude(&mut attempt, outcome)?;
2060 self.clean_or_quarantine(&mut attempt)?;
2061 Ok(
2062 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
2063 ReconcileProgress::Replacement {
2064 attempt: attempt.id,
2065 operation,
2066 }
2067 }),
2068 )
2069 }
2070 RecoveryDecision::Terminate(payload) => {
2071 let idle_exit = payload.is_idle_exit();
2081 self.ports
2082 .processes
2083 .record_terminate_intent(&attempt)
2084 .map_err(LifecycleError::Failed)?;
2085 self.ports.events.emit(AttemptEvent::TerminateIntent {
2086 attempt: attempt.id,
2087 });
2088 self.ports
2089 .processes
2090 .terminate(&attempt)
2091 .map_err(LifecycleError::Failed)?;
2092 if self
2093 .ports
2094 .processes
2095 .is_alive(&attempt)
2096 .map_err(LifecycleError::Failed)?
2097 {
2098 return Ok(ReconcileProgress::Deferred);
2099 }
2100 self.ports.events.emit(AttemptEvent::Terminated {
2101 attempt: attempt.id,
2102 });
2103 let outcome = if idle_exit {
2109 AttemptOutcome::ExitedIdleWithoutWork
2110 } else {
2111 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
2112 };
2113 self.deregister_runner(policy, &attempt).await;
2114 self.conclude(&mut attempt, outcome)?;
2115 self.clean_or_quarantine(&mut attempt)?;
2116 if idle_exit {
2120 Ok(ReconcileProgress::Reconciled)
2121 } else {
2122 Ok(ReconcileProgress::Replacement {
2123 attempt: attempt.id,
2124 operation: "registration_timeout_replacement",
2125 })
2126 }
2127 }
2128 }
2129 }
2130
2131 fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2132 self.ports
2133 .store
2134 .record_attempt(attempt)
2135 .map_err(|_| LifecycleError::Journal)?;
2136 self.ports.events.emit(AttemptEvent::State {
2137 attempt: attempt.id,
2138 state: attempt.state(),
2139 });
2140 Ok(())
2141 }
2142
2143 async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
2164 let Some(runner_id) = attempt
2165 .github_runner_id()
2166 .or_else(|| read_runner_id(attempt.runtime_path()))
2167 else {
2168 return;
2169 };
2170 if self
2171 .ports
2172 .github
2173 .deregister(&policy.target, runner_id, &self.cancel)
2174 .await
2175 {
2176 self.ports.events.emit(AttemptEvent::Deregistered {
2177 attempt: attempt.id,
2178 runner_id,
2179 });
2180 } else {
2181 tracing::warn!(
2182 attempt = %attempt.id,
2183 runner_id,
2184 "the runner registration could not be removed from GitHub; it will show in the \
2185 target's runner settings until GitHub retires it or a later pass removes it"
2186 );
2187 }
2188 }
2189
2190 fn conclude(
2191 &self,
2192 attempt: &mut RunnerAttempt,
2193 outcome: AttemptOutcome,
2194 ) -> Result<(), LifecycleError> {
2195 attempt
2196 .conclude(outcome.clone(), self.ports.clock.now())
2197 .map_err(|_| LifecycleError::Transition)?;
2198 self.record(attempt)?;
2199 self.ports.events.emit(AttemptEvent::Concluded {
2200 attempt: attempt.id,
2201 outcome: OutcomeKind::of(&outcome),
2202 });
2203 Ok(())
2204 }
2205
2206 fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2221 match self.clean_attempt(attempt) {
2222 Err(LifecycleError::SlotQuarantined { class, .. }) => {
2223 self.ports
2224 .reconcile_events
2225 .emit(LifecycleEvent::AttemptCleanFailed {
2226 policy: attempt.policy_id,
2227 attempt: attempt.id,
2228 reason: class,
2229 });
2230 Ok(())
2231 }
2232 other => other,
2233 }
2234 }
2235
2236 fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2237 let outcome = attempt
2238 .outcome()
2239 .cloned()
2240 .ok_or(LifecycleError::Transition)?;
2241 self.preserve_diagnostics(attempt, &outcome)?;
2242 self.scrub_workspace(attempt)?;
2243 self.ports
2244 .packages
2245 .release(attempt.id)
2246 .map_err(LifecycleError::Failed)?;
2247 attempt
2248 .clean(self.ports.clock.now())
2249 .map_err(|_| LifecycleError::Transition)?;
2250 self.record(attempt)?;
2251 let kind = OutcomeKind::of(&outcome);
2252 self.ports.events.emit(AttemptEvent::Cleaned {
2253 attempt: attempt.id,
2254 outcome: kind,
2255 });
2256 self.ports
2257 .reconcile_events
2258 .emit(LifecycleEvent::AttemptCleaned {
2259 policy: attempt.policy_id,
2260 attempt: attempt.id,
2261 outcome: kind,
2262 });
2263 Ok(())
2264 }
2265
2266 fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2276 #[cfg(test)]
2277 {
2278 if matches!(
2282 std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
2283 Ok("skip_workspace_cleanup" | "reuse_job_workspace")
2284 ) {
2285 return Ok(());
2286 }
2287 }
2288 match attempt.workspace() {
2289 AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
2290 Ok(()) => Ok(()),
2291 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2292 Err(_) => Err(LifecycleError::Failed(FailureReason::Other(
2293 "attempt workspace could not be removed".into(),
2294 ))),
2295 },
2296 AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
2297 }
2298 }
2299
2300 fn scrub_persistent_slot(
2313 &self,
2314 attempt: &RunnerAttempt,
2315 slot: NonZeroU16,
2316 ) -> Result<(), LifecycleError> {
2317 let configured = self
2322 .ports
2323 .store
2324 .policy(attempt.policy_id)
2325 .map_err(|_| LifecycleError::Journal)?
2326 .and_then(|policy| match policy.workspace_policy() {
2327 WorkspacePolicy::Persistent { root } => Some(root.clone()),
2328 WorkspacePolicy::Ephemeral => None,
2329 });
2330 let runtime = attempt.runtime_path();
2331 self.quarantine_on_refusal(
2332 attempt,
2333 verify_journalled_slot(runtime, slot, configured.as_ref())
2334 .and_then(|()| slot_is_present(runtime))
2335 .and_then(|present| {
2336 if present {
2337 scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
2338 } else {
2339 Ok(())
2340 }
2341 }),
2342 )
2343 }
2344
2345 fn quarantine_on_refusal(
2354 &self,
2355 attempt: &RunnerAttempt,
2356 outcome: Result<(), SlotQuarantine>,
2357 ) -> Result<(), LifecycleError> {
2358 let Err(quarantine) = outcome else {
2359 return Ok(());
2360 };
2361 let detail = quarantine.to_string();
2362 tracing::warn!(
2363 attempt = %attempt.id,
2364 policy = %attempt.policy_id,
2365 slot = attempt.workspace().slot_number(),
2366 refusal = quarantine.refusal.class(),
2367 "{detail}"
2368 );
2369 Err(LifecycleError::SlotQuarantined {
2370 class: quarantine.refusal.class(),
2371 detail,
2372 })
2373 }
2374
2375 fn preserve_diagnostics(
2376 &self,
2377 attempt: &RunnerAttempt,
2378 outcome: &AttemptOutcome,
2379 ) -> Result<(), LifecycleError> {
2380 fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
2381 LifecycleError::Failed(FailureReason::Other(
2382 "diagnostics directory could not be created".into(),
2383 ))
2384 })?;
2385 let diagnostic = format!(
2388 "attempt_id={}\npolicy_id={}\noutcome={}\n",
2389 attempt.id,
2390 attempt.policy_id,
2391 OutcomeKind::of(outcome).as_str()
2392 );
2393 fs::write(
2394 self.diagnostics_root.join(format!("{}.log", attempt.id)),
2395 diagnostic,
2396 )
2397 .map_err(|_| {
2398 LifecycleError::Failed(FailureReason::Other(
2399 "redacted diagnostics could not be preserved".into(),
2400 ))
2401 })
2402 }
2403
2404 async fn materialize_with_retry(
2405 &self,
2406 policy: &ScalePolicy,
2407 attempt: &RunnerAttempt,
2408 ) -> Result<RunnerVersion, FailureReason> {
2409 let mut issued = 0_u32;
2410 loop {
2411 issued = issued.saturating_add(1);
2412 match self.ports.packages.materialize(attempt).await {
2413 Ok(version) => return Ok(version),
2414 Err(reason)
2415 if package_failure_is_terminal(&reason)
2416 || issued >= self.retry.max_attempts.max(1) =>
2417 {
2418 return Err(reason);
2419 }
2420 Err(reason) => {
2421 if !self.ports.demand.persists(policy.id).await {
2422 return Err(reason);
2423 }
2424 let delay = self.retry.delay(issued);
2425 self.ports.events.emit(AttemptEvent::Retry {
2426 attempt: attempt.id,
2427 operation: "package_materialization",
2428 delay,
2429 });
2430 self.ports.delay.wait(delay).await;
2431 if !self.ports.demand.persists(policy.id).await {
2432 return Err(reason);
2433 }
2434 }
2435 }
2436 }
2437 }
2438
2439 async fn register_with_retry(
2440 &self,
2441 policy: &ScalePolicy,
2442 attempt: AttemptId,
2443 request: &JitRunnerRequest,
2444 ) -> Result<JitRegistration, LifecycleError> {
2445 let mut issued = 0_u32;
2446 loop {
2447 issued = issued.saturating_add(1);
2448 match self
2449 .ports
2450 .github
2451 .register(&policy.target, request, &self.cancel)
2452 .await
2453 {
2454 Ok(registration) => return Ok(registration),
2455 Err(error) if error.terminal => {
2456 return Err(LifecycleError::Failed(error.reason));
2457 }
2458 Err(error) => {
2459 if issued >= self.retry.max_attempts.max(1)
2460 || !self.ports.demand.persists(policy.id).await
2461 {
2462 return Err(LifecycleError::Failed(error.reason));
2463 }
2464 let delay = error
2465 .retry_after
2466 .unwrap_or_else(|| self.retry.delay(issued));
2467 self.ports.events.emit(AttemptEvent::Retry {
2468 attempt,
2469 operation: "jit_request",
2470 delay,
2471 });
2472 self.ports.delay.wait(delay).await;
2473 if !self.ports.demand.persists(policy.id).await {
2474 return Err(LifecycleError::Failed(error.reason));
2475 }
2476 }
2477 }
2478 }
2479 }
2480
2481 fn allocate_workspace(
2491 &self,
2492 policy: &ScalePolicy,
2493 id: AttemptId,
2494 ) -> Result<Placement, LifecycleError> {
2495 let placement = match policy.workspace_policy() {
2496 WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
2501 WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
2502 };
2503 if placement.is_ok() {
2506 self.root_accepted(policy.id);
2507 }
2508 placement
2509 }
2510
2511 fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
2518 let host = self
2519 .ports
2520 .store
2521 .host(self.host_id)
2522 .map_err(|_| LifecycleError::Journal)?
2523 .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
2524 Ok(host.runner_root_override.clone())
2525 }
2526
2527 fn effective_host_root(
2532 &self,
2533 policy: &ScalePolicy,
2534 ) -> Result<LocalAbsolutePath, LifecycleError> {
2535 match self.configured_host_root()? {
2536 Some(configured) => Ok(configured),
2537 None => default_runner_root(&self.app_paths).map_err(|error| {
2538 self.root_refused(policy.id, "the platform default runner root", error)
2541 }),
2542 }
2543 }
2544
2545 fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
2558 let _ = runner_manager_platform::service::record_runner_root_refusal(
2559 &self.app_paths,
2560 &policy.to_string(),
2561 self.ports.clock.now(),
2562 error.kind(),
2563 root,
2564 &error.to_string(),
2565 );
2566 root_failure(error)
2567 }
2568
2569 fn root_accepted(&self, policy: PolicyId) {
2576 let _ = runner_manager_platform::service::clear_runner_root_refusal(
2577 &self.app_paths,
2578 &policy.to_string(),
2579 );
2580 }
2581
2582 fn allocate_disposable(
2585 &self,
2586 policy: &ScalePolicy,
2587 id: AttemptId,
2588 ) -> Result<Placement, LifecycleError> {
2589 let effective_root = self.effective_host_root(policy)?;
2590 RootPreflight::new(&self.app_paths)
2591 .check(&RootOwner::Host, &effective_root)
2592 .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
2593 let runtime = effective_root.as_path().join({
2594 #[cfg(test)]
2595 {
2596 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
2597 == Ok("reuse_job_workspace")
2598 {
2599 "mutant-shared-workspace".to_owned()
2600 } else {
2601 workspace_name(id)
2602 }
2603 }
2604 #[cfg(not(test))]
2605 {
2606 workspace_name(id)
2607 }
2608 });
2609 fs::create_dir_all(&runtime)
2610 .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
2611 Ok(Placement {
2612 runtime,
2613 workspace: AttemptWorkspace::Ephemeral,
2614 })
2615 }
2616
2617 fn allocate_persistent_slot(
2629 &self,
2630 policy: &ScalePolicy,
2631 root: &LocalAbsolutePath,
2632 ) -> Result<Placement, LifecycleError> {
2633 let ceiling = policy.max_capacity().ok_or_else(|| {
2636 LifecycleError::Failed(FailureReason::Other(
2637 "a persistent workspace needs the policy's max_capacity to bound its slots"
2638 .to_string(),
2639 ))
2640 })?;
2641 let leases = self
2642 .ports
2643 .store
2644 .slot_leases_for_policy(policy.id)
2645 .map_err(|_| LifecycleError::Journal)?;
2646 let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
2647 LifecycleError::Failed(FailureReason::Other(format!(
2648 "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
2649 not been cleaned, so no slot is free; raise the repository's max capacity, or \
2650 finish cleaning a concluded attempt",
2651 policy.target
2652 )))
2653 })?;
2654 let workspace = AttemptWorkspace::persistent_slot(slot);
2655 let name = workspace
2656 .slot_directory_name()
2657 .expect("a persistent allocation names its slot directory");
2658
2659 let host_root = self
2671 .configured_host_root()?
2672 .or_else(|| default_runner_root(&self.app_paths).ok());
2673 let mut preflight = RootPreflight::new(&self.app_paths);
2674 if let Some(host_root) = host_root {
2675 preflight = preflight.against(RootOwner::Host, host_root);
2676 }
2677 let checked = preflight
2678 .check(&RootOwner::Repository(policy.target.to_string()), root)
2679 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2680 if let Some(leaf) = checked.leaf_to_create() {
2681 fs::create_dir(leaf).map_err(|source| {
2682 LifecycleError::Failed(FailureReason::Other(format!(
2683 "the persistent workspace root {} could not be created: {source}",
2684 leaf.display()
2685 )))
2686 })?;
2687 }
2688
2689 let slot_path = runner_root::derive_child(root, &name)
2693 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2694 create_or_validate_slot(slot_path.as_path())?;
2695 runner_root::verify_containment(root, &slot_path)
2696 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2697 accept_reusable_slot(slot_path.as_path())?;
2698 Ok(Placement {
2699 runtime: slot_path.as_path().to_path_buf(),
2700 workspace,
2701 })
2702 }
2703
2704 fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2716 match self.ports.store.record_attempt(attempt) {
2717 Ok(()) => {
2718 self.ports.events.emit(AttemptEvent::State {
2719 attempt: attempt.id,
2720 state: attempt.state(),
2721 });
2722 Ok(())
2723 }
2724 Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
2725 FailureReason::Other(error.to_string()),
2726 )),
2727 Err(_) => Err(LifecycleError::Journal),
2728 }
2729 }
2730
2731 async fn launch_attempt(
2732 &self,
2733 policy: &ScalePolicy,
2734 allocation_guard: &AllocationGuard,
2735 ) -> Result<RunnerAttempt, LifecycleError> {
2736 if !*self
2737 .recovery_complete
2738 .lock()
2739 .map_err(|_| LifecycleError::Journal)?
2740 {
2741 return Err(LifecycleError::RecoveryIncomplete);
2742 }
2743 let labels = policy
2744 .routing_labels()
2745 .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
2746 let id = AttemptId::new_random();
2747 let placement = self.allocate_workspace(policy, id)?;
2748 let mut attempt = RunnerAttempt::allocate_in(
2749 id,
2750 policy.id,
2751 placement.runtime,
2752 placement.workspace,
2753 self.ports.clock.now(),
2754 );
2755 self.record_allocation(&attempt)?;
2760
2761 let version = match self.materialize_with_retry(policy, &attempt).await {
2762 Ok(version) => version,
2763 Err(reason) => return self.fail_launch(&mut attempt, reason),
2764 };
2765 self.prune_under_allocation_lock(allocation_guard, &version)?;
2766 self.versions
2767 .lock()
2768 .map_err(|_| LifecycleError::Journal)?
2769 .insert(id, version);
2770
2771 let jit_request =
2772 JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
2773 let registration = match self.register_with_retry(policy, id, &jit_request).await {
2774 Ok(registration) => registration,
2775 Err(error) => return self.fail_launch(&mut attempt, error.reason()),
2776 };
2777 let runner_id = registration.runner().id;
2778 write_runner_id(attempt.runtime_path(), runner_id)?;
2779 attempt
2780 .jit_received(self.ports.clock.now())
2781 .map_err(|_| LifecycleError::Transition)?;
2782 self.record(&attempt)?;
2783 let config = registration.into_config();
2784 let mut issued = 0_u32;
2785 let pid = loop {
2786 issued = issued.saturating_add(1);
2787 match self.ports.processes.spawn(&attempt, &config) {
2788 Ok(pid) => break pid,
2789 Err(error) => {
2790 if let Some(pid) = error.live_pid {
2791 attempt
2792 .started(pid, self.ports.clock.now())
2793 .map_err(|_| LifecycleError::Transition)?;
2794 self.record(&attempt)?;
2795 return Err(LifecycleError::Failed(error.reason));
2796 }
2797 if !error.retryable
2798 || issued >= self.retry.max_attempts.max(1)
2799 || !self.ports.demand.persists(policy.id).await
2800 {
2801 return self.fail_launch(&mut attempt, error.reason);
2802 }
2803 let delay = self.retry.delay(issued);
2804 self.ports.events.emit(AttemptEvent::Retry {
2805 attempt: attempt.id,
2806 operation: "process_start",
2807 delay,
2808 });
2809 self.ports.delay.wait(delay).await;
2810 if !self.ports.demand.persists(policy.id).await {
2811 return self.fail_launch(&mut attempt, error.reason);
2812 }
2813 }
2814 }
2815 };
2816 attempt
2817 .started(pid, self.ports.clock.now())
2818 .map_err(|_| LifecycleError::Transition)?;
2819 self.record(&attempt)?;
2820 Ok(attempt)
2821 }
2822
2823 fn fail_launch<T>(
2824 &self,
2825 attempt: &mut RunnerAttempt,
2826 reason: FailureReason,
2827 ) -> Result<T, LifecycleError> {
2828 self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
2829 Err(LifecycleError::Failed(reason))
2830 }
2831
2832 fn prune_under_allocation_lock(
2835 &self,
2836 guard: &AllocationGuard,
2837 version: &RunnerVersion,
2838 ) -> Result<(), LifecycleError> {
2839 let attempts = self
2840 .ports
2841 .store
2842 .attempts()
2843 .map_err(|_| LifecycleError::Journal)?;
2844 self.ports
2845 .packages
2846 .prune_obsolete_guarded(
2847 PruneAuthority::from_launch_request(guard),
2848 version,
2849 &attempts,
2850 )
2851 .map_err(LifecycleError::Failed)
2852 }
2853}
2854
2855#[async_trait]
2856impl RunnerLauncher for LifecycleLauncher {
2857 async fn supervise(
2858 &self,
2859 policy: &ScalePolicy,
2860 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
2861 LifecycleLauncher::supervise(self, policy)
2862 .await
2863 .map_err(|error| LaunchFailure::new(error.reason()))
2864 }
2865
2866 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
2867 self.ports.store.attempts().map_err(|_| {
2868 LaunchFailure::new(FailureReason::Other(
2869 "attempt journal could not be read".into(),
2870 ))
2871 })
2872 }
2873
2874 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
2875 self.launch_attempt(request.policy, request.allocation_guard)
2876 .await
2877 .map_err(|error| LaunchFailure::new(error.reason()))
2878 }
2879
2880 async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
2881 let mut attempt = self
2882 .ports
2883 .store
2884 .attempt(id)
2885 .map_err(|_| {
2886 LaunchFailure::new(FailureReason::Other(
2887 "attempt journal could not be read".into(),
2888 ))
2889 })?
2890 .ok_or_else(|| {
2891 LaunchFailure::new(FailureReason::Other(
2892 "attempt disappeared from the journal".into(),
2893 ))
2894 })?;
2895 self.clean_attempt(&mut attempt)
2896 .map_err(|error| LaunchFailure::new(error.reason()))
2897 }
2898}
2899
2900fn runner_name(attempt: AttemptId) -> String {
2901 format!("runner-manager-{attempt}")
2902}
2903
2904fn read_runner_id(runtime: &Path) -> Option<u64> {
2905 fs::read_to_string(runtime.join(RUNNER_ID_FILE))
2906 .ok()?
2907 .trim()
2908 .parse()
2909 .ok()
2910}
2911
2912fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
2913 let target = runtime.join(RUNNER_ID_FILE);
2914 if let Some(existing) = read_runner_id(runtime) {
2915 return (existing == runner_id)
2916 .then_some(())
2917 .ok_or(LifecycleError::Journal);
2918 }
2919 let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
2920 write_durable_file(&temporary, runner_id.to_string().as_bytes())
2921 .map_err(|_| LifecycleError::Journal)?;
2922 match fs::rename(&temporary, &target) {
2923 Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
2924 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
2925 let _ = fs::remove_file(&temporary);
2926 (read_runner_id(runtime) == Some(runner_id))
2927 .then_some(())
2928 .ok_or(LifecycleError::Journal)
2929 }
2930 Err(_) => {
2931 let _ = fs::remove_file(&temporary);
2932 Err(LifecycleError::Journal)
2933 }
2934 }
2935}
2936
2937fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2938 let mut file = fs::OpenOptions::new()
2939 .create(true)
2940 .truncate(true)
2941 .write(true)
2942 .open(path)?;
2943 file.write_all(bytes)?;
2944 file.sync_all()?;
2945 let parent = path.parent().ok_or_else(|| {
2946 std::io::Error::new(
2947 std::io::ErrorKind::InvalidInput,
2948 "file has no parent directory",
2949 )
2950 })?;
2951 sync_directory(parent)
2952}
2953
2954#[cfg(unix)]
2955fn sync_directory(path: &Path) -> std::io::Result<()> {
2956 fs::File::open(path)?.sync_all()
2957}
2958
2959#[cfg(windows)]
2960fn sync_directory(path: &Path) -> std::io::Result<()> {
2961 use std::os::windows::fs::OpenOptionsExt;
2962
2963 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
2964 const FILE_SHARE_ALL: u32 = 0x0000_0007;
2965 const GENERIC_WRITE: u32 = 0x4000_0000;
2966 fs::OpenOptions::new()
2967 .access_mode(GENERIC_WRITE)
2968 .share_mode(FILE_SHARE_ALL)
2969 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
2970 .open(path)?
2971 .sync_all()
2972}
2973
2974#[cfg(test)]
2975mod tests {
2976 use super::*;
2977 use std::collections::BTreeSet;
2978 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2979
2980 use crate::reconcile::{AllocationLock, InProcessAllocationLock};
2981 use runner_manager_domain::model::{Elapsed, TargetScope};
2982 use runner_manager_domain::store::SqliteStore;
2983 use runner_manager_github::jit::JitRunner;
2984 use runner_manager_testkit::clock::FakeClock;
2985 use runner_manager_testkit::fixtures;
2986
2987 type CapturedFields = Vec<(String, String)>;
2989
2990 #[derive(Clone, Default)]
2998 struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
2999
3000 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
3001 fn on_event(
3002 &self,
3003 event: &tracing::Event<'_>,
3004 _context: tracing_subscriber::layer::Context<'_, S>,
3005 ) {
3006 struct Collect(Vec<(String, String)>);
3007 impl tracing::field::Visit for Collect {
3008 fn record_debug(
3009 &mut self,
3010 field: &tracing::field::Field,
3011 value: &dyn std::fmt::Debug,
3012 ) {
3013 self.0.push((
3016 field.name().to_owned(),
3017 format!("{value:?}").trim_matches('"').to_owned(),
3018 ));
3019 }
3020 }
3021 let mut collected = Collect(Vec::new());
3022 event.record(&mut collected);
3023 self.0
3024 .lock()
3025 .expect("the capture mutex is not poisoned")
3026 .push(collected.0);
3027 }
3028 }
3029
3030 #[test]
3048 fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
3049 use runner_manager_platform::logging;
3050 use tracing_subscriber::layer::SubscriberExt as _;
3051
3052 let captured = CapturedEvents::default();
3053 let error = RunnerRootError::DeniedByPrivacyPolicy {
3054 requested: PathBuf::from("/Volumes/NVME/runners"),
3055 refused: PathBuf::from("/Volumes/NVME"),
3056 remediation: RootOwner::Host.remediation(),
3057 };
3058 let kind = error.kind();
3059
3060 let failure = tracing::subscriber::with_default(
3061 tracing_subscriber::registry().with(captured.clone()),
3062 || root_failure(error),
3063 );
3064
3065 assert!(
3070 matches!(
3071 &failure,
3072 LifecycleError::Failed(FailureReason::Other(detail))
3073 if detail.contains("/Volumes/NVME/runners")
3074 && detail.contains("Full Disk Access")
3075 ),
3076 "the reason must still carry the detail: {failure:?}"
3077 );
3078
3079 let events = captured
3080 .0
3081 .lock()
3082 .expect("the capture mutex is not poisoned")
3083 .clone();
3084 let event = events
3085 .iter()
3086 .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
3087 .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
3088
3089 for (name, value) in event {
3094 assert!(
3095 logging::is_field_allowed(name),
3096 "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
3097 logging::REDACTION
3098 );
3099 assert_eq!(
3100 &logging::redact(value),
3101 value,
3102 "`{name}` does not survive value-shape scrubbing: {event:?}"
3103 );
3104 }
3105 }
3106
3107 fn nz(slot: u16) -> NonZeroU16 {
3109 NonZeroU16::new(slot).expect("a positive slot")
3110 }
3111
3112 const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
3113
3114 #[derive(Debug, Default)]
3115 struct FakeGithubLifecycle {
3116 registration_failures: Mutex<VecDeque<bool>>,
3117 observations: Mutex<VecDeque<LifecycleGithubObservation>>,
3118 registrations: AtomicUsize,
3119 remaining_runners: AtomicUsize,
3120 deregistrations: Mutex<Vec<u64>>,
3124 deregistration_fails: AtomicBool,
3127 journal: Mutex<Option<Arc<SqliteStore>>>,
3132 registration_facts: Mutex<Vec<RegistrationFact>>,
3134 }
3135
3136 #[derive(Debug, Clone)]
3143 struct RegistrationFact {
3144 leased_slots: Vec<u16>,
3146 work_folder: String,
3148 runner_name: String,
3151 }
3152
3153 impl FakeGithubLifecycle {
3154 fn fail(mut self, terminal: bool) -> Self {
3155 self.registration_failures
3156 .get_mut()
3157 .expect("unpoisoned")
3158 .push_back(terminal);
3159 self
3160 }
3161
3162 fn watch_journal(&self, store: Arc<SqliteStore>) {
3163 *self.journal.lock().unwrap() = Some(store);
3164 }
3165
3166 fn registration_facts(&self) -> Vec<RegistrationFact> {
3167 self.registration_facts.lock().unwrap().clone()
3168 }
3169
3170 fn observe(&self, observation: GithubRunnerObservation) {
3171 let observation = match observation {
3172 GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
3173 GithubRunnerObservation::NotRegistered => {
3174 LifecycleGithubObservation::not_registered()
3175 }
3176 GithubRunnerObservation::Registered { busy } => {
3177 LifecycleGithubObservation::registered(73, busy)
3178 }
3179 };
3180 self.observations.lock().unwrap().push_back(observation);
3181 }
3182 }
3183
3184 #[async_trait]
3185 impl LifecycleGithub for FakeGithubLifecycle {
3186 async fn register(
3187 &self,
3188 _target: &ScaleTarget,
3189 request: &JitRunnerRequest,
3190 _cancel: &CancelToken,
3191 ) -> Result<JitRegistration, JitRequestFailure> {
3192 self.registrations.fetch_add(1, Ordering::SeqCst);
3193 if let Some(store) = self.journal.lock().unwrap().as_ref() {
3194 let slots = store
3195 .attempts()
3196 .expect("the journal is readable")
3197 .iter()
3198 .filter_map(|attempt| attempt.workspace().slot_number())
3199 .collect();
3200 self.registration_facts
3201 .lock()
3202 .unwrap()
3203 .push(RegistrationFact {
3204 leased_slots: slots,
3205 work_folder: request.work_folder().to_string(),
3206 runner_name: request.name().to_string(),
3207 });
3208 }
3209 if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
3210 return Err(JitRequestFailure {
3211 terminal,
3212 reason: if terminal {
3213 FailureReason::Other("GitHub refused JIT registration with 403".into())
3214 } else {
3215 FailureReason::JitRequestFailed
3216 },
3217 retry_after: None,
3218 });
3219 }
3220 self.remaining_runners.store(1, Ordering::SeqCst);
3221 Ok(JitRegistration::new(
3222 EncodedJitConfig::new(JIT),
3223 JitRunner {
3224 id: 73,
3225 name: request.name().to_string(),
3226 os: "windows".into(),
3227 status: "offline".into(),
3228 busy: false,
3229 runner_group_id: Some(1),
3230 labels: request.labels().to_vec(),
3231 },
3232 ))
3233 }
3234
3235 async fn observe(
3236 &self,
3237 _target: &ScaleTarget,
3238 _attempt: AttemptId,
3239 _cancel: &CancelToken,
3240 ) -> LifecycleGithubObservation {
3241 let observation = self
3242 .observations
3243 .lock()
3244 .unwrap()
3245 .pop_front()
3246 .unwrap_or(LifecycleGithubObservation::not_registered());
3247 if observation.status == GithubRunnerObservation::NotRegistered {
3248 self.remaining_runners.store(0, Ordering::SeqCst);
3249 }
3250 observation
3251 }
3252
3253 async fn deregister(
3254 &self,
3255 _target: &ScaleTarget,
3256 runner_id: u64,
3257 _cancel: &CancelToken,
3258 ) -> bool {
3259 self.deregistrations.lock().unwrap().push(runner_id);
3260 if self.deregistration_fails.load(Ordering::SeqCst) {
3261 return false;
3262 }
3263 self.remaining_runners.store(0, Ordering::SeqCst);
3264 true
3265 }
3266 }
3267
3268 #[derive(Debug)]
3269 struct FakePackages {
3270 version: RunnerVersion,
3271 leases: Mutex<BTreeSet<AttemptId>>,
3272 materializations: AtomicUsize,
3273 materialization_failures: AtomicUsize,
3274 releases: AtomicUsize,
3275 prunes: AtomicUsize,
3276 prune_currents: Mutex<Vec<RunnerVersion>>,
3277 }
3278
3279 impl Default for FakePackages {
3280 fn default() -> Self {
3281 Self {
3282 version: RunnerVersion::parse("2.330.0").unwrap(),
3283 leases: Mutex::new(BTreeSet::new()),
3284 materializations: AtomicUsize::new(0),
3285 materialization_failures: AtomicUsize::new(0),
3286 releases: AtomicUsize::new(0),
3287 prunes: AtomicUsize::new(0),
3288 prune_currents: Mutex::new(Vec::new()),
3289 }
3290 }
3291 }
3292
3293 impl FakePackages {
3294 fn fail_materializations(&self, count: usize) {
3295 self.materialization_failures.store(count, Ordering::SeqCst);
3296 }
3297 }
3298
3299 #[async_trait]
3300 impl RuntimePackages for FakePackages {
3301 async fn materialize(
3302 &self,
3303 attempt: &RunnerAttempt,
3304 ) -> Result<RunnerVersion, FailureReason> {
3305 self.materializations.fetch_add(1, Ordering::SeqCst);
3306 if self
3307 .materialization_failures
3308 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3309 if left > 0 { Some(left - 1) } else { None }
3310 })
3311 .is_ok()
3312 {
3313 return Err(FailureReason::Other(
3314 "runner package materialization failed transiently".into(),
3315 ));
3316 }
3317 fs::create_dir_all(attempt.runtime_path()).unwrap();
3318 fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
3319 self.leases.lock().unwrap().insert(attempt.id);
3320 Ok(self.version.clone())
3321 }
3322
3323 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
3324 self.leases.lock().unwrap().remove(&attempt);
3325 self.releases.fetch_add(1, Ordering::SeqCst);
3326 Ok(())
3327 }
3328
3329 fn prune_obsolete_guarded(
3330 &self,
3331 _authority: PruneAuthority<'_>,
3332 current: &RunnerVersion,
3333 _attempts: &[RunnerAttempt],
3334 ) -> Result<(), FailureReason> {
3335 self.prunes.fetch_add(1, Ordering::SeqCst);
3336 self.prune_currents.lock().unwrap().push(current.clone());
3337 Ok(())
3338 }
3339 }
3340
3341 #[derive(Debug, Default)]
3342 struct FakeProcesses {
3343 alive: AtomicBool,
3344 completed_successfully: AtomicBool,
3345 spawns: AtomicUsize,
3346 spawn_failures: AtomicUsize,
3347 live_spawn_failure: AtomicBool,
3348 terminations: AtomicUsize,
3349 intent: AtomicBool,
3350 intent_failure: AtomicBool,
3351 actions: Mutex<Vec<&'static str>>,
3352 saw_secret: AtomicBool,
3353 }
3354
3355 impl FakeProcesses {
3356 fn fail_spawns(&self, count: usize) {
3357 self.spawn_failures.store(count, Ordering::SeqCst);
3358 }
3359
3360 fn fail_spawn_with_live_child(&self) {
3361 self.live_spawn_failure.store(true, Ordering::SeqCst);
3362 }
3363
3364 fn set_alive(&self, alive: bool) {
3365 self.alive.store(alive, Ordering::SeqCst);
3366 }
3367
3368 fn finish_successfully(&self) {
3369 self.completed_successfully.store(true, Ordering::SeqCst);
3370 self.alive.store(false, Ordering::SeqCst);
3371 }
3372
3373 fn fail_intent(&self) {
3374 self.intent_failure.store(true, Ordering::SeqCst);
3375 }
3376 }
3377
3378 impl ProcessSupervisor for FakeProcesses {
3379 fn spawn(
3380 &self,
3381 attempt: &RunnerAttempt,
3382 config: &EncodedJitConfig,
3383 ) -> Result<u32, ProcessStartFailure> {
3384 self.spawns.fetch_add(1, Ordering::SeqCst);
3385 let handoff = RestrictiveHandoff::create(
3388 attempt.runtime_path(),
3389 SecretString::from(config.expose().to_owned()),
3390 )
3391 .unwrap();
3392 self.saw_secret
3393 .store(config.expose() == JIT, Ordering::SeqCst);
3394 let handoff_path = handoff.path().to_path_buf();
3395 let failing = self
3396 .spawn_failures
3397 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3398 if left > 0 { Some(left - 1) } else { None }
3399 })
3400 .is_ok();
3401 drop(handoff);
3402 assert!(!handoff_path.exists(), "handoff must be absent on return");
3403 if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
3404 self.alive.store(true, Ordering::SeqCst);
3405 return Err(ProcessStartFailure::after_spawn_live(4242));
3406 }
3407 if failing {
3408 return Err(ProcessStartFailure::before_spawn(
3409 FailureReason::ProcessStartFailed,
3410 ));
3411 }
3412 self.alive.store(true, Ordering::SeqCst);
3413 Ok(4242)
3414 }
3415
3416 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
3417 self.actions.lock().unwrap().push("observe_process");
3418 Ok(self.alive.load(Ordering::SeqCst))
3419 }
3420
3421 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3422 Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
3423 }
3424
3425 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
3426 self.completed_successfully.load(Ordering::SeqCst)
3427 }
3428
3429 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3430 self.actions.lock().unwrap().push("terminate_intent");
3431 if self.intent_failure.load(Ordering::SeqCst) {
3432 return Err(FailureReason::Other(
3433 "terminate intent directory sync failed".into(),
3434 ));
3435 }
3436 self.intent.store(true, Ordering::SeqCst);
3437 Ok(())
3438 }
3439
3440 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
3441 self.intent.load(Ordering::SeqCst)
3442 }
3443
3444 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3445 assert!(
3446 self.intent.load(Ordering::SeqCst),
3447 "the durable intent must exist before signalling"
3448 );
3449 self.actions.lock().unwrap().push("terminate");
3450 self.terminations.fetch_add(1, Ordering::SeqCst);
3451 self.alive.store(false, Ordering::SeqCst);
3452 Ok(())
3453 }
3454 }
3455
3456 #[derive(Debug, Default)]
3457 struct FakeDemand {
3458 answers: Mutex<VecDeque<bool>>,
3459 }
3460
3461 impl FakeDemand {
3462 fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
3463 Self {
3464 answers: Mutex::new(answers.into_iter().collect()),
3465 }
3466 }
3467 }
3468
3469 #[async_trait]
3470 impl DemandPersistence for FakeDemand {
3471 async fn persists(&self, _policy: PolicyId) -> bool {
3472 self.answers.lock().unwrap().pop_front().unwrap_or(true)
3473 }
3474 }
3475
3476 #[derive(Debug, Default)]
3477 struct FakeDelay(Mutex<Vec<Duration>>);
3478
3479 #[async_trait]
3480 impl RetryDelay for FakeDelay {
3481 async fn wait(&self, duration: Duration) {
3482 self.0.lock().unwrap().push(duration);
3483 }
3484 }
3485
3486 struct Harness {
3487 _root: tempfile::TempDir,
3488 app_paths: runner_manager_platform::paths::AppPaths,
3489 launcher: LifecycleLauncher,
3490 demand: Arc<dyn DemandPersistence>,
3491 store: Arc<SqliteStore>,
3492 github: Arc<FakeGithubLifecycle>,
3493 packages: Arc<FakePackages>,
3494 processes: Arc<FakeProcesses>,
3495 clock: Arc<FakeClock>,
3496 events: Arc<AttemptEventLog>,
3497 reconcile_events: Arc<crate::reconcile::EventLog>,
3498 delay: Arc<FakeDelay>,
3499 host: runner_manager_domain::model::Host,
3500 policy: ScalePolicy,
3501 allocation_lock: InProcessAllocationLock,
3502 workspace_root: Option<LocalAbsolutePath>,
3504 }
3505
3506 impl Harness {
3507 fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
3508 let root = tempfile::tempdir().unwrap();
3509 let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
3510 paths.create_all().unwrap();
3511 let policy = fixtures::policy()
3512 .repository("octo/repo")
3513 .autoscale("home", 2)
3514 .active()
3515 .build();
3516 let host_root = root.path().join("host-root");
3522 fs::create_dir_all(&host_root).unwrap();
3523 let mut host = fixtures::host().build();
3524 host.runner_root_override = Some(
3525 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
3526 .expect("a local absolute host root"),
3527 );
3528 let store = Arc::new(SqliteStore::open_in_memory().unwrap());
3529 store.put_host(&host).unwrap();
3530 let github = Arc::new(github);
3531 let packages = Arc::new(FakePackages::default());
3532 let processes = Arc::new(FakeProcesses::default());
3533 let clock = Arc::new(FakeClock::default());
3534 let events = Arc::new(AttemptEventLog::default());
3535 let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
3536 let delay = Arc::new(FakeDelay::default());
3537 let ports = LifecyclePorts {
3538 store: Arc::clone(&store) as Arc<dyn Store>,
3539 github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
3540 packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
3541 processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
3542 clock: Arc::clone(&clock) as Arc<dyn Clock>,
3543 demand: Arc::clone(&demand),
3544 delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
3545 events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
3546 reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
3547 };
3548 let launcher = Self::launcher_over(policy.host_id, &paths, ports);
3549 Self {
3550 _root: root,
3551 app_paths: paths,
3552 launcher,
3553 demand,
3554 store,
3555 github,
3556 packages,
3557 processes,
3558 clock,
3559 events,
3560 reconcile_events,
3561 delay,
3562 host,
3563 policy,
3564 allocation_lock: InProcessAllocationLock::new(),
3565 workspace_root: None,
3566 }
3567 }
3568
3569 fn with_host_runner_root(mut self) -> Self {
3576 let host_root = self.host_root();
3577 fs::create_dir_all(&host_root).unwrap();
3578 self.host.runner_root_override = Some(
3579 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
3580 .expect("a local absolute host root"),
3581 );
3582 self.store.put_host(&self.host).unwrap();
3583 self
3584 }
3585
3586 fn with_persistent_workspace(mut self, capacity: u16) -> Self {
3588 self = self.with_host_runner_root();
3589 let root = self._root.path().join("persist");
3590 let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
3591 .expect("a local absolute workspace root");
3592 self.policy = fixtures::policy()
3593 .repository("octo/repo")
3594 .autoscale("home", capacity)
3595 .active()
3596 .build();
3597 self.policy
3598 .set_workspace_policy(
3599 WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
3600 .expect("a repository may be persistent"),
3601 )
3602 .expect("a repository may be persistent");
3603 self.workspace_root = Some(root);
3604 self.store.insert_policy(&self.policy).unwrap();
3607 self
3608 }
3609
3610 fn workspace_root(&self) -> &LocalAbsolutePath {
3611 self.workspace_root
3612 .as_ref()
3613 .expect("this harness configured a persistent workspace")
3614 }
3615
3616 fn slot_path(&self, slot: u16) -> PathBuf {
3617 self.workspace_root().as_path().join(format!("s{slot}"))
3618 }
3619
3620 fn host_root(&self) -> PathBuf {
3621 self._root.path().join("host-root")
3622 }
3623
3624 fn attempt(&self, id: AttemptId) -> RunnerAttempt {
3625 self.store
3626 .attempt(id)
3627 .unwrap()
3628 .expect("the attempt is journalled")
3629 }
3630
3631 fn conclude(&self, id: AttemptId) -> RunnerAttempt {
3633 let mut attempt = self.attempt(id);
3634 attempt
3635 .conclude(
3636 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
3637 self.clock.now(),
3638 )
3639 .unwrap();
3640 self.store.record_attempt(&attempt).unwrap();
3641 attempt
3642 }
3643
3644 async fn cleanup_retaining_work(&self, id: AttemptId) {
3645 self.conclude(id);
3646 self.launcher
3647 .clean(id)
3648 .await
3649 .expect("the slot is scrubbed and the lease released");
3650 }
3651
3652 fn launcher_over(
3655 host: HostId,
3656 paths: &runner_manager_platform::paths::AppPaths,
3657 ports: LifecyclePorts,
3658 ) -> LifecycleLauncher {
3659 LifecycleLauncher::new(
3660 host,
3661 paths.clone(),
3662 paths.logs_dir(),
3663 1,
3664 RecoveryTimeouts::new(
3665 Elapsed::seconds(10),
3666 Elapsed::seconds(10),
3667 Elapsed::seconds(10),
3668 ),
3669 RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
3670 ports,
3671 )
3672 }
3673
3674 fn restart(&self) -> LifecycleLauncher {
3677 Self::launcher_over(
3678 self.policy.host_id,
3679 &self.app_paths,
3680 LifecyclePorts {
3681 store: Arc::clone(&self.store) as Arc<dyn Store>,
3682 github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
3683 packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
3684 processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
3685 clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
3686 demand: Arc::clone(&self.demand),
3687 delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
3688 events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
3689 reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
3690 },
3691 )
3692 }
3693
3694 async fn ready(&self) {
3695 self.launcher
3696 .recover_startup(std::slice::from_ref(&self.policy))
3697 .await
3698 .unwrap();
3699 }
3700
3701 async fn launch(&self) -> RunnerAttempt {
3702 self.launch_result().await.unwrap()
3703 }
3704
3705 async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
3706 let guard = self.allocation_lock.acquire().await.unwrap();
3707 self.launcher
3708 .launch(LaunchRequest {
3709 host: &self.host,
3710 policy: &self.policy,
3711 allocation_guard: &guard,
3712 })
3713 .await
3714 }
3715
3716 fn only_attempt(&self) -> RunnerAttempt {
3717 self.store.attempts().unwrap().into_iter().next().unwrap()
3718 }
3719 }
3720
3721 #[tokio::test]
3730 async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
3731 use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
3732
3733 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
3734 .with_host_runner_root();
3735 harness.ready().await;
3736
3737 let unusable = harness
3741 ._root
3742 .path()
3743 .join("absent")
3744 .join("deeper")
3745 .join("runners");
3746 let mut host = harness.host.clone();
3747 host.runner_root_override = Some(
3748 LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
3749 .expect("a local absolute host root"),
3750 );
3751 harness.store.put_host(&host).unwrap();
3752
3753 let failure = harness
3754 .launch_result()
3755 .await
3756 .expect_err("a root whose parents are missing cannot hold a runner");
3757 assert!(
3758 matches!(failure.reason, FailureReason::Other(_)),
3759 "{failure:?}"
3760 );
3761
3762 let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
3763 let refusal = refusals
3764 .first()
3765 .expect("the refusal reached the one surface that can hold it");
3766 assert_eq!(refusal.policy, harness.policy.id.to_string());
3767 assert_eq!(refusal.kind, "missing_parents");
3768 assert!(
3769 refusal.root.contains("runners") && refusal.detail.contains("runners"),
3770 "the directory must be named in full: {refusal:?}"
3771 );
3772
3773 harness.store.put_host(&harness.host).unwrap();
3776 harness.launch().await;
3777 assert!(
3778 runner_root_refusals(&harness.app_paths)
3779 .expect("readable")
3780 .is_empty(),
3781 "a successful placement clears that policy's record"
3782 );
3783
3784 clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
3785 .expect("cleanup");
3786 }
3787
3788 #[tokio::test]
3789 async fn a_job_walks_every_state_and_cleans_every_artifact() {
3790 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3791 harness.ready().await;
3792 let started = harness.launch().await;
3793 assert_eq!(started.state(), AttemptState::Starting);
3794 assert_eq!(read_runner_id(started.runtime_path()), Some(73));
3795
3796 harness
3797 .github
3798 .observe(GithubRunnerObservation::Registered { busy: false });
3799 harness.launcher.supervise(&harness.policy).await.unwrap();
3800 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
3801
3802 harness
3803 .github
3804 .observe(GithubRunnerObservation::Registered { busy: true });
3805 harness.launcher.supervise(&harness.policy).await.unwrap();
3806 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
3807
3808 harness.processes.finish_successfully();
3809 harness
3810 .github
3811 .observe(GithubRunnerObservation::NotRegistered);
3812 harness.launcher.supervise(&harness.policy).await.unwrap();
3813 let cleaned = harness.only_attempt();
3814 assert_eq!(cleaned.state(), AttemptState::Cleaned);
3815 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
3816 assert!(!started.runtime_path().exists());
3817 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
3818 assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
3819
3820 let states: Vec<_> = harness
3821 .events
3822 .events()
3823 .into_iter()
3824 .filter_map(|event| match event {
3825 AttemptEvent::State { state, .. } => Some(state),
3826 _ => None,
3827 })
3828 .collect();
3829 assert_eq!(
3830 states,
3831 vec![
3832 AttemptState::Allocated,
3833 AttemptState::JitReceived,
3834 AttemptState::Starting,
3835 AttemptState::Idle,
3836 AttemptState::Busy,
3837 AttemptState::Finished,
3838 AttemptState::Cleaned,
3839 ]
3840 );
3841 }
3842
3843 #[tokio::test]
3844 async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
3845 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3846 harness.ready().await;
3847 let started = harness.launch().await;
3848 let runtime = started.runtime_path().to_path_buf();
3849
3850 harness
3851 .github
3852 .observe(GithubRunnerObservation::Registered { busy: true });
3853 harness.launcher.supervise(&harness.policy).await.unwrap();
3854 harness.processes.finish_successfully();
3855 harness
3856 .github
3857 .observe(GithubRunnerObservation::NotRegistered);
3858 harness.launcher.supervise(&harness.policy).await.unwrap();
3859
3860 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
3861 assert!(!runtime.exists());
3862 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
3863
3864 let residue = runtime.join("_work").join("late-node-process");
3868 fs::create_dir_all(&residue).unwrap();
3869 fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
3870
3871 harness.launcher.supervise(&harness.policy).await.unwrap();
3872
3873 assert!(!runtime.exists(), "late ephemeral residue is reaped");
3874 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
3875 assert_eq!(
3876 harness.packages.releases.load(Ordering::SeqCst),
3877 1,
3878 "reaping residue does not release the package lease twice"
3879 );
3880 }
3881
3882 #[tokio::test]
3883 async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
3884 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3885 harness.ready().await;
3886 let started = harness.launch().await;
3887 harness
3888 .github
3889 .observe(GithubRunnerObservation::Registered { busy: false });
3890 harness.launcher.supervise(&harness.policy).await.unwrap();
3891 harness.clock.advance_secs(11);
3892 harness.processes.set_alive(false);
3893 harness
3894 .github
3895 .observe(GithubRunnerObservation::NotRegistered);
3896 harness.launcher.supervise(&harness.policy).await.unwrap();
3897
3898 let cleaned = harness.only_attempt();
3899 assert!(cleaned.outcome().unwrap().is_idle_exit());
3900 assert!(!cleaned.outcome().unwrap().is_failure());
3901 assert!(!started.runtime_path().exists());
3902 assert!(
3903 harness
3904 .reconcile_events
3905 .events()
3906 .iter()
3907 .any(|event| matches!(
3908 event,
3909 LifecycleEvent::AttemptCleaned {
3910 outcome: OutcomeKind::IdleExit,
3911 ..
3912 }
3913 ))
3914 );
3915 assert!(!harness.events.events().iter().any(|event| matches!(
3916 event,
3917 AttemptEvent::Concluded {
3918 outcome: OutcomeKind::Failed,
3919 ..
3920 }
3921 )));
3922 }
3923
3924 #[tokio::test]
3925 async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
3926 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3927 harness.processes.fail_spawns(2);
3928 harness.ready().await;
3929 let attempt = harness.launch().await;
3930 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
3931 assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
3932 let names: Vec<_> = fs::read_dir(attempt.runtime_path())
3933 .unwrap()
3934 .map(|entry| entry.unwrap().file_name())
3935 .collect();
3936 assert!(
3937 names.iter().all(|name| {
3938 !name
3939 .to_string_lossy()
3940 .starts_with(RestrictiveHandoff::NAME_PREFIX)
3941 }),
3942 "JIT artifact survived: {names:?}"
3943 );
3944 assert_eq!(
3945 *harness.delay.0.lock().unwrap(),
3946 vec![Duration::from_millis(10), Duration::from_millis(20)]
3947 );
3948 }
3949
3950 #[tokio::test]
3951 async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
3952 let gone = Harness::new(
3953 FakeGithubLifecycle::default().fail(false),
3954 Arc::new(FakeDemand::answering([false])),
3955 );
3956 gone.ready().await;
3957 assert!(gone.launch_result().await.is_err());
3958 assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
3959 assert!(gone.delay.0.lock().unwrap().is_empty());
3960
3961 let forbidden = Harness::new(
3962 FakeGithubLifecycle::default().fail(true),
3963 Arc::new(PersistentDemand),
3964 );
3965 forbidden.ready().await;
3966 assert!(forbidden.launch_result().await.is_err());
3967 assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
3968 assert!(forbidden.delay.0.lock().unwrap().is_empty());
3969 assert!(matches!(
3970 forbidden.only_attempt().outcome(),
3971 Some(AttemptOutcome::Failed {
3972 reason: FailureReason::Other(action)
3973 }) if action.contains("403")
3974 ));
3975
3976 let transient = Harness::new(
3977 FakeGithubLifecycle::default().fail(false).fail(false),
3978 Arc::new(PersistentDemand),
3979 );
3980 transient.ready().await;
3981 transient.launch().await;
3982 assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
3983 assert_eq!(
3984 *transient.delay.0.lock().unwrap(),
3985 vec![Duration::from_millis(10), Duration::from_millis(20)]
3986 );
3987 }
3988
3989 #[test]
3997 fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
3998 const MAX_PATH: usize = 260;
3999 let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
4001 let repo = "GitHub-Runner-Scaler-UI";
4005 let deepest = format!(
4006 r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
4007 "0".repeat(40)
4008 );
4009
4010 let name = workspace_name(AttemptId::new_random());
4011 assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
4012 assert!(
4013 name.chars().all(|c| c.is_ascii_hexdigit()),
4014 "a directory name must not carry the identifier's dashes: {name}"
4015 );
4016
4017 let full = format!(r"{root}\{name}\{deepest}");
4018 assert!(
4019 full.len() < MAX_PATH,
4020 "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
4021 full.len()
4022 );
4023
4024 let old = format!(
4027 r"{root}\{}\{}\{deepest}",
4028 PolicyId::new_random(),
4029 AttemptId::new_random()
4030 );
4031 assert!(
4032 old.len() > MAX_PATH,
4033 "the old layout is supposed to be the thing that did not fit: {} characters",
4034 old.len()
4035 );
4036 }
4037
4038 #[tokio::test]
4039 async fn two_attempts_never_share_a_workspace_even_after_failure() {
4040 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4041 harness.ready().await;
4042 let first = harness.launch().await;
4043 fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
4044 harness
4045 .github
4046 .observe(GithubRunnerObservation::Registered { busy: false });
4047 harness.launcher.supervise(&harness.policy).await.unwrap();
4048 harness.clock.advance_secs(11);
4049 harness.processes.set_alive(false);
4050 harness
4051 .github
4052 .observe(GithubRunnerObservation::NotRegistered);
4053 harness.launcher.supervise(&harness.policy).await.unwrap();
4054 assert!(!first.runtime_path().exists());
4055
4056 let second = harness.launch().await;
4057 assert_ne!(first.runtime_path(), second.runtime_path());
4058 assert!(!second.runtime_path().join("hostile-leftover").exists());
4059
4060 fs::write(
4061 second.runtime_path().join("hostile-on-failure"),
4062 b"second job",
4063 )
4064 .unwrap();
4065 harness.processes.set_alive(false);
4066 harness
4067 .github
4068 .observe(GithubRunnerObservation::NotRegistered);
4069 harness.launcher.supervise(&harness.policy).await.unwrap();
4070 assert!(
4071 !second.runtime_path().exists(),
4072 "failed workspace was retained"
4073 );
4074 }
4075
4076 #[tokio::test]
4077 async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
4078 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4079 harness.ready().await;
4080 let attempt = harness.launch().await;
4081
4082 harness
4086 .github
4087 .observe(GithubRunnerObservation::Registered { busy: false });
4088 harness.launcher.supervise(&harness.policy).await.unwrap();
4089 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4090
4091 harness.clock.advance_secs(9);
4094 harness
4095 .github
4096 .observe(GithubRunnerObservation::Registered { busy: false });
4097 let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
4098 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4099 assert!(none_yet.is_empty());
4100 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
4101
4102 harness.clock.advance_secs(1);
4104 harness
4105 .github
4106 .observe(GithubRunnerObservation::Registered { busy: false });
4107 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4108
4109 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4110 assert_eq!(
4111 concluded.outcome(),
4112 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4113 "a surplus runner did not fail; recording one as a failure sends an operator \
4114 hunting a fault that does not exist"
4115 );
4116 assert_eq!(concluded.state(), AttemptState::Cleaned);
4117 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
4118 assert!(!attempt.runtime_path().exists());
4119
4120 assert_eq!(
4123 *harness.github.deregistrations.lock().unwrap(),
4124 vec![73],
4125 "the attempt's own runner id, deleted exactly once"
4126 );
4127
4128 assert!(
4131 replacements.is_empty(),
4132 "a surplus exit must not request a replacement"
4133 );
4134 }
4135
4136 #[tokio::test]
4137 async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
4138 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4142 harness.ready().await;
4143 let attempt = harness.launch().await;
4144 harness
4145 .github
4146 .observe(GithubRunnerObservation::Registered { busy: false });
4147 harness.launcher.supervise(&harness.policy).await.unwrap();
4148
4149 harness
4150 .github
4151 .deregistration_fails
4152 .store(true, Ordering::SeqCst);
4153 harness.clock.advance_secs(11);
4154 harness
4155 .github
4156 .observe(GithubRunnerObservation::Registered { busy: false });
4157 harness.launcher.supervise(&harness.policy).await.unwrap();
4158
4159 assert_eq!(
4160 *harness.github.deregistrations.lock().unwrap(),
4161 vec![73],
4162 "the delete was attempted"
4163 );
4164 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4165 assert_eq!(
4166 concluded.outcome(),
4167 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4168 "the attempt concluded anyway"
4169 );
4170 assert_eq!(concluded.state(), AttemptState::Cleaned);
4171 assert!(!attempt.runtime_path().exists());
4172 }
4173
4174 #[tokio::test]
4175 async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
4176 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4177 harness.ready().await;
4178 let first = harness.launch().await;
4179 harness.processes.set_alive(false);
4180 harness
4181 .github
4182 .observe(GithubRunnerObservation::NotRegistered);
4183 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4184 let failed = harness.store.attempt(first.id).unwrap().unwrap();
4185 assert!(matches!(
4186 failed.outcome(),
4187 Some(AttemptOutcome::Failed {
4188 reason: FailureReason::ProcessExitedUnexpectedly
4189 })
4190 ));
4191 assert!(!first.runtime_path().exists());
4192
4193 assert_eq!(
4194 replacements,
4195 vec![ReplacementIntent {
4196 policy: harness.policy.id,
4197 previous_attempt: first.id,
4198 operation: "exit_before_acceptance_replacement",
4199 }]
4200 );
4201 assert_eq!(harness.store.attempts().unwrap().len(), 1);
4202 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
4203 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4204 assert!(harness.delay.0.lock().unwrap().is_empty());
4205 }
4206
4207 #[tokio::test]
4208 async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
4209 let harness = Harness::new(
4210 FakeGithubLifecycle::default(),
4211 Arc::new(FakeDemand::answering([false])),
4212 );
4213 let id = AttemptId::new_random();
4214 let runtime = harness
4215 .launcher
4216 .app_paths
4217 .runtime_dir()
4218 .join(harness.policy.id.to_string())
4219 .join(id.to_string());
4220 fs::create_dir_all(&runtime).unwrap();
4221 let mut attempt =
4222 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4223 attempt.jit_received(harness.clock.now()).unwrap();
4224 harness.store.record_attempt(&attempt).unwrap();
4225 harness.clock.advance_secs(11);
4226 let replacements = harness
4227 .launcher
4228 .recover_startup(std::slice::from_ref(&harness.policy))
4229 .await
4230 .unwrap();
4231 assert_eq!(
4232 replacements,
4233 vec![ReplacementIntent {
4234 policy: harness.policy.id,
4235 previous_attempt: id,
4236 operation: "jit_expired_replacement",
4237 }]
4238 );
4239
4240 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4241 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4242 assert!(matches!(
4243 cleaned.outcome(),
4244 Some(AttemptOutcome::Failed {
4245 reason: FailureReason::JitExpired
4246 })
4247 ));
4248 assert!(!runtime.exists());
4249 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
4250 assert!(harness.delay.0.lock().unwrap().is_empty());
4251 }
4252
4253 #[tokio::test]
4254 async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
4255 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4256 let id = AttemptId::new_random();
4257 let runtime = harness
4258 .launcher
4259 .app_paths
4260 .runtime_dir()
4261 .join("expired-with-demand");
4262 fs::create_dir_all(&runtime).unwrap();
4263 let mut attempt =
4264 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4265 attempt.jit_received(harness.clock.now()).unwrap();
4266 harness.store.record_attempt(&attempt).unwrap();
4267 harness.clock.advance_secs(11);
4268 let replacements = harness
4269 .launcher
4270 .recover_startup(std::slice::from_ref(&harness.policy))
4271 .await
4272 .unwrap();
4273
4274 let attempts = harness.store.attempts().unwrap();
4275 assert_eq!(attempts.len(), 1);
4276 assert_eq!(
4277 attempts
4278 .iter()
4279 .find(|attempt| attempt.id == id)
4280 .unwrap()
4281 .state(),
4282 AttemptState::Cleaned
4283 );
4284 assert_eq!(
4285 replacements,
4286 vec![ReplacementIntent {
4287 policy: harness.policy.id,
4288 previous_attempt: id,
4289 operation: "jit_expired_replacement",
4290 }]
4291 );
4292 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
4293 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4294 assert!(harness.delay.0.lock().unwrap().is_empty());
4295 }
4296
4297 #[tokio::test]
4298 async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
4299 let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4300 persistent.packages.fail_materializations(2);
4301 persistent.ready().await;
4302 persistent.launch().await;
4303 assert_eq!(
4304 persistent.packages.materializations.load(Ordering::SeqCst),
4305 3
4306 );
4307 assert_eq!(
4308 *persistent.delay.0.lock().unwrap(),
4309 vec![Duration::from_millis(10), Duration::from_millis(20)]
4310 );
4311
4312 let gone_before_wait = Harness::new(
4313 FakeGithubLifecycle::default(),
4314 Arc::new(FakeDemand::answering([false])),
4315 );
4316 gone_before_wait.packages.fail_materializations(3);
4317 gone_before_wait.ready().await;
4318 assert!(gone_before_wait.launch_result().await.is_err());
4319 assert_eq!(
4320 gone_before_wait
4321 .packages
4322 .materializations
4323 .load(Ordering::SeqCst),
4324 1
4325 );
4326 assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
4327
4328 let gone_during_wait = Harness::new(
4329 FakeGithubLifecycle::default(),
4330 Arc::new(FakeDemand::answering([true, false])),
4331 );
4332 gone_during_wait.packages.fail_materializations(3);
4333 gone_during_wait.ready().await;
4334 assert!(gone_during_wait.launch_result().await.is_err());
4335 assert_eq!(
4336 gone_during_wait
4337 .packages
4338 .materializations
4339 .load(Ordering::SeqCst),
4340 1
4341 );
4342 assert_eq!(
4343 *gone_during_wait.delay.0.lock().unwrap(),
4344 vec![Duration::from_millis(10)]
4345 );
4346 }
4347
4348 #[tokio::test]
4349 async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
4350 let harness = Harness::new(
4351 FakeGithubLifecycle::default(),
4352 Arc::new(FakeDemand::answering([true, false])),
4353 );
4354 harness.ready().await;
4355 let first = harness.launch().await;
4356 harness.processes.set_alive(false);
4357 harness
4358 .github
4359 .observe(GithubRunnerObservation::NotRegistered);
4360 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4361
4362 assert_eq!(harness.store.attempts().unwrap().len(), 1);
4363 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
4364 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4365 assert!(harness.delay.0.lock().unwrap().is_empty());
4366 assert_eq!(
4367 replacements,
4368 vec![ReplacementIntent {
4369 policy: harness.policy.id,
4370 previous_attempt: first.id,
4371 operation: "exit_before_acceptance_replacement",
4372 }]
4373 );
4374 assert_eq!(
4375 harness.store.attempt(first.id).unwrap().unwrap().state(),
4376 AttemptState::Cleaned
4377 );
4378 }
4379
4380 #[tokio::test]
4381 async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
4382 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4383 let before = harness.launch_result().await;
4384 assert!(before.is_err());
4385 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4386
4387 let id = AttemptId::new_random();
4388 let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
4389 fs::create_dir_all(&runtime).unwrap();
4390 let mut attempt =
4391 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4392 attempt.jit_received(harness.clock.now()).unwrap();
4393 attempt.started(4242, harness.clock.now()).unwrap();
4394 harness.store.record_attempt(&attempt).unwrap();
4395 harness.processes.set_alive(true);
4396 harness
4397 .github
4398 .observe(GithubRunnerObservation::NotRegistered);
4399 let replacements = harness
4400 .launcher
4401 .recover_startup(std::slice::from_ref(&harness.policy))
4402 .await
4403 .unwrap();
4404 assert!(replacements.is_empty());
4405 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4406 assert!(
4407 harness
4408 .events
4409 .events()
4410 .contains(&AttemptEvent::Adopted { attempt: id })
4411 );
4412 }
4413
4414 #[tokio::test]
4415 async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
4416 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4417 let id = AttemptId::new_random();
4418 let runtime = harness
4419 .launcher
4420 .app_paths
4421 .runtime_dir()
4422 .join("spawn-before-starting");
4423 fs::create_dir_all(&runtime).unwrap();
4424 let mut attempt =
4425 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4426 attempt.jit_received(harness.clock.now()).unwrap();
4427 harness.store.record_attempt(&attempt).unwrap();
4428 harness.processes.set_alive(true);
4429 harness
4430 .github
4431 .observe(GithubRunnerObservation::Registered { busy: true });
4432
4433 let replacements = harness
4434 .launcher
4435 .recover_startup(std::slice::from_ref(&harness.policy))
4436 .await
4437 .unwrap();
4438 assert!(replacements.is_empty());
4439 let recovered = harness.store.attempt(id).unwrap().unwrap();
4440 assert_eq!(recovered.state(), AttemptState::Busy);
4441 assert_eq!(recovered.process_id(), Some(4242));
4442 assert_eq!(recovered.github_runner_id(), Some(73));
4443 let events = harness.events.events();
4444 let starting = events
4445 .iter()
4446 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
4447 .unwrap();
4448 let busy = events
4449 .iter()
4450 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
4451 .unwrap();
4452 assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
4453
4454 harness.processes.finish_successfully();
4455 harness
4456 .github
4457 .observe(GithubRunnerObservation::NotRegistered);
4458 assert!(
4459 harness
4460 .launcher
4461 .supervise(&harness.policy)
4462 .await
4463 .unwrap()
4464 .is_empty()
4465 );
4466 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4467 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4468 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
4469 assert!(!runtime.exists());
4470 }
4471
4472 #[tokio::test]
4473 async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
4474 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4475 harness.processes.fail_spawn_with_live_child();
4476 harness.ready().await;
4477 assert!(harness.launch_result().await.is_err());
4478
4479 let attempt = harness.only_attempt();
4480 assert_eq!(attempt.state(), AttemptState::Starting);
4481 assert_eq!(attempt.process_id(), Some(4242));
4482 assert!(attempt.outcome().is_none());
4483 assert!(attempt.state().counts_against_capacity());
4484 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4485 assert!(harness.delay.0.lock().unwrap().is_empty());
4486
4487 harness.processes.set_alive(false);
4488 harness
4489 .github
4490 .observe(GithubRunnerObservation::NotRegistered);
4491 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4492 assert_eq!(replacements.len(), 1);
4493 assert_eq!(
4494 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
4495 AttemptState::Cleaned
4496 );
4497 }
4498
4499 #[tokio::test]
4500 async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
4501 for sidecar_already_present in [false, true] {
4502 let harness = Harness::new(
4503 FakeGithubLifecycle::default(),
4504 Arc::new(FakeDemand::answering([false])),
4505 );
4506 let id = AttemptId::new_random();
4507 let runtime =
4508 harness
4509 .launcher
4510 .app_paths
4511 .runtime_dir()
4512 .join(if sidecar_already_present {
4513 "after-id-sidecar"
4514 } else {
4515 "before-id-sidecar"
4516 });
4517 fs::create_dir_all(&runtime).unwrap();
4518 let mut attempt =
4519 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4520 if sidecar_already_present {
4521 write_runner_id(&runtime, 73).unwrap();
4522 attempt.jit_received(harness.clock.now()).unwrap();
4523 }
4524 harness.store.record_attempt(&attempt).unwrap();
4525 harness.processes.set_alive(true);
4526 harness
4527 .github
4528 .observe(GithubRunnerObservation::Registered { busy: false });
4529 harness
4530 .launcher
4531 .recover_startup(std::slice::from_ref(&harness.policy))
4532 .await
4533 .unwrap();
4534
4535 assert_eq!(read_runner_id(&runtime), Some(73));
4536 assert!(
4537 harness
4538 .store
4539 .attempt(id)
4540 .unwrap()
4541 .unwrap()
4542 .outcome()
4543 .is_none()
4544 );
4545 let events = harness.events.events();
4546 let recovered = events.iter().position(|event| {
4547 matches!(
4548 event,
4549 AttemptEvent::RemoteIdentityRecovered {
4550 attempt,
4551 runner_id: 73
4552 } if *attempt == id
4553 )
4554 });
4555 assert_eq!(recovered.is_some(), !sidecar_already_present);
4556 if let Some(recovered) = recovered {
4557 let adopted = events
4558 .iter()
4559 .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
4560 .unwrap();
4561 assert!(
4562 recovered < adopted,
4563 "identity was not durable before adoption: {events:?}"
4564 );
4565 }
4566 assert!(runtime.exists());
4567 }
4568 }
4569
4570 #[tokio::test]
4571 async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
4572 let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4573 let unknown_attempt = RunnerAttempt::allocate(
4574 AttemptId::new_random(),
4575 PolicyId::from_u128(0xfeed),
4576 unknown
4577 .launcher
4578 .app_paths
4579 .runtime_dir()
4580 .join("unknown-policy"),
4581 unknown.clock.now(),
4582 );
4583 unknown.store.record_attempt(&unknown_attempt).unwrap();
4584 let expired_id = AttemptId::new_random();
4585 let expired_runtime = unknown
4586 .launcher
4587 .app_paths
4588 .runtime_dir()
4589 .join("expired-beside-unknown");
4590 fs::create_dir_all(&expired_runtime).unwrap();
4591 let mut expired = RunnerAttempt::allocate(
4592 expired_id,
4593 unknown.policy.id,
4594 expired_runtime,
4595 unknown.clock.now(),
4596 );
4597 expired.jit_received(unknown.clock.now()).unwrap();
4598 unknown.store.record_attempt(&expired).unwrap();
4599 unknown.clock.advance_secs(11);
4600 assert!(matches!(
4601 unknown
4602 .launcher
4603 .recover_startup(std::slice::from_ref(&unknown.policy))
4604 .await,
4605 Err(LifecycleError::RecoveryIncomplete)
4606 ));
4607 assert!(unknown.launch_result().await.is_err());
4608 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
4609 let recovered_policy = fixtures::policy()
4610 .id(PolicyId::from_u128(0xfeed))
4611 .repository("octo/repo")
4612 .autoscale("home", 2)
4613 .active()
4614 .build();
4615 let pending = unknown
4616 .launcher
4617 .recover_startup(&[unknown.policy.clone(), recovered_policy])
4618 .await
4619 .unwrap();
4620 assert_eq!(
4621 pending,
4622 vec![ReplacementIntent {
4623 policy: unknown.policy.id,
4624 previous_attempt: expired_id,
4625 operation: "jit_expired_replacement",
4626 }]
4627 );
4628 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
4629
4630 let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4631 let id = AttemptId::new_random();
4632 let runtime = unreachable
4633 .launcher
4634 .app_paths
4635 .runtime_dir()
4636 .join("unreachable");
4637 fs::create_dir_all(&runtime).unwrap();
4638 unreachable
4639 .store
4640 .record_attempt(&RunnerAttempt::allocate(
4641 id,
4642 unreachable.policy.id,
4643 runtime,
4644 unreachable.clock.now(),
4645 ))
4646 .unwrap();
4647 unreachable
4648 .github
4649 .observe(GithubRunnerObservation::Unreachable);
4650 assert!(matches!(
4651 unreachable
4652 .launcher
4653 .recover_startup(std::slice::from_ref(&unreachable.policy))
4654 .await,
4655 Err(LifecycleError::RecoveryIncomplete)
4656 ));
4657 assert!(unreachable.launch_result().await.is_err());
4658 assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
4659 }
4660
4661 #[tokio::test]
4662 async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
4663 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4664 let id = AttemptId::new_random();
4665 let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
4666 fs::create_dir_all(&runtime).unwrap();
4667 let mut attempt =
4668 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4669 attempt.jit_received(harness.clock.now()).unwrap();
4670 attempt.started(4242, harness.clock.now()).unwrap();
4671 attempt.assigned_job(73, harness.clock.now()).unwrap();
4672 harness.store.record_attempt(&attempt).unwrap();
4673 harness.processes.set_alive(false);
4674 harness
4675 .github
4676 .observe(GithubRunnerObservation::NotRegistered);
4677 harness
4678 .launcher
4679 .recover_startup(std::slice::from_ref(&harness.policy))
4680 .await
4681 .unwrap();
4682 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4683 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4684 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
4685 assert!(!runtime.exists());
4686 }
4687
4688 #[tokio::test]
4689 async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
4690 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4691 harness.ready().await;
4692 let id = AttemptId::new_random();
4693 let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
4694 fs::create_dir_all(&runtime).unwrap();
4695 let mut attempt =
4696 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4697 attempt.jit_received(harness.clock.now()).unwrap();
4698 attempt.started(4242, harness.clock.now()).unwrap();
4699 harness.store.record_attempt(&attempt).unwrap();
4700 harness.clock.advance_secs(11);
4701 harness.processes.set_alive(true);
4702 harness
4703 .github
4704 .observe(GithubRunnerObservation::NotRegistered);
4705 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4706 assert_eq!(
4707 replacements,
4708 vec![ReplacementIntent {
4709 policy: harness.policy.id,
4710 previous_attempt: id,
4711 operation: "registration_timeout_replacement",
4712 }]
4713 );
4714
4715 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
4716 assert!(!harness.processes.alive.load(Ordering::SeqCst));
4717 let actions = harness.processes.actions.lock().unwrap().clone();
4718 let intent = actions
4719 .iter()
4720 .position(|action| *action == "terminate_intent")
4721 .unwrap();
4722 let signal = actions
4723 .iter()
4724 .position(|action| *action == "terminate")
4725 .unwrap();
4726 assert!(
4727 intent < signal,
4728 "intent was not durable before signal: {actions:?}"
4729 );
4730
4731 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4732 assert!(matches!(
4733 cleaned.outcome(),
4734 Some(AttemptOutcome::Failed {
4735 reason: FailureReason::TerminatedAfterRegistrationTimeout
4736 })
4737 ));
4738 let events = harness.events.events();
4739 let intent = events
4740 .iter()
4741 .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
4742 .unwrap();
4743 let stopped = events
4744 .iter()
4745 .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
4746 .unwrap();
4747 let concluded = events
4748 .iter()
4749 .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
4750 .unwrap();
4751 assert!(intent < stopped && stopped < concluded, "{events:?}");
4752 }
4753
4754 #[tokio::test]
4755 async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
4756 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4757 let id = AttemptId::new_random();
4758 let runtime = harness
4759 .launcher
4760 .app_paths
4761 .runtime_dir()
4762 .join("timeout-after-crash");
4763 fs::create_dir_all(&runtime).unwrap();
4764 let mut attempt =
4765 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4766 attempt.jit_received(harness.clock.now()).unwrap();
4767 attempt.started(4242, harness.clock.now()).unwrap();
4768 harness.store.record_attempt(&attempt).unwrap();
4769 harness.processes.intent.store(true, Ordering::SeqCst);
4770 harness.processes.set_alive(false);
4771 harness
4772 .github
4773 .observe(GithubRunnerObservation::NotRegistered);
4774
4775 let replacements = harness
4776 .launcher
4777 .recover_startup(std::slice::from_ref(&harness.policy))
4778 .await
4779 .unwrap();
4780 assert_eq!(
4781 replacements,
4782 vec![ReplacementIntent {
4783 policy: harness.policy.id,
4784 previous_attempt: id,
4785 operation: "registration_timeout_replacement",
4786 }]
4787 );
4788 let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
4789 .await
4790 .unwrap();
4791 assert_eq!(consumed, replacements);
4792 assert!(
4793 RunnerLauncher::supervise(&harness.launcher, &harness.policy)
4794 .await
4795 .unwrap()
4796 .is_empty(),
4797 "startup replacement evidence must be consumed exactly once by e1"
4798 );
4799 assert!(matches!(
4800 harness.store.attempt(id).unwrap().unwrap().outcome(),
4801 Some(AttemptOutcome::Failed {
4802 reason: FailureReason::TerminatedAfterRegistrationTimeout
4803 })
4804 ));
4805 }
4806
4807 #[tokio::test]
4808 async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
4809 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4810 let id = AttemptId::new_random();
4811 let runtime = harness
4812 .launcher
4813 .app_paths
4814 .runtime_dir()
4815 .join("timeout-sync-failure");
4816 fs::create_dir_all(&runtime).unwrap();
4817 let mut attempt =
4818 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4819 attempt.jit_received(harness.clock.now()).unwrap();
4820 attempt.started(4242, harness.clock.now()).unwrap();
4821 harness.store.record_attempt(&attempt).unwrap();
4822 harness.clock.advance_secs(11);
4823 harness.processes.set_alive(true);
4824 harness.processes.fail_intent();
4825 harness
4826 .github
4827 .observe(GithubRunnerObservation::NotRegistered);
4828
4829 assert!(
4830 harness
4831 .launcher
4832 .recover_startup(std::slice::from_ref(&harness.policy))
4833 .await
4834 .is_err()
4835 );
4836 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
4837 assert!(harness.processes.alive.load(Ordering::SeqCst));
4838 assert_eq!(
4839 harness.store.attempt(id).unwrap().unwrap().state(),
4840 AttemptState::Starting
4841 );
4842 assert!(!harness.events.events().iter().any(|event| matches!(
4843 event,
4844 AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
4845 if *attempt == id
4846 )));
4847 }
4848
4849 #[tokio::test]
4850 async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
4851 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4852 harness.ready().await;
4853 let attempt = harness.launch().await;
4854 harness
4855 .github
4856 .observe(GithubRunnerObservation::Registered { busy: false });
4857 harness.launcher.supervise(&harness.policy).await.unwrap();
4858 harness.clock.advance_secs(11);
4859 harness.processes.set_alive(false);
4860 harness
4861 .github
4862 .observe(GithubRunnerObservation::NotRegistered);
4863 harness.launcher.supervise(&harness.policy).await.unwrap();
4864 let diagnostic = fs::read_to_string(
4865 harness
4866 .launcher
4867 .diagnostics_root
4868 .join(format!("{}.log", attempt.id)),
4869 )
4870 .unwrap();
4871 assert!(diagnostic.contains("exited_idle_without_work"));
4872 assert!(!diagnostic.contains(JIT));
4873 assert!(!diagnostic.contains("ghp_"));
4874 assert!(!attempt.runtime_path().exists());
4875 }
4876
4877 #[test]
4878 fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
4879 let root = tempfile::tempdir().unwrap();
4880 let policy = fixtures::policy()
4881 .repository("octo/repo")
4882 .autoscale("home", 1)
4883 .active()
4884 .build();
4885 let runtime = root.path().join("successful");
4886 fs::create_dir_all(&runtime).unwrap();
4887 let processes = NativeProcesses::new();
4888 let config = EncodedJitConfig::new(JIT);
4889 let handoff =
4890 RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
4891 .unwrap();
4892 let mut child = native_inspection_spec()
4893 .spawn_runner_with_handoff(&handoff)
4894 .expect("native child starts");
4895 let pid = child.pid();
4896 handoff.delete().unwrap();
4897 let command_line = native_command_line(pid);
4898 assert!(
4899 !command_line.contains(JIT),
4900 "the encoded JIT configuration appeared in the native process listing"
4901 );
4902 assert_no_jit_file(&runtime);
4903 child
4904 .stop(Duration::from_secs(1))
4905 .expect("native child stops");
4906
4907 let failed_runtime = root.path().join("failed");
4908 fs::create_dir_all(&failed_runtime).unwrap();
4909 let failed = RunnerAttempt::allocate(
4910 AttemptId::new_random(),
4911 policy.id,
4912 &failed_runtime,
4913 FakeClock::default().now(),
4914 );
4915 assert!(
4916 processes
4917 .spawn(&failed, &EncodedJitConfig::new(JIT))
4918 .is_err(),
4919 "a runtime with no runner executable must fail"
4920 );
4921 assert_no_jit_file(&failed_runtime);
4922 processes
4923 .record_terminate_intent(&failed)
4924 .expect("the intent file and its directory entry are durably synced");
4925 assert_eq!(
4926 fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
4927 b"registration-timeout\n"
4928 );
4929 }
4930
4931 #[test]
4932 fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
4933 let root = tempfile::tempdir().unwrap();
4934 let policy = fixtures::policy()
4935 .repository("octo/repo")
4936 .autoscale("home", 1)
4937 .active()
4938 .build();
4939 let processes = NativeProcesses::new();
4940 processes.use_long_lived_test_listener();
4941 for (index, boundary) in [
4942 PostSpawnBoundary::HandoffDelete,
4943 PostSpawnBoundary::IdentitySerialize,
4944 PostSpawnBoundary::IdentityWrite,
4945 PostSpawnBoundary::ChildMapInsert,
4946 ]
4947 .into_iter()
4948 .enumerate()
4949 {
4950 let runtime = root.path().join(format!("post-spawn-{index}"));
4951 let bin = runtime.join("bin");
4952 fs::create_dir_all(&bin).unwrap();
4953 #[cfg(windows)]
4954 let listener = bin.join("Runner.Listener.exe");
4955 #[cfg(not(windows))]
4956 let listener = bin.join("Runner.Listener");
4957 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
4958 let attempt = RunnerAttempt::allocate(
4959 AttemptId::new_random(),
4960 policy.id,
4961 &runtime,
4962 FakeClock::default().now(),
4963 );
4964 processes.fail_post_spawn_at(boundary);
4965 let failure = processes
4966 .spawn(&attempt, &EncodedJitConfig::new(JIT))
4967 .expect_err("fault must cross the post-spawn cleanup path");
4968 assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
4969 assert!(
4970 !processes.is_alive(&attempt).unwrap(),
4971 "{boundary:?} left a child"
4972 );
4973 assert!(!NativeProcesses::identity_path(&attempt).exists());
4974 assert_no_jit_file(&runtime);
4975 }
4976 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
4977
4978 let runtime = root.path().join("identity-and-stop-fail");
4979 let bin = runtime.join("bin");
4980 fs::create_dir_all(&bin).unwrap();
4981 #[cfg(windows)]
4982 let listener = bin.join("Runner.Listener.exe");
4983 #[cfg(not(windows))]
4984 let listener = bin.join("Runner.Listener");
4985 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
4986 let attempt = RunnerAttempt::allocate(
4987 AttemptId::new_random(),
4988 policy.id,
4989 &runtime,
4990 FakeClock::default().now(),
4991 );
4992 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
4996 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
4997 processes.fail_next_post_spawn_stop();
4998 let failure = processes
4999 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5000 .expect_err("the identity boundary must fail closed");
5001 assert!(failure.live_pid.is_some());
5002 assert_long_lived_listener_ready(&processes, &attempt);
5003 assert!(processes.is_alive(&attempt).unwrap());
5004 assert!(!NativeProcesses::identity_path(&attempt).exists());
5005 assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
5006 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5007 processes.terminate(&attempt).unwrap();
5008
5009 let runtime = root.path().join("persistent-stop-and-identity-failures");
5010 let bin = runtime.join("bin");
5011 fs::create_dir_all(&bin).unwrap();
5012 #[cfg(windows)]
5013 let listener = bin.join("Runner.Listener.exe");
5014 #[cfg(not(windows))]
5015 let listener = bin.join("Runner.Listener");
5016 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5017 let mut unresolved = RunnerAttempt::allocate(
5018 AttemptId::new_random(),
5019 policy.id,
5020 &runtime,
5021 FakeClock::default().now(),
5022 );
5023 for _ in 0..3 {
5024 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
5025 }
5026 processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
5027 let failure = processes
5028 .spawn(&unresolved, &EncodedJitConfig::new(JIT))
5029 .expect_err("bounded cleanup must return even when every stop errors");
5030 let pid = failure
5031 .live_pid
5032 .expect("the owned child remains supervised in this invocation");
5033 assert!(matches!(failure.reason, FailureReason::Other(_)));
5034 assert_long_lived_listener_ready(&processes, &unresolved);
5035 unresolved.jit_received(FakeClock::default().now()).unwrap();
5036 unresolved.started(pid, FakeClock::default().now()).unwrap();
5037 let journal = SqliteStore::open_in_memory().unwrap();
5038 journal.record_attempt(&unresolved).unwrap();
5039 let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
5040 assert_eq!(recovered.process_id(), Some(pid));
5041 assert_eq!(recovered.state(), AttemptState::Starting);
5042 assert!(processes.is_alive(&unresolved).unwrap());
5043 assert!(!NativeProcesses::identity_path(&unresolved).exists());
5044 assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
5045 assert_eq!(
5046 fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
5047 pid.to_string(),
5048 "bounded cleanup must leave durable unresolved-process evidence before returning"
5049 );
5050 assert!(
5051 NativeProcesses::new().is_alive(&recovered).is_err(),
5052 "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
5053 );
5054 processes.terminate(&unresolved).unwrap();
5055
5056 let runtime = root.path().join("post-spawn-stop-failed");
5057 let bin = runtime.join("bin");
5058 fs::create_dir_all(&bin).unwrap();
5059 #[cfg(windows)]
5060 let listener = bin.join("Runner.Listener.exe");
5061 #[cfg(not(windows))]
5062 let listener = bin.join("Runner.Listener");
5063 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5064 let attempt = RunnerAttempt::allocate(
5065 AttemptId::new_random(),
5066 policy.id,
5067 &runtime,
5068 FakeClock::default().now(),
5069 );
5070 processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
5071 processes.fail_next_post_spawn_stop();
5072 let failure = processes
5073 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5074 .expect_err("the injected stop failure must preserve supervision");
5075 let live_pid = failure
5076 .live_pid
5077 .expect("live PID is returned to the journal");
5078 assert!(!failure.retryable);
5079 assert_long_lived_listener_ready(&processes, &attempt);
5080 assert!(NativeProcesses::identity_path(&attempt).is_file());
5081 assert_eq!(
5082 NativeProcesses::read_identity(&attempt)
5083 .unwrap()
5084 .unwrap()
5085 .pid(),
5086 live_pid
5087 );
5088 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5089 processes.terminate(&attempt).unwrap();
5090 }
5091
5092 #[test]
5093 #[ignore = "spawned only as the platform-stable native listener fixture"]
5094 fn long_lived_native_listener_helper() {
5095 let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
5096 .map(PathBuf::from)
5097 .expect("the parent supplies the readiness path");
5098 fs::write(ready, b"ready\n").expect("the listener publishes readiness");
5099 std::thread::sleep(Duration::from_secs(30));
5100 }
5101
5102 fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
5103 let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
5104 let deadline = std::time::Instant::now() + Duration::from_secs(5);
5105 loop {
5106 if ready.is_file() {
5107 assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
5108 return;
5109 }
5110 assert!(
5111 processes.is_alive(attempt).unwrap(),
5112 "the native listener exited before publishing readiness"
5113 );
5114 assert!(
5115 std::time::Instant::now() < deadline,
5116 "the native listener stayed alive but never published readiness"
5117 );
5118 std::thread::sleep(Duration::from_millis(10));
5119 }
5120 }
5121
5122 #[tokio::test]
5123 async fn every_production_launch_prunes_under_the_same_allocation_guard() {
5124 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5125 harness.ready().await;
5126 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
5127 harness.launch().await;
5128 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
5129 assert_eq!(
5130 *harness.packages.prune_currents.lock().unwrap(),
5131 vec![harness.packages.version.clone()],
5132 "the leased current version is an exclusion, never the prune target"
5133 );
5134 }
5135
5136 fn assert_no_jit_file(runtime: &Path) {
5137 for entry in fs::read_dir(runtime).unwrap() {
5138 let path = entry.unwrap().path();
5139 if path.is_file() {
5140 let bytes = fs::read(&path).unwrap();
5141 assert!(
5142 !bytes
5143 .windows(JIT.len())
5144 .any(|window| window == JIT.as_bytes()),
5145 "a JIT payload survived in a runtime file"
5146 );
5147 }
5148 }
5149 }
5150
5151 #[test]
5152 fn production_listener_command_uses_the_supported_jit_contract() {
5153 let runtime = Path::new("runtime");
5154 let spec = runner_listener_spec(PathBuf::from("Runner.Listener"), runtime);
5155 let arguments: Vec<_> = spec
5156 .arguments()
5157 .iter()
5158 .map(|argument| argument.to_string_lossy().into_owned())
5159 .collect();
5160
5161 assert_eq!(arguments, ["run"]);
5162 assert!(
5163 !arguments
5164 .iter()
5165 .any(|argument| argument == "--jit-config-file"),
5166 "the obsolete file option would be rejected by Runner.Listener 2.336.0"
5167 );
5168 }
5169
5170 #[cfg(windows)]
5171 fn native_inspection_spec() -> SpawnSpec {
5172 SpawnSpec::new("powershell.exe").args([
5173 "-NoProfile",
5174 "-NonInteractive",
5175 "-Command",
5176 "Start-Sleep -Seconds 30",
5177 ])
5178 }
5179
5180 #[cfg(unix)]
5181 fn native_inspection_spec() -> SpawnSpec {
5182 SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
5183 }
5184
5185 #[cfg(windows)]
5186 fn native_command_line(pid: u32) -> String {
5187 let output = std::process::Command::new("powershell.exe")
5188 .args([
5189 "-NoProfile",
5190 "-NonInteractive",
5191 "-Command",
5192 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
5193 ])
5194 .output()
5195 .expect("PowerShell can inspect the native child");
5196 assert!(output.status.success(), "native process inspection failed");
5197 String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
5198 }
5199
5200 #[cfg(target_os = "linux")]
5201 fn native_command_line(pid: u32) -> String {
5202 fs::read(format!("/proc/{pid}/cmdline"))
5203 .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
5204 .expect("/proc exposes the native child command line")
5205 }
5206
5207 #[cfg(target_os = "macos")]
5208 fn native_command_line(pid: u32) -> String {
5209 let output = std::process::Command::new("ps")
5210 .args(["-o", "command=", "-p", &pid.to_string()])
5211 .output()
5212 .expect("ps can inspect the native child");
5213 assert!(output.status.success(), "native process inspection failed");
5214 String::from_utf8(output.stdout).expect("the command line is UTF-8")
5215 }
5216
5217 #[tokio::test]
5220 async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
5221 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5222 .with_persistent_workspace(2);
5223 harness.github.watch_journal(Arc::clone(&harness.store));
5224 harness.ready().await;
5225
5226 let attempt = harness.launch().await;
5227
5228 assert_eq!(
5229 attempt.workspace(),
5230 AttemptWorkspace::persistent_slot(nz(1)),
5231 "the lowest free slot is leased"
5232 );
5233 assert_eq!(attempt.runtime_path(), harness.slot_path(1));
5234 assert!(attempt.holds_slot_lease());
5235 assert_eq!(
5237 harness.attempt(attempt.id).runtime_path(),
5238 harness.slot_path(1)
5239 );
5240
5241 let facts = harness.github.registration_facts();
5245 assert_eq!(facts.len(), 1);
5246 assert_eq!(
5247 facts[0].leased_slots,
5248 vec![1],
5249 "the lease was journalled first"
5250 );
5251 assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
5252 }
5253
5254 #[tokio::test]
5255 async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
5256 let harness = Harness::new(
5257 FakeGithubLifecycle::default().fail(true),
5258 Arc::new(PersistentDemand),
5259 )
5260 .with_persistent_workspace(2);
5261 harness.ready().await;
5262
5263 harness.launch_result().await.unwrap_err();
5265 let first = harness.store.attempts().unwrap().remove(0);
5266 assert_eq!(first.state(), AttemptState::Failed);
5267 assert!(
5268 !first.state().counts_against_capacity(),
5269 "a concluded attempt is invisible to host capacity"
5270 );
5271 assert!(
5272 first.holds_slot_lease(),
5273 "and still owns its directory, so its slot is not free"
5274 );
5275
5276 let second = harness.launch().await;
5277 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5278 assert_eq!(second.runtime_path(), harness.slot_path(2));
5279 assert_eq!(
5280 harness
5281 .store
5282 .slot_leases_for_policy(harness.policy.id)
5283 .unwrap()
5284 .len(),
5285 2
5286 );
5287 }
5288
5289 #[tokio::test]
5290 async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
5291 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5292 .with_persistent_workspace(1);
5293 harness.ready().await;
5294
5295 let first = harness.launch().await;
5296 assert_eq!(first.runtime_path(), harness.slot_path(1));
5297
5298 let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
5300 fs::create_dir_all(&checkout).unwrap();
5301 fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
5302
5303 harness.cleanup_retaining_work(first.id).await;
5304
5305 let second = harness.launch().await;
5306 assert_ne!(second.id, first.id);
5307 assert_eq!(
5308 second.workspace(),
5309 AttemptWorkspace::persistent_slot(nz(1)),
5310 "a released slot is leased again rather than skipped"
5311 );
5312 assert_eq!(
5313 second.runtime_path(),
5314 first.runtime_path(),
5315 "the same slot is the same exact path"
5316 );
5317 assert_eq!(
5318 fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
5319 "from the first job",
5320 "the retained job workspace survived the second allocation"
5321 );
5322 assert!(harness.slot_path(1).join("runner-package").exists());
5324 }
5325
5326 #[tokio::test]
5327 async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
5328 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5329 .with_persistent_workspace(2);
5330 harness.ready().await;
5331
5332 let first = harness.launch().await;
5333 let second = harness.launch().await;
5334 assert_eq!(second.runtime_path(), harness.slot_path(2));
5335 let kept = harness
5336 .slot_path(2)
5337 .join(DEFAULT_WORK_FOLDER)
5338 .join("kept.txt");
5339 fs::create_dir_all(kept.parent().unwrap()).unwrap();
5340 fs::write(&kept, b"s2 was here").unwrap();
5341 harness.cleanup_retaining_work(second.id).await;
5342
5343 harness.policy.set_max_capacity(nz(1)).unwrap();
5345 let refusal = harness.launch_result().await.unwrap_err().to_string();
5346 assert!(
5347 refusal.contains("s1 to s1"),
5348 "the refusal names the ceiling it reached: {refusal}"
5349 );
5350 assert!(
5351 harness.slot_path(2).exists() && kept.exists(),
5352 "lowering capacity deletes nothing; the higher slot is merely unusable"
5353 );
5354
5355 harness.policy.set_max_capacity(nz(2)).unwrap();
5357 let third = harness.launch().await;
5358 assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5359 assert_eq!(third.runtime_path(), harness.slot_path(2));
5360 assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
5361 assert!(first.holds_slot_lease(), "s1 was never disturbed");
5362 }
5363
5364 #[tokio::test]
5365 async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
5366 for policy in [
5367 fixtures::policy()
5368 .organization("octo")
5369 .autoscale("home", 2)
5370 .active()
5371 .build(),
5372 fixtures::policy()
5373 .repository("octo/repo")
5374 .autoscale("home", 2)
5375 .active()
5376 .build(),
5377 ] {
5378 let mut harness =
5379 Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5380 .with_host_runner_root();
5381 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
5382 harness.policy = policy;
5383 harness.ready().await;
5384
5385 let attempt = harness.launch().await;
5386 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
5387 assert_eq!(attempt.workspace().slot_number(), None);
5388 assert!(!attempt.holds_slot_lease());
5389 assert_eq!(
5390 attempt.runtime_path().parent().unwrap(),
5391 harness.host_root(),
5392 "a disposable attempt is a child of the host root, never of a slot"
5393 );
5394 assert!(
5395 harness
5396 .store
5397 .slot_leases_for_policy(harness.policy.id)
5398 .unwrap()
5399 .is_empty()
5400 );
5401 }
5402 }
5403
5404 #[tokio::test]
5405 async fn two_concurrent_allocations_never_share_a_slot() {
5406 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5407 .with_persistent_workspace(2);
5408 harness.github.watch_journal(Arc::clone(&harness.store));
5409 harness.ready().await;
5410
5411 let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
5415 let first = first.unwrap();
5416 let second = second.unwrap();
5417
5418 let slots: BTreeSet<u16> = [&first, &second]
5419 .iter()
5420 .map(|attempt| {
5421 attempt
5422 .workspace()
5423 .slot_number()
5424 .expect("a persistent attempt leases a slot")
5425 })
5426 .collect();
5427 assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
5428 assert_ne!(first.runtime_path(), second.runtime_path());
5429 assert_eq!(
5430 harness
5431 .store
5432 .slot_leases_for_policy(harness.policy.id)
5433 .unwrap()
5434 .len(),
5435 2
5436 );
5437
5438 let facts = harness.github.registration_facts();
5443 assert_eq!(facts.len(), 2);
5444 for fact in facts {
5445 let attempt = [&first, &second]
5446 .into_iter()
5447 .find(|attempt| runner_name(attempt.id) == fact.runner_name)
5448 .expect("every registration belongs to one of the two attempts");
5449 let slot = attempt
5450 .workspace()
5451 .slot_number()
5452 .expect("a persistent attempt leases a slot");
5453 assert!(
5454 fact.leased_slots.contains(&slot),
5455 "a JIT request never precedes its own lease: s{slot} not in {:?}",
5456 fact.leased_slots
5457 );
5458 assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
5459 }
5460 }
5461
5462 #[tokio::test]
5463 async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
5464 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5465 .with_persistent_workspace(2);
5466 harness.ready().await;
5467 let first = harness.launch().await;
5468
5469 let clash = RunnerAttempt::allocate_in(
5473 AttemptId::new_random(),
5474 harness.policy.id,
5475 first.runtime_path(),
5476 AttemptWorkspace::persistent_slot(nz(1)),
5477 harness.clock.now(),
5478 );
5479 assert!(matches!(
5480 harness.store.record_attempt(&clash).unwrap_err(),
5481 StoreError::SlotAlreadyLeased { slot: 1, .. }
5482 ));
5483
5484 let error = harness.launcher.record_allocation(&clash).unwrap_err();
5487 let rendered = error.to_string();
5488 assert!(rendered.contains("slot s1"), "{rendered}");
5489 assert!(rendered.contains("nothing was written"), "{rendered}");
5490 assert_eq!(
5491 harness.store.attempts().unwrap().len(),
5492 1,
5493 "the losing allocator journalled nothing"
5494 );
5495 }
5496
5497 #[test]
5498 fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
5499 let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
5500 slots
5501 .iter()
5502 .map(|slot| {
5503 RunnerAttempt::allocate_in(
5504 AttemptId::new_random(),
5505 fixtures::POLICY_ID,
5506 format!("/srv/rman/acme/s{slot}"),
5507 AttemptWorkspace::persistent_slot(nz(*slot)),
5508 fixtures::created_at(),
5509 )
5510 })
5511 .collect()
5512 };
5513
5514 assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
5515 assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
5516 assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
5518 assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
5520 assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
5521 let ephemeral = vec![RunnerAttempt::allocate(
5523 AttemptId::new_random(),
5524 fixtures::POLICY_ID,
5525 "/srv/rman/host/abc",
5526 fixtures::created_at(),
5527 )];
5528 assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
5529 }
5530
5531 #[test]
5532 fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
5533 let root = tempfile::tempdir().unwrap();
5534 let slot = root.path().join("s1");
5535 fs::create_dir(&slot).unwrap();
5536 accept_reusable_slot(&slot).expect("an empty slot is reusable");
5537
5538 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
5539 accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
5540
5541 fs::create_dir(slot.join("bin")).unwrap();
5544 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
5545 let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
5546 assert!(refusal.contains("bin"), "{refusal}");
5547 assert!(refusal.contains(".github-runner-id"), "{refusal}");
5548
5549 let file_work = root.path().join("s2");
5551 fs::create_dir(&file_work).unwrap();
5552 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
5553 assert!(accept_reusable_slot(&file_work).is_err());
5554 }
5555
5556 #[cfg(unix)]
5557 #[test]
5558 fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
5559 let root = tempfile::tempdir().unwrap();
5563 let elsewhere = root.path().join("elsewhere");
5564 fs::create_dir(&elsewhere).unwrap();
5565
5566 let slot = root.path().join("s1");
5567 fs::create_dir(&slot).unwrap();
5568 std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
5569 assert!(accept_reusable_slot(&slot).is_err());
5570
5571 let linked_slot = root.path().join("s2");
5572 std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
5573 assert!(create_or_validate_slot(&linked_slot).is_err());
5574 }
5575
5576 #[test]
5577 fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
5578 let root = tempfile::tempdir().unwrap();
5579 let occupied = root.path().join("s1");
5580 fs::write(&occupied, b"an operator's file").unwrap();
5581 let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
5582 assert!(refusal.contains("is not a directory"), "{refusal}");
5583 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
5584
5585 let fresh = root.path().join("s2");
5586 create_or_validate_slot(&fresh).expect("a missing slot is created");
5587 assert!(fresh.is_dir());
5588 create_or_validate_slot(&fresh).expect("an existing directory is accepted");
5589 }
5590
5591 #[test]
5592 fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
5593 assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
5594 assert!(!is_work_folder(OsStr::new("_work2")));
5595 assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
5599 }
5600
5601 #[test]
5602 fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
5603 let root = tempfile::tempdir().unwrap();
5604 let package = root.path().join("package");
5605 fs::create_dir_all(package.join("bin")).unwrap();
5606 fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
5607 fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
5609
5610 let slot = root.path().join("s1");
5611 let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
5612 fs::create_dir_all(&retained).unwrap();
5613 fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
5614
5615 copy_package_tree(&package, &slot).expect("the package lays out around `_work`");
5616 assert!(slot.join("bin").join("Runner.Listener").exists());
5617 assert!(
5618 slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
5619 "the guard is top-level only"
5620 );
5621 assert_eq!(
5622 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
5623 "from the first job"
5624 );
5625
5626 fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
5628 let error = copy_package_tree(&package, &slot).unwrap_err();
5629 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
5630 assert_eq!(
5631 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
5632 "from the first job"
5633 );
5634 }
5635
5636 #[test]
5637 fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
5638 let root = tempfile::tempdir().unwrap();
5639
5640 let slot = root.path().join("s1");
5641 let retained = slot.join(DEFAULT_WORK_FOLDER);
5642 fs::create_dir_all(retained.join("repo")).unwrap();
5643 fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
5644 fs::create_dir_all(slot.join("bin")).unwrap();
5645 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
5646 let persistent = RunnerAttempt::allocate_in(
5647 AttemptId::new_random(),
5648 fixtures::POLICY_ID,
5649 &slot,
5650 AttemptWorkspace::persistent_slot(nz(1)),
5651 fixtures::created_at(),
5652 );
5653
5654 remove_materialized_package(&persistent).unwrap();
5655 assert!(slot.is_dir(), "the slot itself is not removed");
5656 assert!(!slot.join("bin").exists());
5657 assert!(!slot.join(".github-runner-id").exists());
5658 assert_eq!(
5659 fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
5660 "kept"
5661 );
5662
5663 let disposable_path = root.path().join("abcdef012345");
5664 fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
5665 let disposable = RunnerAttempt::allocate(
5666 AttemptId::new_random(),
5667 fixtures::POLICY_ID,
5668 &disposable_path,
5669 fixtures::created_at(),
5670 );
5671 remove_materialized_package(&disposable).unwrap();
5672 assert!(
5673 !disposable_path.exists(),
5674 "a disposable directory is still removed whole"
5675 );
5676 }
5677
5678 fn litter_the_slot(slot: &Path) {
5688 for directory in ["bin", "externals", "_diag"] {
5689 fs::create_dir_all(slot.join(directory)).unwrap();
5690 }
5691 fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
5692 for file in SENSITIVE_SLOT_ENTRIES
5693 .iter()
5694 .filter(|entry| !slot.join(entry).is_dir())
5695 {
5696 fs::write(slot.join(file), b"runner state").unwrap();
5697 }
5698 fs::write(
5700 slot.join(format!(
5701 "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
5702 RestrictiveHandoff::NAME_PREFIX
5703 )),
5704 JIT.as_bytes(),
5705 )
5706 .unwrap();
5707 }
5708
5709 fn retain_under_work(slot: &Path) -> PathBuf {
5711 let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
5712 fs::create_dir_all(&checkout).unwrap();
5713 let marker = checkout.join("build-output.bin");
5714 fs::write(&marker, RETAINED).unwrap();
5715 marker
5716 }
5717
5718 const RETAINED: &str = "a Git-ignored build output the next job reuses";
5720
5721 fn entries_of(directory: &Path) -> Vec<String> {
5723 let mut names: Vec<String> = fs::read_dir(directory)
5724 .unwrap()
5725 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
5726 .collect();
5727 names.sort();
5728 names
5729 }
5730
5731 fn only_the_job_workspace() -> Vec<String> {
5733 vec![DEFAULT_WORK_FOLDER.to_owned()]
5734 }
5735
5736 #[cfg(unix)]
5737 #[test]
5738 fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
5739 use std::sync::mpsc;
5740
5741 let temporary = tempfile::tempdir().unwrap();
5742 let tree = temporary.path().join("attempt");
5743 let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
5744 fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
5745 assert!(
5746 std::process::Command::new("mkfifo")
5747 .arg(&diagnostic)
5748 .status()
5749 .unwrap()
5750 .success()
5751 );
5752
5753 let (finished, result) = mpsc::channel();
5754 std::thread::spawn(move || {
5755 let removed = remove_runtime_tree(&tree);
5756 let _ = finished.send(removed);
5757 });
5758
5759 result
5760 .recv_timeout(Duration::from_secs(2))
5761 .expect("runtime deletion must not wait for a FIFO peer")
5762 .unwrap();
5763 assert!(!diagnostic.exists());
5764 }
5765
5766 struct BlockedDeletion {
5782 directory: PathBuf,
5783 #[cfg(windows)]
5784 _handle: fs::File,
5785 }
5786
5787 impl BlockedDeletion {
5788 const HELD: &'static str = "held-open";
5789
5790 fn inject(directory: &Path) -> Option<Self> {
5793 #[cfg(unix)]
5794 if !Self::refusal_is_possible() {
5795 return None;
5796 }
5797 fs::create_dir_all(directory).unwrap();
5798 fs::write(
5799 directory.join(Self::HELD),
5800 b"a file the scrub cannot remove",
5801 )
5802 .unwrap();
5803 #[cfg(windows)]
5804 let handle = {
5805 use std::os::windows::fs::OpenOptionsExt;
5806
5807 fs::OpenOptions::new()
5808 .read(true)
5809 .share_mode(0)
5810 .open(directory.join(Self::HELD))
5811 .expect("the blocking handle opens")
5812 };
5813 #[cfg(unix)]
5814 Self::set_mode(directory, 0o555);
5815 Some(Self {
5816 directory: directory.to_path_buf(),
5817 #[cfg(windows)]
5818 _handle: handle,
5819 })
5820 }
5821
5822 fn release(self) {
5823 drop(self);
5824 }
5825
5826 #[cfg(unix)]
5827 fn refusal_is_possible() -> bool {
5828 let probe = tempfile::tempdir().unwrap();
5829 let directory = probe.path().join("probe");
5830 fs::create_dir(&directory).unwrap();
5831 fs::write(directory.join("file"), b"probe").unwrap();
5832 Self::set_mode(&directory, 0o555);
5833 let refused = fs::remove_dir_all(&directory).is_err();
5834 Self::set_mode(&directory, 0o755);
5835 refused
5836 }
5837
5838 #[cfg(unix)]
5839 fn set_mode(directory: &Path, mode: u32) {
5840 use std::os::unix::fs::PermissionsExt;
5841
5842 let mut permissions = fs::metadata(directory).unwrap().permissions();
5843 permissions.set_mode(mode);
5844 fs::set_permissions(directory, permissions).unwrap();
5845 }
5846 }
5847
5848 impl Drop for BlockedDeletion {
5849 fn drop(&mut self) {
5850 #[cfg(unix)]
5851 Self::set_mode(&self.directory, 0o755);
5852 #[cfg(not(unix))]
5853 let _ = &self.directory;
5854 }
5855 }
5856
5857 #[tokio::test]
5858 async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
5859 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5860 .with_persistent_workspace(1);
5861 harness.ready().await;
5862
5863 let first = harness.launch().await;
5864 let slot = harness.slot_path(1);
5865 assert_eq!(first.runtime_path(), slot);
5866 assert_eq!(
5867 read_runner_id(&slot),
5868 Some(73),
5869 "the attempt registered, so its identity is on disk"
5870 );
5871 let marker = retain_under_work(&slot);
5872 litter_the_slot(&slot);
5873
5874 harness.cleanup_retaining_work(first.id).await;
5875
5876 assert_eq!(entries_of(&slot), only_the_job_workspace());
5879 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
5880 assert_eq!(
5881 read_runner_id(&slot),
5882 None,
5883 "the first attempt's registration identity is gone before the second starts"
5884 );
5885 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
5886 assert!(!harness.attempt(first.id).holds_slot_lease());
5887
5888 let second = harness.launch().await;
5889 assert_ne!(second.id, first.id);
5890 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
5891 assert_eq!(
5892 second.runtime_path(),
5893 slot,
5894 "the same slot, so the same retained `_work`"
5895 );
5896 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
5897 }
5898
5899 #[tokio::test]
5900 async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
5901 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5902 .with_persistent_workspace(1);
5903 harness.ready().await;
5904 let attempt = harness.launch().await;
5905 let slot = harness.slot_path(1);
5906 let marker = retain_under_work(&slot);
5907 litter_the_slot(&slot);
5908 harness.conclude(attempt.id);
5909
5910 harness
5916 .store
5917 .remove_policy(harness.policy.id, harness.policy.revision())
5918 .unwrap();
5919 assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
5920
5921 harness
5922 .launcher
5923 .clean(attempt.id)
5924 .await
5925 .expect("journal facts alone are enough to clean the slot");
5926
5927 assert_eq!(entries_of(&slot), only_the_job_workspace());
5928 assert!(marker.exists());
5929 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
5930 }
5931
5932 #[tokio::test]
5933 async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
5934 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5935 .with_persistent_workspace(2);
5936 harness.ready().await;
5937 let first = harness.launch().await;
5938 let slot = harness.slot_path(1);
5939 let marker = retain_under_work(&slot);
5940 litter_the_slot(&slot);
5941 harness.conclude(first.id);
5942
5943 let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
5944 eprintln!(
5945 "skipped: this account cannot be refused a deletion, so no partial deletion can \
5946 be injected"
5947 );
5948 return;
5949 };
5950
5951 let refusal = harness
5952 .launcher
5953 .clean(first.id)
5954 .await
5955 .expect_err("a deletion that failed may not report a cleaned slot");
5956 let rendered = refusal.reason.to_string();
5957 assert!(rendered.contains("could not be removed"), "{rendered}");
5958
5959 let held = harness.attempt(first.id);
5960 assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
5961 assert!(held.holds_slot_lease(), "so the slot is still leased");
5962 assert!(
5963 !held.state().counts_against_capacity(),
5964 "and a concluded attempt still costs the host no capacity"
5965 );
5966
5967 let restarted = harness.restart();
5972 restarted
5973 .recover_startup(std::slice::from_ref(&harness.policy))
5974 .await
5975 .expect("one quarantined slot does not stop the host recovering");
5976 assert_eq!(
5977 harness.attempt(first.id).state(),
5978 AttemptState::Failed,
5979 "the quarantine survived the restart"
5980 );
5981 assert!(
5982 harness
5983 .reconcile_events
5984 .events()
5985 .iter()
5986 .any(|event| matches!(
5987 event,
5988 LifecycleEvent::AttemptCleanFailed {
5989 reason: "slot_entry_could_not_be_removed",
5990 ..
5991 }
5992 )),
5993 "the refusal is reported rather than retried in silence"
5994 );
5995
5996 let guard = harness.allocation_lock.acquire().await.unwrap();
5999 let second = restarted
6000 .launch(LaunchRequest {
6001 host: &harness.host,
6002 policy: &harness.policy,
6003 allocation_guard: &guard,
6004 })
6005 .await
6006 .expect("the host can still launch");
6007 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
6008 drop(guard);
6009
6010 block.release();
6013 restarted
6014 .clean(first.id)
6015 .await
6016 .expect("the retried cleanup completes");
6017 assert_eq!(entries_of(&slot), only_the_job_workspace());
6018 assert!(marker.exists());
6019 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
6020 }
6021
6022 #[tokio::test]
6033 async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
6034 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6035 harness.ready().await;
6036 let attempt = harness.launch().await;
6037 let runtime = attempt.runtime_path().to_path_buf();
6038 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
6039 harness.conclude(attempt.id);
6040
6041 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
6042 eprintln!(
6043 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
6044 );
6045 return;
6046 };
6047
6048 let refusal = harness
6049 .launcher
6050 .clean(attempt.id)
6051 .await
6052 .expect_err("a deletion that failed may not report a removed workspace");
6053 let rendered = refusal.reason.to_string();
6054 assert!(
6055 rendered.contains("could not be removed"),
6056 "the refusal names what happened: {rendered}"
6057 );
6058 assert_ne!(
6059 harness.attempt(attempt.id).state(),
6060 AttemptState::Cleaned,
6061 "an attempt whose directory is still on disk is not cleaned"
6062 );
6063 assert!(
6064 runtime.is_dir(),
6065 "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
6066 );
6067
6068 block.release();
6071 harness
6072 .launcher
6073 .clean(attempt.id)
6074 .await
6075 .expect("the retried cleanup completes");
6076 assert!(!runtime.exists(), "the whole attempt directory goes");
6077 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
6078 }
6079
6080 #[tokio::test]
6081 async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
6082 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6083 .with_persistent_workspace(1);
6084 harness.ready().await;
6085 let first = harness.launch().await;
6086 let slot = harness.slot_path(1);
6087 let marker = retain_under_work(&slot);
6088 harness.cleanup_retaining_work(first.id).await;
6089
6090 harness
6094 .policy
6095 .set_workspace_policy(WorkspacePolicy::Ephemeral)
6096 .unwrap();
6097
6098 let second = harness.launch().await;
6099 assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
6100 assert_eq!(
6101 second.runtime_path().parent().unwrap(),
6102 harness.host_root(),
6103 "a disposable attempt is a child of the host root"
6104 );
6105 assert!(slot.is_dir(), "the old slot is left where it stands");
6106 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
6107
6108 harness.conclude(second.id);
6111 harness.launcher.clean(second.id).await.unwrap();
6112 assert!(!second.runtime_path().exists());
6113 assert!(marker.exists());
6114 }
6115
6116 #[tokio::test]
6117 async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
6118 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6119 .with_persistent_workspace(1);
6120 harness.ready().await;
6121 let slot = harness.slot_path(1);
6122 fs::create_dir_all(&slot).unwrap();
6123 let marker = retain_under_work(&slot);
6124 litter_the_slot(&slot);
6125
6126 let id = AttemptId::new_random();
6127 let mut attempt = RunnerAttempt::allocate_in(
6128 id,
6129 harness.policy.id,
6130 &slot,
6131 AttemptWorkspace::persistent_slot(nz(1)),
6132 harness.clock.now(),
6133 );
6134 attempt.jit_received(harness.clock.now()).unwrap();
6135 attempt.started(4242, harness.clock.now()).unwrap();
6136 harness.store.record_attempt(&attempt).unwrap();
6137 harness.clock.advance_secs(11);
6138 harness.processes.set_alive(true);
6139 harness
6140 .github
6141 .observe(GithubRunnerObservation::NotRegistered);
6142
6143 harness.launcher.supervise(&harness.policy).await.unwrap();
6144
6145 let actions = harness.processes.actions.lock().unwrap().clone();
6149 let intent = actions
6150 .iter()
6151 .position(|action| *action == "terminate_intent")
6152 .unwrap();
6153 let signal = actions
6154 .iter()
6155 .position(|action| *action == "terminate")
6156 .unwrap();
6157 assert!(intent < signal, "{actions:?}");
6158 assert!(!harness.processes.alive.load(Ordering::SeqCst));
6159
6160 let cleaned = harness.attempt(id);
6161 assert_eq!(cleaned.state(), AttemptState::Cleaned);
6162 assert!(matches!(
6163 cleaned.outcome(),
6164 Some(AttemptOutcome::Failed {
6165 reason: FailureReason::TerminatedAfterRegistrationTimeout
6166 })
6167 ));
6168 assert_eq!(entries_of(&slot), only_the_job_workspace());
6169 assert!(marker.exists());
6170 }
6171
6172 #[test]
6173 fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
6174 let root = tempfile::tempdir().unwrap();
6175 let slot = root.path().join("s1");
6176 fs::create_dir(&slot).unwrap();
6177 let marker = retain_under_work(&slot);
6178 litter_the_slot(&slot);
6179 fs::write(slot.join("runner-package"), b"verified").unwrap();
6180
6181 scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
6182 verify_slot_scrubbed(&slot).expect("and proves it afterwards");
6183
6184 assert_eq!(entries_of(&slot), only_the_job_workspace());
6185 assert!(marker.exists());
6186 }
6187
6188 #[test]
6189 fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
6190 let slot = Path::new("/runners/s1");
6191
6192 let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
6195 assert!(counted.contains("2 entries other than"), "{counted}");
6196 assert!(counted.contains("including `bin`"), "{counted}");
6197 assert_eq!(
6198 residue_detail(slot, 1, &[]),
6199 format!(
6200 "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
6201 slot.display()
6202 )
6203 );
6204
6205 let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
6210 assert!(!raced.contains('0'), "{raced}");
6211 assert!(raced.contains("reported nothing but"), "{raced}");
6212 assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
6213 }
6214
6215 #[test]
6216 fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
6217 let root = tempfile::tempdir().unwrap();
6218 let slot = root.path().join("s1");
6219 fs::create_dir(&slot).unwrap();
6220 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6221 verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
6222
6223 for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
6227 fs::write(slot.join(survivor), b"left behind").unwrap();
6228 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6229 assert_eq!(quarantine.refusal, SlotRefusal::Residue);
6230 assert!(
6231 quarantine.detail.contains(&format!("`{survivor}`")),
6232 "{quarantine}"
6233 );
6234 fs::remove_file(slot.join(survivor)).unwrap();
6235 }
6236
6237 let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
6240 fs::write(&handoff, JIT.as_bytes()).unwrap();
6241 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6242 assert!(
6243 quarantine.detail.contains("an encoded JIT handoff"),
6244 "{quarantine}"
6245 );
6246 assert!(!quarantine.detail.contains(JIT), "{quarantine}");
6247 fs::remove_file(&handoff).unwrap();
6248
6249 fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
6253 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6254 assert!(
6255 quarantine.detail.contains("1 entry other than"),
6256 "{quarantine}"
6257 );
6258 assert!(
6259 !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
6260 "{quarantine}"
6261 );
6262 }
6263
6264 #[test]
6265 fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
6266 let root = tempfile::tempdir().unwrap();
6267 let configured =
6268 LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
6269 let slot = configured.as_path().join("s1");
6270 fs::create_dir(&slot).unwrap();
6271
6272 verify_journalled_slot(&slot, nz(1), Some(&configured))
6273 .expect("the journalled slot agrees");
6274 verify_journalled_slot(&slot, nz(1), None)
6275 .expect("and a policy that is gone removes a check, not the ability to clean");
6276
6277 assert_eq!(
6280 verify_journalled_slot(&slot, nz(2), None)
6281 .unwrap_err()
6282 .refusal,
6283 SlotRefusal::NotTheJournalledSlot
6284 );
6285 for stray in ["s1/nested", "not-a-slot", "s01"] {
6286 let path = configured.as_path().join(stray);
6287 assert_eq!(
6288 verify_journalled_slot(&path, nz(1), None)
6289 .unwrap_err()
6290 .refusal,
6291 SlotRefusal::NotTheJournalledSlot,
6292 "{}",
6293 path.display()
6294 );
6295 }
6296
6297 let elsewhere = tempfile::tempdir().unwrap();
6300 let other =
6301 LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
6302 assert_eq!(
6303 verify_journalled_slot(&slot, nz(1), Some(&other))
6304 .unwrap_err()
6305 .refusal,
6306 SlotRefusal::PolicyRootDisagrees
6307 );
6308 }
6309
6310 #[cfg(unix)]
6311 #[test]
6312 fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
6313 let root = tempfile::tempdir().unwrap();
6317 let outside = root.path().join("operator-data");
6318 fs::create_dir(&outside).unwrap();
6319 let sentinel = outside.join("do-not-delete.txt");
6320 fs::write(
6321 &sentinel,
6322 b"an operator's data, outside every approved root",
6323 )
6324 .unwrap();
6325
6326 let slot = root.path().join("s1");
6327 fs::create_dir(&slot).unwrap();
6328 fs::create_dir(slot.join("bin")).unwrap();
6329 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6330
6331 let quarantine = scrub_slot_entries(&slot).unwrap_err();
6332 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
6333 assert!(
6334 sentinel.exists(),
6335 "the deletion followed the link out of the slot"
6336 );
6337 assert!(outside.is_dir());
6338 assert!(
6339 slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
6340 "the substituted link is left for the operator, never unlinked as if it were ours"
6341 );
6342
6343 let file_work = root.path().join("s2");
6347 fs::create_dir(&file_work).unwrap();
6348 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
6349 assert_eq!(
6350 scrub_slot_entries(&file_work).unwrap_err().refusal,
6351 SlotRefusal::WorkNotADirectory
6352 );
6353 }
6354
6355 #[cfg(unix)]
6356 #[test]
6357 fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
6358 let root = tempfile::tempdir().unwrap();
6359 let outside = root.path().join("operator-data");
6360 fs::create_dir(&outside).unwrap();
6361 let sentinel = outside.join("do-not-delete.txt");
6362 fs::write(
6363 &sentinel,
6364 b"an operator's data, outside every approved root",
6365 )
6366 .unwrap();
6367
6368 let inside = root.path().join("inside");
6371 fs::create_dir(&inside).unwrap();
6372 let slot = inside.join("s1");
6373 std::os::unix::fs::symlink(&outside, &slot).unwrap();
6374
6375 assert_eq!(
6376 verify_journalled_slot(&slot, nz(1), None)
6377 .unwrap_err()
6378 .refusal,
6379 SlotRefusal::Containment
6380 );
6381 assert!(sentinel.exists());
6382 assert!(
6383 slot.symlink_metadata().is_ok(),
6384 "the link is left for the operator rather than removed as if it were ours"
6385 );
6386 }
6387
6388 #[cfg(windows)]
6398 #[test]
6399 fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
6400 let root = tempfile::tempdir().unwrap();
6401 let outside = root.path().join("operator-data");
6402 fs::create_dir(&outside).unwrap();
6403 let sentinel = outside.join("do-not-delete.txt");
6404 fs::write(
6405 &sentinel,
6406 b"an operator's data, outside every approved root",
6407 )
6408 .unwrap();
6409
6410 let inside = root.path().join("inside");
6413 fs::create_dir(&inside).unwrap();
6414 let slot = inside.join("s1");
6415 let Some(()) = plant_junction(&slot, &outside) else {
6416 eprintln!("skipped: this machine would not create a directory junction");
6417 return;
6418 };
6419
6420 assert_eq!(
6421 verify_journalled_slot(&slot, nz(1), None)
6422 .unwrap_err()
6423 .refusal,
6424 SlotRefusal::Containment
6425 );
6426 assert!(
6427 sentinel.exists(),
6428 "the refusal resolved the junction and reached the operator's data"
6429 );
6430 assert!(
6431 slot.symlink_metadata().is_ok(),
6432 "the junction is left for the operator rather than removed as if it were ours"
6433 );
6434 }
6435
6436 #[cfg(windows)]
6445 fn plant_junction(link: &Path, target: &Path) -> Option<()> {
6446 let made = std::process::Command::new("cmd")
6447 .arg("/C")
6448 .arg("mklink")
6449 .arg("/J")
6450 .arg(link)
6451 .arg(target)
6452 .output()
6453 .ok()?;
6454 (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
6455 }
6456
6457 #[cfg(windows)]
6458 #[test]
6459 fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
6460 let root = tempfile::tempdir().unwrap();
6461 let outside = root.path().join("operator-data");
6462 fs::create_dir(&outside).unwrap();
6463 let sentinel = outside.join("do-not-delete.txt");
6464 fs::write(
6465 &sentinel,
6466 b"an operator's data, outside every approved root",
6467 )
6468 .unwrap();
6469
6470 let slot = root.path().join("s1");
6471 fs::create_dir(&slot).unwrap();
6472 fs::create_dir(slot.join("bin")).unwrap();
6473 let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
6474 eprintln!("skipped: this machine would not create a directory junction");
6475 return;
6476 };
6477
6478 let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6482 assert!(is_link_like(&work), "a junction is a reparse point");
6483 let quarantine = scrub_slot_entries(&slot).unwrap_err();
6484 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
6485 assert!(
6486 sentinel.exists(),
6487 "the deletion followed the junction out of the slot"
6488 );
6489 assert!(outside.is_dir());
6490
6491 let elsewhere = root.path().join("s2");
6494 fs::create_dir(&elsewhere).unwrap();
6495 fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
6496 if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
6497 scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
6498 verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
6499 assert!(sentinel.exists(), "the junction was followed, not unlinked");
6500 assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
6501 }
6502 }
6503
6504 #[cfg(unix)]
6505 #[tokio::test]
6506 async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
6507 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6508 .with_persistent_workspace(2);
6509 harness.ready().await;
6510 let first = harness.launch().await;
6511 let slot = harness.slot_path(1);
6512 harness.conclude(first.id);
6513
6514 let outside = harness._root.path().join("operator-data");
6515 fs::create_dir_all(&outside).unwrap();
6516 let sentinel = outside.join("do-not-delete.txt");
6517 fs::write(&sentinel, b"outside every approved root").unwrap();
6518 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6519
6520 harness
6521 .launcher
6522 .clean(first.id)
6523 .await
6524 .expect_err("a slot whose `_work` was substituted is quarantined");
6525 assert!(sentinel.exists());
6526
6527 let held = harness.attempt(first.id);
6528 assert_eq!(held.state(), AttemptState::Failed);
6529 assert!(held.holds_slot_lease());
6530
6531 let second = harness.launch().await;
6533 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
6534 }
6535
6536 #[test]
6537 fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
6538 let root = tempfile::tempdir().unwrap();
6539 let occupied = root.path().join("s1");
6540 fs::write(&occupied, b"an operator's file").unwrap();
6541 verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
6544 assert_eq!(
6545 slot_is_present(&occupied).unwrap_err().refusal,
6546 SlotRefusal::SlotNotADirectory
6547 );
6548 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
6549
6550 assert!(!slot_is_present(&root.path().join("s2")).unwrap());
6553 let present = root.path().join("s3");
6554 fs::create_dir(&present).unwrap();
6555 assert!(slot_is_present(&present).unwrap());
6556 }
6557
6558 #[test]
6559 fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
6560 let root = tempfile::tempdir().unwrap();
6561
6562 let disposable = root.path().join("abcdef012345");
6566 fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
6567 let ephemeral = RunnerAttempt::allocate(
6568 AttemptId::new_random(),
6569 fixtures::POLICY_ID,
6570 &disposable,
6571 fixtures::created_at(),
6572 );
6573 remove_materialized_package(&ephemeral).unwrap();
6574 assert!(!disposable.exists());
6575
6576 let slot = root.path().join("s1");
6578 fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
6579 fs::create_dir_all(slot.join("bin")).unwrap();
6580 let persistent = RunnerAttempt::allocate_in(
6581 AttemptId::new_random(),
6582 fixtures::POLICY_ID,
6583 &slot,
6584 AttemptWorkspace::persistent_slot(nz(1)),
6585 fixtures::created_at(),
6586 );
6587 remove_materialized_package(&persistent).unwrap();
6588 assert_eq!(entries_of(&slot), only_the_job_workspace());
6589 assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
6590 }
6591
6592 #[test]
6593 fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
6594 let refusals = [
6595 SlotRefusal::NotTheJournalledSlot,
6596 SlotRefusal::PolicyRootDisagrees,
6597 SlotRefusal::Containment,
6598 SlotRefusal::SlotNotADirectory,
6599 SlotRefusal::Enumeration,
6600 SlotRefusal::WorkNotADirectory,
6601 SlotRefusal::Deletion,
6602 SlotRefusal::Residue,
6603 ];
6604 let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
6605 assert_eq!(
6606 classes.len(),
6607 refusals.len(),
6608 "an event class shared by two refusals tells an operator less than it appears to"
6609 );
6610 for refusal in refusals {
6611 assert!(
6614 refusal
6615 .class()
6616 .chars()
6617 .all(|c| c.is_ascii_lowercase() || c == '_'),
6618 "{}",
6619 refusal.class()
6620 );
6621 assert!(
6622 refusal.remediation().contains("slot lease"),
6623 "every refusal has to say the lease is still held: {}",
6624 refusal.class()
6625 );
6626 }
6627 }
6628
6629 #[test]
6630 fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
6631 let root = tempfile::tempdir().unwrap();
6632 let source = root.path().join("source with spaces");
6633 let dest = root.path().join("dest with spaces");
6634
6635 fs::create_dir_all(&source).unwrap();
6636 fs::write(source.join("file1.txt"), b"hello").unwrap();
6637
6638 let nested = source.join("nested dir");
6639 fs::create_dir_all(&nested).unwrap();
6640 fs::write(nested.join("file2.txt"), b"world").unwrap();
6641
6642 let nested_work = nested.join(DEFAULT_WORK_FOLDER);
6644 fs::create_dir_all(&nested_work).unwrap();
6645 fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
6646
6647 copy_package_tree(&source, &dest).unwrap();
6648
6649 assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
6650 assert_eq!(
6651 fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
6652 "world"
6653 );
6654 assert_eq!(
6655 fs::read_to_string(
6656 dest.join("nested dir")
6657 .join(DEFAULT_WORK_FOLDER)
6658 .join("allowed.txt")
6659 )
6660 .unwrap(),
6661 "allowed"
6662 );
6663 }
6664
6665 #[test]
6666 fn copy_package_tree_refuses_top_level_work_folder() {
6667 let root = tempfile::tempdir().unwrap();
6668 let source = root.path().join("source");
6669 let dest = root.path().join("dest");
6670
6671 fs::create_dir_all(&source).unwrap();
6672 fs::write(source.join("file1.txt"), b"hello").unwrap();
6673
6674 let top_work = source.join(DEFAULT_WORK_FOLDER);
6676 fs::create_dir_all(&top_work).unwrap();
6677
6678 let err = copy_package_tree(&source, &dest).unwrap_err();
6679 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
6680 }
6681}