mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
fix(xtask): wait for Chrome child processes to settle PDF after launcher exits (#3103)
This commit is contained in:
+132
-11
@@ -96,22 +96,18 @@ pub fn print_to_pdf(chrome: &Path, html: &Path, pdf: &Path, log: &Path) -> Resul
|
||||
let mut quiet_polls = 0;
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
if is_complete(pdf) {
|
||||
// On Windows the launched `chrome.exe` can exit while its child processes are still
|
||||
// writing the PDF, so give the file up to 60 s to appear, and only accept it once the
|
||||
// size has been quiet and the file complete for 5 consecutive one-second polls.
|
||||
if wait_for_settled_pdf(pdf, Duration::from_secs(60), Duration::from_millis(1000), 5) {
|
||||
return Ok(());
|
||||
}
|
||||
bail!("Chrome exited with {status} without writing {}; see {}", pdf.display(), log.display());
|
||||
}
|
||||
let len = fs::metadata(pdf).map(|m| m.len()).ok();
|
||||
if len == last_len && len.is_some() && is_complete(pdf) {
|
||||
quiet_polls += 1;
|
||||
if quiet_polls >= 10 {
|
||||
stop(&mut child);
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
quiet_polls = 0;
|
||||
if poll_settled_pdf(pdf, &mut last_len, &mut quiet_polls, 10) {
|
||||
stop(&mut child);
|
||||
return Ok(());
|
||||
}
|
||||
last_len = len;
|
||||
if started.elapsed() > Duration::from_secs(120) {
|
||||
stop(&mut child);
|
||||
bail!("Chrome timed out printing {}; see {}", html.display(), log.display());
|
||||
@@ -176,10 +172,58 @@ fn is_complete(pdf: &Path) -> bool {
|
||||
fs::read(pdf).map(|bytes| bytes.len() > 1024 && bytes[bytes.len() - 1024..].windows(5).any(|w| w == b"%%EOF")).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check whether `pdf` is complete and unchanged since `last_len`, updating `last_len` and
|
||||
/// `quiet_polls`. Returns `true` once `quiet_polls` reaches `required_quiet_polls`.
|
||||
fn poll_settled_pdf(pdf: &Path, last_len: &mut Option<u64>, quiet_polls: &mut usize, required_quiet_polls: usize) -> bool {
|
||||
let len = fs::metadata(pdf).map(|m| m.len()).ok();
|
||||
let settled = if len == *last_len && len.is_some() && is_complete(pdf) {
|
||||
*quiet_polls += 1;
|
||||
*quiet_polls >= required_quiet_polls.max(1)
|
||||
} else {
|
||||
*quiet_polls = 0;
|
||||
false
|
||||
};
|
||||
*last_len = len;
|
||||
settled
|
||||
}
|
||||
|
||||
/// Poll `pdf` after the top-level Chrome process exits until the file exists, is complete, and
|
||||
/// keeps the same byte length across `required_quiet_polls` consecutive polls of `poll_interval`,
|
||||
/// or until `timeout` elapses.
|
||||
fn wait_for_settled_pdf(pdf: &Path, timeout: Duration, poll_interval: Duration, required_quiet_polls: usize) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut last_len = None;
|
||||
let mut quiet_polls = 0;
|
||||
while Instant::now() < deadline {
|
||||
if poll_settled_pdf(pdf, &mut last_len, &mut quiet_polls, required_quiet_polls) {
|
||||
return true;
|
||||
}
|
||||
thread::sleep(poll_interval);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
fn new(tag: &str) -> Self {
|
||||
let dir = env::temp_dir().join(format!("pdfcraft-chrome-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_disable_code_sign_clone_and_use_throwaway_profile() {
|
||||
let args = chrome_args(Path::new("/tmp/prof"), OsString::from("--print-to-pdf=out.pdf"), "file:///a.html");
|
||||
@@ -196,4 +240,81 @@ mod tests {
|
||||
drop(profile);
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_exit_wait_accepts_pdf_written_and_settled_after_launcher_exits() {
|
||||
let dir = TestDir::new("exit-test");
|
||||
let pdf = dir.0.join("chrome.pdf");
|
||||
|
||||
let pdf_writer = pdf.clone();
|
||||
let writer = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
// Partial write without `%%EOF` while child processes are still rendering.
|
||||
let partial = vec![b'%'; 1500];
|
||||
fs::write(&pdf_writer, &partial).unwrap();
|
||||
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
let mut complete = partial;
|
||||
complete.extend_from_slice(&[b' '; 512]);
|
||||
complete.extend_from_slice(b"\n%%EOF\n");
|
||||
fs::write(&pdf_writer, &complete).unwrap();
|
||||
complete.len() as u64
|
||||
});
|
||||
|
||||
assert!(wait_for_settled_pdf(&pdf, Duration::from_secs(5), Duration::from_millis(10), 5));
|
||||
let final_len = fs::metadata(&pdf).unwrap().len();
|
||||
let expected_len = writer.join().unwrap();
|
||||
assert_eq!(final_len, expected_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_settled_pdf_resets_quiet_count_when_incremental_eof_grows() {
|
||||
let dir = TestDir::new("increment-test");
|
||||
let pdf = dir.0.join("chrome.pdf");
|
||||
|
||||
let mut last_len = None;
|
||||
let mut quiet_polls = 0;
|
||||
|
||||
// Missing file never settles.
|
||||
assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5));
|
||||
assert_eq!(quiet_polls, 0);
|
||||
|
||||
// First Skia increment already ends with `%%EOF`.
|
||||
let mut first_increment = vec![b'%'; 1500];
|
||||
first_increment.extend_from_slice(b"\n%%EOF\n");
|
||||
fs::write(&pdf, &first_increment).unwrap();
|
||||
|
||||
// First observation records size; next 4 polls increment `quiet_polls` to 4 (< 5).
|
||||
assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5));
|
||||
assert_eq!(quiet_polls, 0);
|
||||
for expected_quiet in 1..=4 {
|
||||
assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5));
|
||||
assert_eq!(quiet_polls, expected_quiet);
|
||||
}
|
||||
|
||||
// Second Skia increment grows the file before the 5th quiet poll: counter resets to 0.
|
||||
let mut final_increment = first_increment;
|
||||
final_increment.extend_from_slice(&[b' '; 1024]);
|
||||
final_increment.extend_from_slice(b"\n%%EOF\n");
|
||||
fs::write(&pdf, &final_increment).unwrap();
|
||||
|
||||
assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5));
|
||||
assert_eq!(quiet_polls, 0);
|
||||
|
||||
// Only after 5 consecutive unchanging polls at the new size does it report settled.
|
||||
for expected_quiet in 1..=4 {
|
||||
assert!(!poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5));
|
||||
assert_eq!(quiet_polls, expected_quiet);
|
||||
}
|
||||
assert!(poll_settled_pdf(&pdf, &mut last_len, &mut quiet_polls, 5));
|
||||
assert_eq!(quiet_polls, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_exit_wait_times_out_when_pdf_is_never_written() {
|
||||
let dir = TestDir::new("timeout-test");
|
||||
let pdf = dir.0.join("missing.pdf");
|
||||
|
||||
assert!(!wait_for_settled_pdf(&pdf, Duration::from_millis(50), Duration::from_millis(10), 5));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user