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 until = Instant::now() + CATCH_UP;
281 while ahead.iter().any(|job| job.exists()) && Instant::now() < until {
282 std::thread::sleep(Duration::from_millis(20));
283 }
284}
285
286#[must_use]
289pub fn has_pending() -> bool {
290 std::fs::read_dir(spool()).is_ok_and(|mut entries| {
291 entries.any(|e| e.is_ok_and(|e| e.path().extension().is_some_and(|x| x == "job")))
292 })
293}
294
295fn pending() -> Vec<PathBuf> {
296 let Ok(entries) = std::fs::read_dir(spool()) else {
297 return Vec::new();
298 };
299 let mut jobs: Vec<PathBuf> = entries
300 .filter_map(Result::ok)
301 .map(|e| e.path())
302 .filter(|p| p.extension().is_some_and(|x| x == "job"))
303 .collect();
304 jobs.sort();
305 jobs
306}
307
308fn read_job(path: &Path) -> Option<Job> {
309 serde_json::from_slice(&std::fs::read(path).ok()?).ok()
310}
311
312#[must_use]
316pub fn drain(wait: bool) -> i32 {
317 if mkdir_private(&spool()).is_err() {
318 return 1;
319 }
320 loop {
321 let Some(lock) = open_lock(&drain_lock_path()) else {
322 return 1;
323 };
324 if !flock(&lock, wait) {
325 return 0;
326 }
327 let drained = drain_held();
328 drop(lock);
329 if !drained {
330 return 1;
331 }
332 if pending().is_empty() {
333 return 0;
334 }
335 }
336}
337
338fn drain_held() -> bool {
343 let stamp = binary_stamp();
344 let mut stuck: Option<(PathBuf, u32)> = None;
345 loop {
346 if binary_stamp() != stamp {
347 crate::bookkeep::log_line(
348 "book queue: the ljos binary changed; this drainer stops and the next job starts one",
349 );
350 return false;
351 }
352 let jobs = pending();
353 let Some(first) = jobs.first().cloned() else {
354 return true;
355 };
356 let Some(head) = read_job(&first) else {
357 crate::bookkeep::log_line(&format!(
358 "book queue: dropped unreadable job {}",
359 first.display()
360 ));
361 let _ = std::fs::remove_file(&first);
362 continue;
363 };
364 let mut batch = vec![first.clone()];
365 for path in jobs.iter().skip(1).take(BATCH - 1) {
366 match read_job(path) {
367 Some(job) if job.cwd == head.cwd && same_env(&job, &head) => {
368 batch.push(path.clone());
369 }
370 _ => break,
371 }
372 }
373 if !run_batch(&head, &batch) {
374 return false;
375 }
376 if !first.exists() {
377 stuck = None;
378 continue;
379 }
380 let tries = match &stuck {
381 Some((path, n)) if *path == first => n + 1,
382 _ => 1,
383 };
384 let old = std::fs::metadata(&first)
385 .and_then(|m| m.modified())
386 .ok()
387 .and_then(|t| t.elapsed().ok())
388 .is_some_and(|age| age > STALE);
389 if tries >= TRIES || old {
390 crate::bookkeep::log_line(&format!(
391 "book queue: dropped {} {} after {tries} tries",
392 head.event,
393 first.display()
394 ));
395 let _ = std::fs::remove_file(&first);
396 stuck = None;
397 continue;
398 }
399 stuck = Some((first, tries));
400 std::thread::sleep(Duration::from_millis(250));
402 }
403}
404
405fn same_env(a: &Job, b: &Job) -> bool {
406 let kept = |job: &Job| {
407 job.env
408 .iter()
409 .filter(|(k, _)| !PER_CALL.contains(&k.as_str()))
410 .cloned()
411 .collect::<Vec<_>>()
412 };
413 kept(a) == kept(b)
414}
415
416fn wear_call(job: &Job) {
419 crate::with_env_mutation(|| {
420 for key in PER_CALL {
421 match job.env.iter().find(|(k, _)| k == key) {
422 Some((_, v)) => std::env::set_var(key, v),
423 None => std::env::remove_var(key),
424 }
425 }
426 });
427 crate::ballot_sig::export_hook_session(job.payload_session.as_deref());
428 crate::bind_call_conversation(&job.raw, &crate::hook_call_as(&job.raw, Some(&job.event)));
429}
430
431fn run_batch(head: &Job, batch: &[PathBuf]) -> bool {
435 let Ok(exe) = std::env::current_exe() else {
436 crate::bookkeep::log_line("book queue: the running binary has no path; drainer stops");
437 return false;
438 };
439 let dir = if Path::new(&head.cwd).is_dir() {
440 PathBuf::from(&head.cwd)
441 } else {
442 PathBuf::from("/")
443 };
444 let mut cmd = std::process::Command::new(&exe);
445 cmd.arg("hook")
446 .arg("--apply-book")
447 .args(batch)
448 .env_clear()
449 .envs(head.env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
450 .current_dir(dir)
451 .stdin(std::process::Stdio::null())
452 .stdout(std::process::Stdio::null())
453 .stderr(std::process::Stdio::null());
454 let mut child = match cmd.spawn() {
455 Ok(child) => child,
456 Err(e) => {
457 crate::bookkeep::log_line(&format!(
458 "book queue: cannot start {} to apply a batch: {e}; drainer stops",
459 exe.display()
460 ));
461 return false;
462 }
463 };
464 let started = Instant::now();
465 loop {
466 match child.try_wait() {
467 Ok(Some(_)) | Err(_) => return true,
468 Ok(None) if started.elapsed() > BATCH_DEADLINE => {
469 let _ = child.kill();
470 let _ = child.wait();
471 crate::bookkeep::log_line(
472 "book queue: a batch ran past its deadline and was stopped",
473 );
474 return true;
475 }
476 Ok(None) => std::thread::sleep(Duration::from_millis(20)),
477 }
478 }
479}
480
481#[must_use]
485pub fn apply(files: &[PathBuf]) -> i32 {
486 crate::batch_tracker_commits();
487 for path in files {
488 let Some(job) = read_job(path) else {
489 let _ = std::fs::remove_file(path);
490 continue;
491 };
492 wear_call(&job);
493 crate::act_for_ancestry(Some(job.ancestry.clone()));
494 crate::bookkeep::act_for(Some((job.owner, job.head.clone())));
495 let call = crate::HookCall {
496 event: job.event.clone(),
497 cue: job.cue.clone(),
498 session: Some(job.session.clone()),
499 shape: crate::HookShape::default(),
500 };
501 let done = match job.ask.as_deref() {
502 Some(reason) => crate::bookkeep::note_ask_within(&job.raw, &call, reason),
503 None => crate::bookkeep::observe_queued(&job.raw, &call),
504 };
505 if !done {
506 break;
507 }
508 let _ = std::fs::remove_file(path);
509 }
510 crate::act_for_ancestry(None);
511 crate::bookkeep::act_for(None);
512 let said = crate::flush_tracker_commits();
513 for line in said.lines() {
514 if line.contains("could not") || line.contains("refused") {
515 crate::bookkeep::log_line(line.trim());
516 }
517 }
518 0
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 fn git(dir: &Path, args: &[&str]) {
526 let ok = std::process::Command::new("git")
527 .arg("-C")
528 .arg(dir)
529 .args(args)
530 .env("GIT_CONFIG_GLOBAL", "/dev/null")
531 .env("GIT_CONFIG_NOSYSTEM", "1")
532 .output()
533 .unwrap();
534 assert!(
535 ok.status.success(),
536 "{}",
537 String::from_utf8_lossy(&ok.stderr)
538 );
539 }
540
541 #[test]
542 fn head_is_read_from_the_git_files_as_git_reads_it() {
543 let dir = tempfile::tempdir().unwrap();
544 let repo = dir.path();
545 git(repo, &["init", "-q", "-b", "main"]);
546 assert_eq!(head_now(repo), None, "no commit yet");
547 std::fs::create_dir(repo.join("sub")).unwrap();
548 git(
549 repo,
550 &[
551 "-c",
552 "user.name=t",
553 "-c",
554 "user.email=t@t",
555 "commit",
556 "-q",
557 "--allow-empty",
558 "-m",
559 "one",
560 ],
561 );
562 let said = std::process::Command::new("git")
563 .arg("-C")
564 .arg(repo)
565 .args(["rev-parse", "HEAD"])
566 .output()
567 .unwrap();
568 let sha = String::from_utf8_lossy(&said.stdout).trim().to_string();
569 assert_eq!(head_now(&repo.join("sub")).as_deref(), Some(sha.as_str()));
570 git(repo, &["pack-refs", "--all"]);
571 assert_eq!(head_now(repo).as_deref(), Some(sha.as_str()), "packed ref");
572 let tree = tempfile::tempdir().unwrap();
573 let wt = tree.path().join("wt");
574 git(
575 repo,
576 &["worktree", "add", "-q", "--detach", wt.to_str().unwrap()],
577 );
578 assert_eq!(head_now(&wt).as_deref(), Some(sha.as_str()), "worktree");
579 assert_eq!(head_now(Path::new("/")), None);
580 }
581}