use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant, SystemTime};
use serde::{Deserialize, Serialize};
use super::{
placement_identity, pool_dir, shed, state_of, survey, write_record, Claim, MaintenanceOutcome,
SlotRecord, SlotState, Timestamp,
};
use crate::error::{self, Result};
use crate::event::ArtifactId;
use crate::session::{Lifecycle, Scope, SessionToken};
use crate::store::{self, Resolution};
use crate::workspaces::{self, Maintenance, Overrides, Span};
use crate::{git, ids, lock, processes, stream, workspace};
const POLL: Duration = Duration::from_millis(10);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MaintainReport {
pub identities: Vec<IdentityMaintenance>,
}
impl MaintainReport {
pub(crate) fn every_command_succeeded(&self) -> bool {
self.identities
.iter()
.all(|identity| match &identity.outcome {
IdentityOutcome::Slots(slots) => slots.iter().all(|slot| {
!matches!(
slot.outcome,
SlotOutcome::Ran {
outcome: MaintenanceOutcome::Failed { .. }
| MaintenanceOutcome::TimedOut,
..
}
)
}),
_ => true,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdentityMaintenance {
pub identity: String,
pub outcome: IdentityOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IdentityOutcome {
NoMaintainCommand,
NoSlots,
Claimed {
by_pid: u32,
},
Slots(Vec<SlotMaintenance>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SlotMaintenance {
pub number: u32,
pub outcome: SlotOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SlotOutcome {
NotDue {
last_maintained: String,
},
InUse {
session: SessionToken,
},
Unavailable {
holder: String,
},
Broken {
reason: String,
},
Ran {
outcome: MaintenanceOutcome,
duration_ms: u64,
log: Option<ArtifactId>,
},
}
fn maintenance_identity(pool: &Path) -> String {
format!("maintain:{}", pool.display())
}
pub fn pool_maintain(scope: Scope, older_than: Option<Span>) -> Result<MaintainReport> {
let registry = store::load()?;
let resolutions: Vec<Resolution> = match &scope {
Scope::Repo(repo) => vec![store::resolve(®istry, repo)?],
Scope::All => {
let mut identities: Vec<&str> = registry
.checkouts
.values()
.map(|checkout| checkout.identity.as_str())
.collect();
identities.sort_unstable();
identities.dedup();
identities
.into_iter()
.map(|identity| store::resolve(®istry, identity))
.collect::<Result<Vec<_>>>()?
}
};
let mut report = MaintainReport {
identities: Vec::new(),
};
for resolution in resolutions {
let outcome = maintain_identity(&resolution, older_than)?;
report.identities.push(IdentityMaintenance {
identity: resolution.key,
outcome,
});
}
Ok(report)
}
fn maintain_identity(resolution: &Resolution, older_than: Option<Span>) -> Result<IdentityOutcome> {
let resolved = workspaces::resolve_for(resolution, Overrides::default())?;
let Some(maintenance) = resolved.maintain else {
return Ok(IdentityOutcome::NoMaintainCommand);
};
let pool = pool_dir(&workspace::identity_dir(&resolution.key)?);
if !pool.is_dir() {
return Ok(IdentityOutcome::NoSlots);
}
let held = maintenance_identity(&pool);
let Some(_maintaining) = lock::try_exclusive(&held)? else {
return Ok(IdentityOutcome::Claimed {
by_pid: lock::stamped_owner(&held)?.unwrap_or(0),
});
};
let numbers: Vec<u32> = {
let _serial = lock::exclusive(&placement_identity(&pool))?;
let mut survey = survey(&resolution.key, &pool)?;
shed(&mut survey, resolved.file_pool, &resolution.publication);
survey.slots.iter().map(|slot| slot.number).collect()
};
if numbers.is_empty() {
return Ok(IdentityOutcome::NoSlots);
}
let mut slots = Vec::with_capacity(numbers.len());
for number in numbers {
let outcome = maintain_slot(&resolution.key, &pool, number, &maintenance, older_than)?;
slots.push(SlotMaintenance { number, outcome });
}
Ok(IdentityOutcome::Slots(slots))
}
struct Claimed {
dir: PathBuf,
record: SlotRecord,
_held: lock::Guard,
}
fn maintain_slot(
identity: &str,
pool: &Path,
number: u32,
maintenance: &Maintenance,
older_than: Option<Span>,
) -> Result<SlotOutcome> {
let claimed = match claim(identity, pool, number, older_than)? {
Ok(claimed) => claimed,
Err(kept) => return Ok(kept),
};
let ran = run(&claimed.dir.join("worktree"), maintenance);
let log = match stream::store_artifact("log", &ran.output) {
Ok(stored) => Some(ArtifactId(stored.id)),
Err(failure) => {
eprintln!(
"onevcs: warning: the maintenance of slot {number} of {identity} is recorded \
without what it wrote: {failure}"
);
None
}
};
let mut record = claimed.record;
record.maintaining = None;
record.last_maintained = Some(Timestamp::now());
record.last_outcome = Some(ran.outcome);
write_record(&claimed.dir, &record)?;
Ok(SlotOutcome::Ran {
outcome: ran.outcome,
duration_ms: u64::try_from(ran.duration.as_millis()).unwrap_or(u64::MAX),
log,
})
}
fn claim(
identity: &str,
pool: &Path,
number: u32,
older_than: Option<Span>,
) -> Result<std::result::Result<Claimed, SlotOutcome>> {
let _serial = lock::exclusive(&placement_identity(pool))?;
let dir = pool.join(number.to_string());
let open: Vec<workspace::Record> = workspace::all()?
.into_iter()
.filter(|record| record.identity == identity && record.state == Lifecycle::Open)
.collect();
let broken = |reason: String| Ok(Err(SlotOutcome::Broken { reason }));
let held_by = |holder: String| Ok(Err(SlotOutcome::Unavailable { holder }));
let (record, state) = state_of(&dir, number, identity, &open);
let mut record = match (record, state) {
(_, SlotState::InUse { session }) => return Ok(Err(SlotOutcome::InUse { session })),
(_, SlotState::Broken { reason }) => return broken(reason),
(_, SlotState::Maintaining { pid, since }) => {
return held_by(format!(
"a maintenance run this one did not start (pid {pid}) has claimed it since {since}"
))
}
(Some(record), SlotState::Idle) => record,
(None, SlotState::Idle) => return broken("its record could not be read".to_owned()),
};
if let (Some(span), Some(last)) = (older_than, &record.last_maintained) {
if !due(last, span) {
return Ok(Err(SlotOutcome::NotDue {
last_maintained: last.clone().into(),
}));
}
}
let Some(held) = lock::try_exclusive(&workspace::occupancy_identity(&dir))? else {
return held_by("a command is working in it right now".to_owned());
};
if !super::returned(&dir.join("worktree")) {
let holders = processes::holding(&dir);
if !holders.is_empty() {
let named: Vec<String> = holders.iter().map(ToString::to_string).collect();
return held_by(format!(
"a process is still working inside it: {}",
named.join("; ")
));
}
}
let pid = std::process::id();
let Some(started) = workspace::process_started(pid) else {
return Err(error::invalid(format!(
"this host cannot identify process {pid} by its start time, so a maintenance \
claim it wrote could not be told from one a dead process left"
)));
};
record.maintaining = Some(Claim {
pid,
started,
since: Timestamp::now(),
});
write_record(&dir, &record)?;
Ok(Ok(Claimed {
dir,
record,
_held: held,
}))
}
fn due(last: &Timestamp, older_than: Span) -> bool {
let Some(instant) = ids::instant_of(&String::from(last.clone())) else {
return true;
};
let elapsed = SystemTime::now()
.duration_since(instant)
.unwrap_or(Duration::ZERO);
elapsed >= older_than.as_duration()
}
struct Ran {
outcome: MaintenanceOutcome,
duration: Duration,
output: String,
}
fn run(worktree: &Path, maintenance: &Maintenance) -> Ran {
let started = Instant::now();
let bound = maintenance.timeout.as_duration();
let program = &maintenance.command.program;
let mut command = Command::new(program);
command
.args(&maintenance.command.args)
.current_dir(worktree)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
git::detach_process_group(&mut command);
let mut child = match command.spawn() {
Ok(child) => child,
Err(failure) => {
return Ran {
outcome: MaintenanceOutcome::Failed { exit: None },
duration: started.elapsed(),
output: format!("{program} could not be started: {failure}\n"),
}
}
};
let (ended, endings) = mpsc::channel();
let out_reader = git::PipeCapture::start(
child.stdout.take().expect("stdout was piped"),
ended.clone(),
);
let err_reader = git::PipeCapture::start(child.stderr.take().expect("stderr was piped"), ended);
let exited = exited_within(&mut child, started, bound, &endings);
let Some(status) = exited else {
git::terminate_group(&child);
let _ = child.kill();
let _ = child.wait();
let duration = started.elapsed();
let mut output = combined(out_reader.finish().bytes, err_reader.finish().bytes);
output.push_str(&format!(
"\n[onevcs: timed out after {:.3}s, bound {}]\n",
duration.as_secs_f64(),
maintenance.timeout
));
return Ran {
outcome: MaintenanceOutcome::TimedOut,
duration,
output,
};
};
let duration = started.elapsed();
let output = combined(out_reader.finish().bytes, err_reader.finish().bytes);
let outcome = match status {
Ok(status) if status.success() => MaintenanceOutcome::Succeeded,
Ok(status) => MaintenanceOutcome::Failed {
exit: status.code(),
},
Err(_) => MaintenanceOutcome::Failed { exit: None },
};
Ran {
outcome,
duration,
output,
}
}
fn exited_within(
child: &mut Child,
started: Instant,
bound: Duration,
endings: &mpsc::Receiver<()>,
) -> Option<std::io::Result<std::process::ExitStatus>> {
loop {
match child.try_wait() {
Ok(Some(status)) => return Some(Ok(status)),
Err(failure) => return Some(Err(failure)),
Ok(None) if started.elapsed() >= bound => return None,
Ok(None) => git::await_an_ending(endings, POLL),
}
}
}
fn combined(stdout: Vec<u8>, stderr: Vec<u8>) -> String {
let mut output = String::from_utf8_lossy(&stdout).into_owned();
output.push_str(&String::from_utf8_lossy(&stderr));
output
}