1use std::io::Write;
15use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
16use std::os::unix::io::AsRawFd;
17use std::path::{Path, PathBuf};
18use std::time::{Duration, Instant};
19
20use serde::{Deserialize, Serialize};
21
22const BATCH: usize = 64;
24const BATCH_DEADLINE: Duration = Duration::from_secs(120);
26const TRIES: u32 = 20;
29const STALE: Duration = Duration::from_secs(600);
30const CATCH_UP: Duration = Duration::from_secs(5);
33const PER_CALL: &[&str] = &["LJOS_HARNESS_SESSION"];
36
37#[derive(Serialize, Deserialize)]
38struct Job {
39 raw: String,
40 event: String,
41 cue: String,
42 session: String,
43 #[serde(default)]
44 payload_session: Option<String>,
45 #[serde(default)]
46 ask: Option<String>,
47 owner: (u32, u64),
48 ancestry: Vec<(u32, String)>,
49 head: Option<String>,
50 cwd: String,
51 env: Vec<(String, String)>,
52}
53
54fn spool() -> PathBuf {
55 crate::runtime_dir().join("bookq")
56}
57
58fn drain_lock_path() -> PathBuf {
59 crate::runtime_dir().join("bookq.lock")
60}
61
62#[must_use]
65pub fn book_later(raw: &str, call: &crate::HookCall) -> String {
66 enqueue(raw, call);
67 crate::bookkeep::recall_for(raw, call)
68}
69
70pub fn enqueue_ask(raw: &str, call: &crate::HookCall, reason: &str) {
72 write_job(raw, call, Some(reason));
73}
74
75pub fn enqueue(raw: &str, call: &crate::HookCall) {
77 if crate::bookkeep::books(&call.event) {
78 write_job(raw, call, None);
79 }
80}
81
82fn write_job(raw: &str, call: &crate::HookCall, ask: Option<&str>) {
83 let cwd = std::env::current_dir()
84 .map(|p| p.display().to_string())
85 .unwrap_or_default();
86 let value: serde_json::Value = serde_json::from_str(raw.trim()).unwrap_or_default();
87 let place = value["cwd"]
88 .as_str()
89 .or_else(|| value["workspacePaths"][0].as_str())
90 .filter(|s| !s.is_empty())
91 .map_or_else(|| PathBuf::from(&cwd), PathBuf::from);
92 let job = Job {
93 raw: raw.to_string(),
94 event: call.event.clone(),
95 cue: call.cue.clone(),
96 session: crate::bookkeep::session_key(call),
97 payload_session: call.session.clone(),
98 ask: ask.map(str::to_string),
99 owner: crate::bookkeep::hook_owner(),
100 ancestry: crate::ancestry(),
101 head: head_now(&place),
102 cwd,
103 env: std::env::vars_os()
104 .filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?)))
105 .collect(),
106 };
107 let dir = spool();
108 if mkdir_private(&dir).is_err() {
109 crate::bookkeep::log_line("book queue: cannot make the spool; nothing booked");
110 return;
111 }
112 let Ok(body) = serde_json::to_vec(&job) else {
113 return;
114 };
115 let stamp = std::time::SystemTime::now()
116 .duration_since(std::time::UNIX_EPOCH)
117 .map_or(0, |d| d.as_nanos());
118 let name = format!("{stamp:024}-{:010}.job", std::process::id());
119 let tmp = dir.join(format!(".{name}.tmp"));
120 let written = std::fs::OpenOptions::new()
121 .create_new(true)
122 .write(true)
123 .mode(0o600)
124 .open(&tmp)
125 .and_then(|mut f| f.write_all(&body));
126 if written.is_err() || std::fs::rename(&tmp, dir.join(&name)).is_err() {
127 let _ = std::fs::remove_file(&tmp);
128 crate::bookkeep::log_line("book queue: a job could not be written; nothing booked");
129 return;
130 }
131 kick();
132}
133
134fn mkdir_private(dir: &Path) -> std::io::Result<()> {
135 use std::os::unix::fs::DirBuilderExt as _;
136 std::fs::DirBuilder::new()
137 .recursive(true)
138 .mode(0o700)
139 .create(dir)
140}
141
142fn head_now(dir: &Path) -> Option<String> {
145 let mut at = dir;
146 let dot = loop {
147 let dot = at.join(".git");
148 if dot.exists() {
149 break dot;
150 }
151 at = at.parent()?;
152 };
153 let git_dir = if dot.is_file() {
154 let text = std::fs::read_to_string(&dot).ok()?;
155 let named = PathBuf::from(text.trim().strip_prefix("gitdir:")?.trim());
156 if named.is_absolute() {
157 named
158 } else {
159 at.join(named)
160 }
161 } else {
162 dot
163 };
164 let common = std::fs::read_to_string(git_dir.join("commondir"))
165 .ok()
166 .map(|c| git_dir.join(c.trim()))
167 .unwrap_or_else(|| git_dir.clone());
168 let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
169 let head = head.trim();
170 let sha = match head.strip_prefix("ref:") {
171 None => head.to_string(),
172 Some(name) => {
173 let name = name.trim();
174 match std::fs::read_to_string(common.join(name)) {
175 Ok(sha) => sha.trim().to_string(),
176 Err(_) => std::fs::read_to_string(common.join("packed-refs"))
177 .ok()?
178 .lines()
179 .find_map(|line| {
180 let (sha, refname) = line.split_once(' ')?;
181 (refname.trim() == name).then(|| sha.to_string())
182 })?,
183 }
184 }
185 };
186 (sha.len() >= 40 && sha.chars().all(|c| c.is_ascii_hexdigit())).then_some(sha)
187}
188
189fn open_lock(path: &Path) -> Option<std::fs::File> {
190 std::fs::OpenOptions::new()
191 .create(true)
192 .append(true)
193 .mode(0o600)
194 .open(path)
195 .ok()
196}
197
198fn flock(file: &std::fs::File, wait: bool) -> bool {
199 let how = if wait {
200 libc::LOCK_EX
201 } else {
202 libc::LOCK_EX | libc::LOCK_NB
203 };
204 unsafe { libc::flock(file.as_raw_fd(), how) == 0 }
206}
207
208fn kick() {
212 use std::os::unix::process::CommandExt as _;
213 if cfg!(test) {
215 return;
216 }
217 let Some(lock) = open_lock(&drain_lock_path()) else {
218 return;
219 };
220 if !flock(&lock, false) {
221 return;
222 }
223 drop(lock);
224 let Some(exe) = ljos_exe() else {
225 return;
226 };
227 let mut cmd = std::process::Command::new(exe);
228 cmd.args(["hook", "--drain", "--no-wait"])
229 .stdin(std::process::Stdio::null())
230 .stdout(std::process::Stdio::null())
231 .stderr(std::process::Stdio::null());
232 unsafe {
234 cmd.pre_exec(|| {
235 libc::setsid();
236 Ok(())
237 });
238 }
239 let _ = cmd.spawn();
240}
241
242fn ljos_exe() -> Option<PathBuf> {
245 let me = std::env::current_exe().ok()?;
246 if me.file_name().is_some_and(|name| name == "ljos") {
247 return Some(me);
248 }
249 let beside = me.with_file_name("ljos");
250 if beside.is_file() {
251 return Some(beside);
252 }
253 which::which("ljos").ok()
254}
255
256fn binary_stamp() -> Option<(u64, u64, i64)> {
260 let exe = std::env::current_exe().ok()?;
261 let meta = std::fs::metadata(exe).ok()?;
262 Some((meta.ino(), meta.len(), meta.mtime()))
263}
264
265pub fn catch_up() {
272 if std::env::var_os("LJOS_IN_HOOK").is_some() {
273 return;
274 }
275 let ahead = pending();
276 if ahead.is_empty() {
277 return;
278 }
279 kick();
280 let wait = crate::wall_left().map_or(CATCH_UP, |left| left.min(CATCH_UP));
281 let until = Instant::now() + wait;
282 while ahead.iter().any(|job| job.exists()) && Instant::now() < until {
283 std::thread::sleep(Duration::from_millis(20));
284 }
285}
286
287#[must_use]
290pub fn has_pending() -> bool {
291 std::fs::read_dir(spool()).is_ok_and(|mut entries| {
292 entries.any(|e| e.is_ok_and(|e| e.path().extension().is_some_and(|x| x == "job")))
293 })
294}
295
296fn pending() -> Vec<PathBuf> {
297 let Ok(entries) = std::fs::read_dir(spool()) else {
298 return Vec::new();
299 };
300 let mut jobs: Vec<PathBuf> = entries
301 .filter_map(Result::ok)
302 .map(|e| e.path())
303 .filter(|p| p.extension().is_some_and(|x| x == "job"))
304 .collect();
305 jobs.sort();
306 jobs
307}
308
309fn read_job(path: &Path) -> Option<Job> {
310 serde_json::from_slice(&std::fs::read(path).ok()?).ok()
311}
312
313#[must_use]
317pub fn drain(wait: bool) -> i32 {
318 if mkdir_private(&spool()).is_err() {
319 return 1;
320 }
321 loop {
322 let Some(lock) = open_lock(&drain_lock_path()) else {
323 return 1;
324 };
325 if !flock(&lock, wait) {
326 return 0;
327 }
328 let drained = drain_held();
329 drop(lock);
330 if !drained {
331 return 1;
332 }
333 if pending().is_empty() {
334 return 0;
335 }
336 }
337}
338
339fn drain_held() -> bool {
344 let stamp = binary_stamp();
345 let mut stuck: Option<(PathBuf, u32)> = None;
346 loop {
347 if binary_stamp() != stamp {
348 crate::bookkeep::log_line(
349 "book queue: the ljos binary changed; this drainer stops and the next job starts one",
350 );
351 return false;
352 }
353 let jobs = pending();
354 let Some(first) = jobs.first().cloned() else {
355 return true;
356 };
357 let Some(head) = read_job(&first) else {
358 crate::bookkeep::log_line(&format!(
359 "book queue: dropped unreadable job {}",
360 first.display()
361 ));
362 let _ = std::fs::remove_file(&first);
363 continue;
364 };
365 let mut batch = vec![first.clone()];
366 for path in jobs.iter().skip(1).take(BATCH - 1) {
367 match read_job(path) {
368 Some(job) if job.cwd == head.cwd && same_env(&job, &head) => {
369 batch.push(path.clone());
370 }
371 _ => break,
372 }
373 }
374 if !run_batch(&head, &batch) {
375 return false;
376 }
377 if !first.exists() {
378 stuck = None;
379 continue;
380 }
381 let tries = match &stuck {
382 Some((path, n)) if *path == first => n + 1,
383 _ => 1,
384 };
385 let old = std::fs::metadata(&first)
386 .and_then(|m| m.modified())
387 .ok()
388 .and_then(|t| t.elapsed().ok())
389 .is_some_and(|age| age > STALE);
390 if tries >= TRIES || old {
391 crate::bookkeep::log_line(&format!(
392 "book queue: dropped {} {} after {tries} tries",
393 head.event,
394 first.display()
395 ));
396 let _ = std::fs::remove_file(&first);
397 stuck = None;
398 continue;
399 }
400 stuck = Some((first, tries));
401 std::thread::sleep(Duration::from_millis(250));
403 }
404}
405
406fn same_env(a: &Job, b: &Job) -> bool {
407 let kept = |job: &Job| {
408 job.env
409 .iter()
410 .filter(|(k, _)| !PER_CALL.contains(&k.as_str()))
411 .cloned()
412 .collect::<Vec<_>>()
413 };
414 kept(a) == kept(b)
415}
416
417fn wear_call(job: &Job) {
420 crate::with_env_mutation(|| {
421 for key in PER_CALL {
422 match job.env.iter().find(|(k, _)| k == key) {
423 Some((_, v)) => std::env::set_var(key, v),
424 None => std::env::remove_var(key),
425 }
426 }
427 });
428 crate::ballot_sig::export_hook_session(job.payload_session.as_deref());
429 crate::bind_call_conversation(&job.raw, &crate::hook_call_as(&job.raw, Some(&job.event)));
430}
431
432fn run_batch(head: &Job, batch: &[PathBuf]) -> bool {
436 let Ok(exe) = std::env::current_exe() else {
437 crate::bookkeep::log_line("book queue: the running binary has no path; drainer stops");
438 return false;
439 };
440 let dir = if Path::new(&head.cwd).is_dir() {
441 PathBuf::from(&head.cwd)
442 } else {
443 PathBuf::from("/")
444 };
445 let mut cmd = std::process::Command::new(&exe);
446 cmd.arg("hook")
447 .arg("--apply-book")
448 .args(batch)
449 .env_clear()
450 .envs(head.env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
451 .current_dir(dir)
452 .stdin(std::process::Stdio::null())
453 .stdout(std::process::Stdio::null())
454 .stderr(std::process::Stdio::null());
455 let mut child = match cmd.spawn() {
456 Ok(child) => child,
457 Err(e) => {
458 crate::bookkeep::log_line(&format!(
459 "book queue: cannot start {} to apply a batch: {e}; drainer stops",
460 exe.display()
461 ));
462 return false;
463 }
464 };
465 let started = Instant::now();
466 loop {
467 match child.try_wait() {
468 Ok(Some(_)) | Err(_) => return true,
469 Ok(None) if started.elapsed() > BATCH_DEADLINE => {
470 let _ = child.kill();
471 let _ = child.wait();
472 crate::bookkeep::log_line(
473 "book queue: a batch ran past its deadline and was stopped",
474 );
475 return true;
476 }
477 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
478 }
479 }
480}
481
482#[must_use]
486pub fn apply(files: &[PathBuf]) -> i32 {
487 crate::batch_tracker_commits();
488 for path in files {
489 let Some(job) = read_job(path) else {
490 let _ = std::fs::remove_file(path);
491 continue;
492 };
493 wear_call(&job);
494 crate::act_for_ancestry(Some(job.ancestry.clone()));
495 crate::bookkeep::act_for(Some((job.owner, job.head.clone())));
496 let call = crate::HookCall {
497 event: job.event.clone(),
498 cue: job.cue.clone(),
499 session: Some(job.session.clone()),
500 shape: crate::HookShape::default(),
501 };
502 let done = match job.ask.as_deref() {
503 Some(reason) => crate::bookkeep::note_ask_within(&job.raw, &call, reason),
504 None => crate::bookkeep::observe_queued(&job.raw, &call),
505 };
506 if !done {
507 break;
508 }
509 let _ = std::fs::remove_file(path);
510 }
511 crate::act_for_ancestry(None);
512 crate::bookkeep::act_for(None);
513 let said = crate::flush_tracker_commits();
514 for line in said.lines() {
515 if line.contains("could not") || line.contains("refused") {
516 crate::bookkeep::log_line(line.trim());
517 }
518 }
519 0
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 fn git(dir: &Path, args: &[&str]) {
527 let ok = crate::git_cmd()
528 .arg("-C")
529 .arg(dir)
530 .args(args)
531 .env("GIT_CONFIG_GLOBAL", "/dev/null")
532 .env("GIT_CONFIG_NOSYSTEM", "1")
533 .output()
534 .unwrap();
535 assert!(
536 ok.status.success(),
537 "{}",
538 String::from_utf8_lossy(&ok.stderr)
539 );
540 }
541
542 #[test]
543 fn head_is_read_from_the_git_files_as_git_reads_it() {
544 let dir = tempfile::tempdir().unwrap();
545 let repo = dir.path();
546 git(repo, &["init", "-q", "-b", "main"]);
547 assert_eq!(head_now(repo), None, "no commit yet");
548 std::fs::create_dir(repo.join("sub")).unwrap();
549 git(
550 repo,
551 &[
552 "-c",
553 "user.name=t",
554 "-c",
555 "user.email=t@t",
556 "commit",
557 "-q",
558 "--allow-empty",
559 "-m",
560 "one",
561 ],
562 );
563 let said = crate::git_cmd()
564 .arg("-C")
565 .arg(repo)
566 .args(["rev-parse", "HEAD"])
567 .output()
568 .unwrap();
569 let sha = String::from_utf8_lossy(&said.stdout).trim().to_string();
570 assert_eq!(head_now(&repo.join("sub")).as_deref(), Some(sha.as_str()));
571 git(repo, &["pack-refs", "--all"]);
572 assert_eq!(head_now(repo).as_deref(), Some(sha.as_str()), "packed ref");
573 let tree = tempfile::tempdir().unwrap();
574 let wt = tree.path().join("wt");
575 git(
576 repo,
577 &["worktree", "add", "-q", "--detach", wt.to_str().unwrap()],
578 );
579 assert_eq!(head_now(&wt).as_deref(), Some(sha.as_str()), "worktree");
580 assert_eq!(head_now(Path::new("/")), None);
581 }
582}