1use std::path::{Path, PathBuf};
30use std::process::{Child, Command, Stdio};
31use std::sync::mpsc;
32use std::time::{Duration, Instant, SystemTime};
33
34use serde::{Deserialize, Serialize};
35
36use super::{
37 placement_identity, pool_dir, shed, state_of, survey, write_record, Claim, MaintenanceOutcome,
38 SlotRecord, SlotState, Timestamp,
39};
40use crate::error::{self, Result};
41use crate::event::ArtifactId;
42use crate::session::{Lifecycle, Scope, SessionToken};
43use crate::store::{self, Resolution};
44use crate::workspaces::{self, Maintenance, Overrides, Span};
45use crate::{git, ids, lock, processes, stream, workspace};
46
47const POLL: Duration = Duration::from_millis(10);
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct MaintainReport {
54 pub identities: Vec<IdentityMaintenance>,
56}
57
58impl MaintainReport {
59 pub(crate) fn every_command_succeeded(&self) -> bool {
63 self.identities
64 .iter()
65 .all(|identity| match &identity.outcome {
66 IdentityOutcome::Slots(slots) => slots.iter().all(|slot| {
67 !matches!(
68 slot.outcome,
69 SlotOutcome::Ran {
70 outcome: MaintenanceOutcome::Failed { .. }
71 | MaintenanceOutcome::TimedOut,
72 ..
73 }
74 )
75 }),
76 _ => true,
77 })
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct IdentityMaintenance {
84 pub identity: String,
86 pub outcome: IdentityOutcome,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum IdentityOutcome {
97 NoMaintainCommand,
99 MaintenanceUnreadable {
101 reason: String,
103 },
104 NoSlots,
106 Claimed {
108 by_pid: u32,
110 },
111 Slots(Vec<SlotMaintenance>),
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct SlotMaintenance {
118 pub number: u32,
120 pub outcome: SlotOutcome,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "kebab-case")]
129pub enum SlotOutcome {
130 NotDue {
132 last_maintained: String,
134 },
135 InUse {
137 session: SessionToken,
139 },
140 Unavailable {
147 holder: String,
149 },
150 Broken {
153 reason: String,
155 },
156 Ran {
158 outcome: MaintenanceOutcome,
160 duration_ms: u64,
162 log: Option<ArtifactId>,
165 disk: Option<SlotDisk>,
167 },
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct SlotDisk {
173 pub before_bytes: u64,
175 pub after_bytes: u64,
177}
178
179fn maintenance_identity(pool: &Path) -> String {
185 format!("maintain:{}", pool.display())
186}
187
188pub fn pool_maintain(scope: Scope, older_than: Option<Span>) -> Result<MaintainReport> {
193 let registry = store::load()?;
194 let resolutions: Vec<Resolution> = match &scope {
195 Scope::Repo(repo) => vec![store::resolve(®istry, repo)?],
196 Scope::All => {
197 let mut identities: Vec<&str> = registry
201 .checkouts
202 .values()
203 .map(|checkout| checkout.identity.as_str())
204 .collect();
205 identities.sort_unstable();
206 identities.dedup();
207 identities
208 .into_iter()
209 .map(|identity| store::resolve(®istry, identity))
210 .collect::<Result<Vec<_>>>()?
211 }
212 };
213 let mut report = MaintainReport {
214 identities: Vec::new(),
215 };
216 for resolution in resolutions {
217 let outcome = maintain_identity(&resolution, older_than)?;
218 report.identities.push(IdentityMaintenance {
219 identity: resolution.key,
220 outcome,
221 });
222 }
223 Ok(report)
224}
225
226fn maintain_identity(resolution: &Resolution, older_than: Option<Span>) -> Result<IdentityOutcome> {
228 let resolving = Instant::now();
229 let resolved = workspaces::resolve_for(resolution, Overrides::default())?;
230 let maintenance = match crate::repository_config::resolve(resolution, &resolved) {
231 crate::repository_config::MaintenanceSource::Declared { maintenance, .. }
232 | crate::repository_config::MaintenanceSource::HostRule { maintenance, .. } => maintenance,
233 crate::repository_config::MaintenanceSource::Undeclared { .. } => {
234 return Ok(IdentityOutcome::NoMaintainCommand)
235 }
236 crate::repository_config::MaintenanceSource::Unreadable { reason } => {
238 return Ok(IdentityOutcome::MaintenanceUnreadable { reason })
239 } };
241 let resolving_seconds = resolving.elapsed().as_secs_f64();
242 let pool = pool_dir(&workspace::identity_dir(&resolution.key)?);
243 if !pool.is_dir() {
244 return Ok(IdentityOutcome::NoSlots);
245 }
246 let held = maintenance_identity(&pool);
247 let Some(_maintaining) = lock::try_exclusive(&held)? else {
248 return Ok(IdentityOutcome::Claimed {
249 by_pid: lock::stamped_owner(&held)?.unwrap_or(0),
250 });
251 };
252 let numbers: Vec<u32> = {
256 let _serial = lock::exclusive(&placement_identity(&pool))?;
257 let mut survey = survey(&resolution.key, &pool)?;
258 shed(&mut survey, resolved.file_pool, &resolution.publication);
259 survey.slots.iter().map(|slot| slot.number).collect()
260 };
261 if numbers.is_empty() {
262 return Ok(IdentityOutcome::NoSlots);
263 }
264 let mut slots = Vec::with_capacity(numbers.len());
265 for number in numbers {
266 let outcome = maintain_slot(
267 &resolution.key,
268 &pool,
269 number,
270 &maintenance,
271 older_than,
272 resolving_seconds,
273 )?;
274 slots.push(SlotMaintenance { number, outcome });
275 }
276 Ok(IdentityOutcome::Slots(slots))
277}
278
279struct Claimed {
283 dir: PathBuf,
284 record: SlotRecord,
285 _held: lock::Guard,
286}
287
288fn maintain_slot(
291 identity: &str,
292 pool: &Path,
293 number: u32,
294 maintenance: &Maintenance,
295 older_than: Option<Span>,
296 resolving_seconds: f64,
297) -> Result<SlotOutcome> {
298 let claiming = Instant::now();
299 let claimed = match claim(identity, pool, number, older_than)? {
300 Ok(claimed) => claimed,
301 Err(kept) => return Ok(kept),
302 };
303 let claiming_seconds = claiming.elapsed().as_secs_f64();
304 let worktree = claimed.dir.join("worktree");
305 let measure = || match disk_bytes(&worktree) {
306 Ok(measured) => Some(measured),
307 Err(failure) => {
308 eprintln!("onevcs: warning: slot {number} of {identity}: cannot measure worktree {}: {failure}", worktree.display());
309 None
310 }
311 };
312 let measuring = Instant::now();
313 let before = measure();
314 let before_seconds = measuring.elapsed().as_secs_f64();
315 let ran = run(&worktree, maintenance);
316 let measuring = Instant::now();
317 let after = measure();
318 let after_seconds = measuring.elapsed().as_secs_f64();
319 let recording = Instant::now();
320 let disk = before
321 .zip(after)
322 .map(|((before_bytes, _), (after_bytes, _))| SlotDisk {
323 before_bytes,
324 after_bytes,
325 });
326 let log = match stream::store_artifact("log", &ran.output) {
327 Ok(stored) => Some(ArtifactId(stored.id)),
328 Err(failure) => {
329 eprintln!(
330 "onevcs: warning: the maintenance of slot {number} of {identity} is recorded \
331 without what it wrote: {failure}"
332 );
333 None
334 }
335 };
336 let mut record = claimed.record;
337 record.maintaining = None;
338 record.last_maintained = Some(Timestamp::now());
339 record.last_outcome = Some(ran.outcome);
340 write_record(&claimed.dir, &record)?;
341 if let Some(path) = std::env::var_os("ONEVCS_POOL_MAINTAIN_TELEMETRY") {
342 let telemetry = serde_json::json!({
343 "resolving_seconds": resolving_seconds,
344 "claiming_seconds": claiming_seconds,
345 "before_seconds": before_seconds,
346 "after_seconds": after_seconds,
347 "recording_seconds": recording.elapsed().as_secs_f64(),
348 "before_entries": before.map(|(_, entries)| entries),
349 "after_entries": after.map(|(_, entries)| entries),
350 });
351 let path = PathBuf::from(path);
352 let valid = path.is_absolute()
353 && path.parent().is_some_and(Path::is_dir)
354 && match std::fs::symlink_metadata(&path) {
355 Ok(metadata) => metadata.is_file() && !metadata.is_symlink(),
356 Err(failure) => failure.kind() == std::io::ErrorKind::NotFound,
357 };
358 let recorded = if valid {
359 std::fs::write(&path, telemetry.to_string())
360 } else {
361 Err(std::io::Error::new(std::io::ErrorKind::InvalidInput,
362 "telemetry requires an absolute regular file path with an existing parent directory"))
363 };
364 if let Err(failure) = recorded {
365 eprintln!(
366 "onevcs: warning: cannot record maintenance telemetry at {}: {failure}",
367 path.display()
368 );
369 }
370 }
371 Ok(SlotOutcome::Ran {
372 outcome: ran.outcome,
373 duration_ms: u64::try_from(ran.duration.as_millis()).unwrap_or(u64::MAX),
374 log,
375 disk,
376 })
377}
378
379fn claim(
386 identity: &str,
387 pool: &Path,
388 number: u32,
389 older_than: Option<Span>,
390) -> Result<std::result::Result<Claimed, SlotOutcome>> {
391 let _serial = lock::exclusive(&placement_identity(pool))?;
392 let dir = pool.join(number.to_string());
393 let open: Vec<workspace::Record> = workspace::all()?
394 .into_iter()
395 .filter(|record| record.identity == identity && record.state == Lifecycle::Open)
396 .collect();
397 let broken = |reason: String| Ok(Err(SlotOutcome::Broken { reason }));
398 let held_by = |holder: String| Ok(Err(SlotOutcome::Unavailable { holder }));
399 let (record, state) = state_of(&dir, number, identity, &open);
400 let mut record = match (record, state) {
401 (_, SlotState::InUse { session }) => return Ok(Err(SlotOutcome::InUse { session })),
402 (_, SlotState::Broken { reason }) => return broken(reason),
403 (_, SlotState::Maintaining { pid, since }) => {
404 return held_by(format!(
405 "a maintenance run this one did not start (pid {pid}) has claimed it since {since}"
406 ))
407 }
408 (Some(record), SlotState::Idle) => record,
409 (None, SlotState::Idle) => return broken("its record could not be read".to_owned()),
414 };
415 if let (Some(span), Some(last)) = (older_than, &record.last_maintained) {
416 if !due(last, span) {
417 return Ok(Err(SlotOutcome::NotDue {
418 last_maintained: last.clone().into(),
419 }));
420 }
421 }
422 let Some(held) = lock::try_exclusive(&workspace::occupancy_identity(&dir))? else {
425 return held_by("a command is working in it right now".to_owned());
426 };
427 if !super::returned(&dir.join("worktree")) {
431 let holders = processes::holding(&dir);
432 if !holders.is_empty() {
433 let named: Vec<String> = holders.iter().map(ToString::to_string).collect();
434 return held_by(format!(
435 "a process is still working inside it: {}",
436 named.join("; ")
437 ));
438 }
439 }
440 let pid = std::process::id();
441 let Some(started) = workspace::process_started(pid) else {
442 return Err(error::invalid(format!(
447 "this host cannot identify process {pid} by its start time, so a maintenance \
448 claim it wrote could not be told from one a dead process left"
449 )));
450 };
451 record.maintaining = Some(Claim {
452 pid,
453 started,
454 since: Timestamp::now(),
455 });
456 write_record(&dir, &record)?;
457 Ok(Ok(Claimed {
458 dir,
459 record,
460 _held: held,
461 }))
462}
463
464fn due(last: &Timestamp, older_than: Span) -> bool {
472 let Some(instant) = ids::instant_of(&String::from(last.clone())) else {
473 return true;
474 };
475 let elapsed = SystemTime::now()
476 .duration_since(instant)
477 .unwrap_or(Duration::ZERO);
478 elapsed >= older_than.as_duration()
479}
480
481struct Ran {
483 outcome: MaintenanceOutcome,
484 duration: Duration,
485 output: String,
486}
487
488fn run(worktree: &Path, maintenance: &Maintenance) -> Ran {
499 let started = Instant::now();
500 let bound = maintenance.timeout.as_duration();
501 let program = &maintenance.command.program;
502 let executable = if program.chars().any(std::path::is_separator) {
503 std::path::absolute(worktree).map(|root| root.join(program))
504 } else {
505 Ok(PathBuf::from(program))
506 };
507 let spawned = executable.and_then(|executable| {
508 let mut command = Command::new(executable);
509 command
510 .args(&maintenance.command.args)
511 .current_dir(worktree)
512 .stdin(Stdio::null())
513 .stdout(Stdio::piped())
514 .stderr(Stdio::piped());
515 git::detach_process_group(&mut command);
516 command.spawn()
517 });
518 let mut child = match spawned {
519 Ok(child) => child,
520 Err(failure) => {
521 return Ran {
522 outcome: MaintenanceOutcome::Failed { exit: None },
523 duration: started.elapsed(),
524 output: format!("{program} could not be started: {failure}\n"),
525 }
526 }
527 };
528 let (ended, endings) = mpsc::channel();
529 let out_reader = git::PipeCapture::start(
530 child.stdout.take().expect("stdout was piped"),
531 ended.clone(),
532 );
533 let err_reader = git::PipeCapture::start(child.stderr.take().expect("stderr was piped"), ended);
534 let exited = exited_within(&mut child, started, bound, &endings);
535 let Some(status) = exited else {
536 git::terminate_group(&child);
537 let _ = child.kill();
538 let _ = child.wait();
539 let duration = started.elapsed();
540 let mut output = combined(out_reader.finish().bytes, err_reader.finish().bytes);
541 output.push_str(&format!(
542 "\n[onevcs: timed out after {:.3}s, bound {}]\n",
543 duration.as_secs_f64(),
544 maintenance.timeout
545 ));
546 return Ran {
547 outcome: MaintenanceOutcome::TimedOut,
548 duration,
549 output,
550 };
551 };
552 let duration = started.elapsed();
553 let output = combined(out_reader.finish().bytes, err_reader.finish().bytes);
554 let outcome = match status {
555 Ok(status) if status.success() => MaintenanceOutcome::Succeeded,
556 Ok(status) => MaintenanceOutcome::Failed {
557 exit: status.code(),
558 },
559 Err(_) => MaintenanceOutcome::Failed { exit: None },
563 };
564 Ran {
565 outcome,
566 duration,
567 output,
568 }
569}
570
571fn exited_within(
574 child: &mut Child,
575 started: Instant,
576 bound: Duration,
577 endings: &mpsc::Receiver<()>,
578) -> Option<std::io::Result<std::process::ExitStatus>> {
579 loop {
580 match child.try_wait() {
581 Ok(Some(status)) => return Some(Ok(status)),
582 Err(failure) => return Some(Err(failure)),
583 Ok(None) if started.elapsed() >= bound => return None,
584 Ok(None) => git::await_an_ending(endings, POLL),
585 }
586 }
587}
588
589fn combined(stdout: Vec<u8>, stderr: Vec<u8>) -> String {
593 let mut output = String::from_utf8_lossy(&stdout).into_owned();
594 output.push_str(&String::from_utf8_lossy(&stderr));
595 output
596}
597
598#[cfg(unix)]
600fn disk_bytes(root: &Path) -> std::io::Result<(u64, u64)> {
601 use std::os::unix::fs::MetadataExt;
602 let mut seen = std::collections::HashSet::new();
603 let mut pending = vec![root.to_path_buf()];
604 let mut bytes = 0u64;
605 let mut entries = 0u64;
606 while let Some(path) = pending.pop() {
607 let metadata = std::fs::symlink_metadata(&path)?;
608 entries += 1;
609 if !seen.insert((metadata.dev(), metadata.ino())) {
610 continue;
611 }
612 bytes = bytes.saturating_add(metadata.blocks().saturating_mul(512));
613 if metadata.is_dir() {
614 for entry in std::fs::read_dir(&path)? {
615 pending.push(entry?.path());
616 }
617 }
618 }
619 Ok((bytes, entries))
620}
621
622#[cfg(not(unix))]
623fn disk_bytes(_root: &Path) -> std::io::Result<(u64, u64)> {
624 Err(std::io::Error::new(
625 std::io::ErrorKind::Unsupported,
626 "allocated block measurement is unavailable on this platform",
627 ))
628}