1use std::io::BufRead;
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25use serde::{Deserialize, Serialize};
26
27use crate::{hook, learn, live};
28
29pub const MODE: &str = "async";
31
32pub const WHY_SUPERSEDED: &str = "superseded";
36pub const WHY_GONE: &str = "gone";
37pub const WHY_UNSTARTED: &str = "unstarted";
38pub const WHY_UNPLACED: &str = "unplaced";
39pub const WHYS: [&str; 4] = [WHY_SUPERSEDED, WHY_GONE, WHY_UNSTARTED, WHY_UNPLACED];
40
41pub const JOB_VERSION: u32 = 1;
43
44#[must_use]
47pub fn on() -> bool {
48 !std::env::var("BORG_ASYNC").is_ok_and(|v| v.trim().eq_ignore_ascii_case("off"))
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
54pub struct Job {
55 pub v: u32,
56 pub session: String,
57 pub check: String,
59 pub stop_ms: i64,
60 pub harness: String,
61 pub cwd: String,
62 pub offset: usize,
65 pub message: String,
67 pub proposal: String,
70 pub changed: bool,
72 pub repo: String,
73 #[serde(default)]
75 pub scope: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub edits: Option<usize>,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
85pub struct Checked {
86 pub check: String,
87 pub stop_ms: i64,
88 pub done_ms: i64,
89 #[serde(default)]
91 pub asks: Vec<String>,
92 #[serde(default)]
93 pub free: Vec<String>,
94 #[serde(default)]
96 pub failed: bool,
97 #[serde(default)]
99 pub dur_ms: u64,
100 #[serde(default)]
102 pub superseded: Vec<String>,
103}
104
105impl Checked {
106 pub(crate) fn of(job: &Job) -> Checked {
108 Checked { check: job.check.clone(), stop_ms: job.stop_ms, ..Default::default() }
109 }
110}
111
112fn file(session: &str, ext: &str) -> PathBuf {
114 hook::state_file(session).with_extension(ext)
115}
116
117fn next_file(session: &str) -> PathBuf {
119 file(session, "job.next")
120}
121
122pub(crate) fn write_job(job: &Job) -> std::io::Result<PathBuf> {
124 let path = file(&job.session, &format!("job.{}", job.check));
125 let tmp = file(&job.session, &format!("job.{}.tmp", job.check));
126 let text = serde_json::to_string(job).map_err(std::io::Error::other)?;
127 std::fs::write(&tmp, text).and_then(|()| std::fs::rename(&tmp, &path)).inspect_err(|_| {
128 let _ = std::fs::remove_file(&tmp);
129 })?;
130 Ok(path)
131}
132
133pub(crate) fn spawn(job: &Path) -> bool {
138 use std::os::unix::process::CommandExt;
139 let mut c = std::process::Command::new(crate::reexec::exe_path());
140 c.arg("check").arg("--job").arg(job).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
141 unsafe {
143 c.pre_exec(|| {
144 libc::setsid();
145 Ok(())
146 });
147 }
148 if c.spawn().is_err() {
149 let _ = std::fs::remove_file(job);
150 return false;
151 }
152 true
153}
154
155pub fn main(job: &str) -> i32 {
157 run(Path::new(job));
158 0
159}
160
161struct Claimed {
163 path: PathBuf,
164 job: Job,
165}
166
167impl Drop for Claimed {
168 fn drop(&mut self) {
169 let _ = std::fs::remove_file(&self.path);
170 }
171}
172
173fn read_job(path: &Path) -> Option<Job> {
174 serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
175}
176
177fn claim(path: &Path) -> Option<Claimed> {
180 let job = read_job(path)?;
181 let to = file(&job.session, &format!("job.{}.{}", job.check, std::process::id()));
182 std::fs::rename(path, &to).ok()?;
183 Some(Claimed { path: to, job })
184}
185
186enum Next {
188 Job(Box<Claimed>),
190 Empty,
192 Stuck,
195}
196
197fn claim_next(session: &str) -> Next {
201 let next = next_file(session);
202 live::locked(&file(session, "checking.next"), || {
203 if !next.exists() {
204 return Next::Empty;
205 }
206 if let Some(c) = claim(&next) {
207 return Next::Job(Box::new(c));
208 }
209 let job = read_job(&next).unwrap_or_else(|| Job { session: session.to_string(), ..Job::default() });
210 match std::fs::remove_file(&next) {
211 Ok(()) => {
212 not_run(&job, WHY_UNPLACED);
213 Next::Empty
214 }
215 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Next::Empty,
216 Err(_) => Next::Stuck,
217 }
218 })
219}
220
221fn wait_next(c: Claimed) {
224 let session = c.job.session.clone();
225 let next = next_file(&session);
226 live::locked(&file(&session, "checking.next"), || match read_job(&next) {
227 Some(waiting) if waiting.stop_ms > c.job.stop_ms => not_run(&c.job, WHY_SUPERSEDED),
228 waiting => match std::fs::rename(&c.path, &next) {
229 Ok(()) => {
230 if let Some(w) = waiting {
231 not_run(&w, WHY_SUPERSEDED);
232 }
233 }
234 Err(_) => not_run(&c.job, WHY_UNPLACED),
236 },
237 });
238}
239
240enum Hold {
242 Mine(std::fs::File),
244 Busy,
246 Broken,
250}
251
252fn hold(session: &str) -> Hold {
253 let Ok(f) = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(file(session, "checking.lock")) else { return Hold::Broken };
254 match f.try_lock() {
255 Ok(()) => Hold::Mine(f),
256 Err(std::fs::TryLockError::WouldBlock) => Hold::Busy,
257 Err(std::fs::TryLockError::Error(_)) => Hold::Broken,
258 }
259}
260
261fn run(path: &Path) {
268 let Some(mine) = claim(path) else { return };
269 let session = mine.job.session.clone();
270 let mut mine = Some(mine);
271 let mut last = i64::MIN;
272 loop {
273 let lock = match hold(&session) {
274 Hold::Mine(f) => Some(f),
275 Hold::Broken => None,
276 Hold::Busy => match mine.take() {
277 Some(c) => {
278 wait_next(c);
279 continue;
280 }
281 None => return,
282 },
283 };
284 let mut job = match mine.take() {
285 Some(c) => Next::Job(Box::new(c)),
286 None => claim_next(&session),
287 };
288 while let Next::Job(c) = job {
289 if c.job.stop_ms < last {
290 not_run(&c.job, WHY_SUPERSEDED);
291 } else {
292 last = c.job.stop_ms;
293 hook::run_job(&c.job);
294 }
295 job = claim_next(&session);
299 drop(c);
300 }
301 drop(lock);
302 if matches!(job, Next::Stuck) || !next_file(&session).exists() {
303 return;
304 }
305 }
306}
307
308pub(crate) fn not_run(job: &Job, why: &str) {
311 crate::stats::count(&learn::home(), crate::stats::CHECK_DROPPED, &job.session, |e| {
312 e.why = why.into();
313 e.mode = MODE.into();
314 e.repo.clone_from(&job.repo);
315 });
316}
317
318#[must_use]
321pub fn index(session: &str) -> u64 {
322 std::fs::read_to_string(file(session, "checks")).ok().and_then(|t| t.trim().parse().ok()).unwrap_or(0)
323}
324
325pub(crate) fn finish(session: &str, out: &mut Checked, called: bool) {
328 if called {
329 let _ = std::fs::write(file(session, "checks"), format!("{}\n", index(session) + 1));
330 }
331 out.done_ms = learn::now_ms();
332 let (path, tmp) = (file(session, "checked"), file(session, &format!("checked.{}", std::process::id())));
333 let Ok(text) = serde_json::to_string(out) else { return };
334 if std::fs::write(&tmp, text).and_then(|()| std::fs::rename(&tmp, &path)).is_err() {
335 let _ = std::fs::remove_file(&tmp);
336 }
337}
338
339#[must_use]
345pub fn call_limit(model: &str) -> Duration {
346 call_limit_in(&learn::home(), model)
347}
348
349#[must_use]
351pub fn call_limit_in(home: &Path, model: &str) -> Duration {
352 let floor = crate::predict::timeout();
353 if std::env::var_os("BORG_PREDICT_TIMEOUT_S").is_some() {
354 return floor;
355 }
356 let p95 = p95(durations(&home.join("outcomes.jsonl"), model));
357 floor.max(Duration::from_millis(p95.saturating_mul(2)))
358}
359
360fn durations(log: &Path, model: &str) -> Vec<u64> {
363 let Ok(f) = std::fs::File::open(log) else { return Vec::new() };
364 let mut kept = std::collections::VecDeque::with_capacity(crate::listen::GAPS_KEPT);
365 for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
366 if !line.contains(crate::outcome::GRADED) {
368 continue;
369 }
370 let Ok(o) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
371 if o["kind"] != crate::outcome::GRADED || o["failed"] == true || o["model"].as_str().unwrap_or_default() != model {
372 continue;
373 }
374 let Some(ms) = o["dur_ms"].as_u64().filter(|ms| *ms > 0) else { continue };
375 if kept.len() == crate::listen::GAPS_KEPT {
376 kept.pop_front();
377 }
378 kept.push_back(ms);
379 }
380 kept.into()
381}
382
383fn p95(mut v: Vec<u64>) -> u64 {
385 v.sort_unstable();
386 let rank = (v.len() * 95).div_ceil(100);
387 v.get(rank.saturating_sub(1)).copied().unwrap_or(0)
388}
389
390#[must_use]
393pub fn rss_kb() -> u64 {
394 let mut u: libc::rusage = unsafe { std::mem::zeroed() };
396 if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut u) } != 0 {
397 return 0;
398 }
399 kib(u64::try_from(u.ru_maxrss).unwrap_or(0))
400}
401
402const fn kib(maxrss: u64) -> u64 {
404 if cfg!(target_os = "macos") { maxrss / 1024 } else { maxrss }
405}
406
407#[must_use]
411pub fn running(session: &str) -> bool {
412 let ctx = hook::state_file(session);
413 let (Some(dir), Some(stem)) = (ctx.parent(), ctx.file_stem().and_then(|s| s.to_str())) else { return false };
414 let jobs = format!("{stem}.job.");
415 let Ok(rd) = std::fs::read_dir(dir) else { return false };
416 rd.flatten().any(|e| e.file_name().to_str().and_then(|n| claimed_pid(n, &jobs)).is_some_and(live::alive))
417}
418
419fn claimed_pid(name: &str, jobs: &str) -> Option<i32> {
422 name.strip_prefix(jobs)?.split_once('.')?.1.parse().ok()
423}
424
425pub fn sweep(session: &str) {
432 let ctx = hook::state_file(session);
433 let (Some(dir), Some(stem)) = (ctx.parent(), ctx.file_stem().and_then(|s| s.to_str())) else { return };
434 let (jobs, results) = (format!("{stem}.job."), format!("{stem}.checked."));
435 let Ok(rd) = std::fs::read_dir(dir) else { return };
436 let mine: Vec<std::fs::DirEntry> =
437 rd.flatten().filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with(&jobs) || n.starts_with(&results))).collect();
438 let pid_of = |name: &str| -> Option<i32> { claimed_pid(name, &jobs).or_else(|| name.strip_prefix(&results)?.parse().ok()) };
440 let names: Vec<String> = mine.iter().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
441 let running = names.iter().filter(|n| n.starts_with(&jobs)).filter_map(|n| pid_of(n)).any(live::alive);
442 let least = ms(crate::predict::timeout()).saturating_add(live::STALE_SLACK_MS);
444 let mut limit: Option<i64> = None;
445 let now = learn::now_ms();
446 for (e, name) in mine.iter().zip(&names) {
447 let gone = match pid_of(name) {
448 Some(pid) => !live::alive(pid),
449 None if name.starts_with(&results) => false,
450 None => {
451 let age = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| now - ms(d));
452 let old = age > least
453 && age
454 > *limit.get_or_insert_with(|| {
455 ms(call_limit(&std::env::var("BORG_PREDICT_MODEL").unwrap_or_default())).saturating_add(live::STALE_SLACK_MS)
456 });
457 old && !(running && name.ends_with(".job.next"))
458 }
459 };
460 if gone {
461 let _ = std::fs::remove_file(e.path());
462 }
463 }
464}
465
466fn ms(d: Duration) -> i64 {
467 i64::try_from(d.as_millis()).unwrap_or(i64::MAX)
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn the_95th_percentile_is_the_nearest_rank() {
476 assert_eq!(p95(vec![]), 0);
477 assert_eq!(p95(vec![7]), 7);
478 assert_eq!(p95((1..=100).rev().collect()), 95);
479 assert_eq!(p95(vec![1, 2, 3, 40]), 40, "4 durations: the 4th");
480 }
481
482 #[test]
483 fn the_limit_reads_the_latest_answered_checks_of_the_same_model() {
484 let d = std::env::temp_dir().join(format!("borg-check-async-limit-{}", std::process::id()));
485 let _ = std::fs::remove_dir_all(&d);
486 std::fs::create_dir_all(&d).unwrap();
487 let log = d.join("outcomes.jsonl");
488 let mut text = String::new();
489 for i in 0..100u64 {
490 text.push_str(&format!("{{\"kind\":\"graded\",\"session\":\"s\",\"ms\":{i},\"model\":\"m\",\"dur_ms\":{}}}\n", 1000 + i));
491 }
492 text.push_str("{\"kind\":\"graded\",\"session\":\"s\",\"ms\":200,\"model\":\"m\",\"dur_ms\":900000,\"failed\":true}\n");
493 text.push_str("{\"kind\":\"graded\",\"session\":\"s\",\"ms\":201,\"model\":\"other\",\"dur_ms\":800000}\n");
494 text.push_str("{\"kind\":\"asked\",\"session\":\"s\",\"ms\":202,\"model\":\"m\",\"dur_ms\":700000}\n");
495 std::fs::write(&log, text).unwrap();
496 let got = durations(&log, "m");
497 assert_eq!(got.len(), crate::listen::GAPS_KEPT, "the latest only");
498 assert_eq!((got[0], *got.last().unwrap()), (1036, 1099), "failed calls, other models and other kinds left out");
499 let _ = std::fs::remove_dir_all(d);
500 }
501}