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