From 811a3a84c190a70ddab22b0b506c9b56d04a922f Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 20 Oct 2026 21:13:46 +0800 Subject: [PATCH] fix(xtask): wait for Chrome child processes to settle PDF after launcher exits (#3103) --- xtask/src/demo_pdf/chrome.rs | 143 ++++++++++++++++++++++++++++++++--- 1 file changed, 132 insertions(+), 11 deletions(-) diff --git a/xtask/src/demo_pdf/chrome.rs b/xtask/src/demo_pdf/chrome.rs index 7d4cbf91f..d1d098c2c 100644 --- a/xtask/src/demo_pdf/chrome.rs +++ b/xtask/src/demo_pdf/chrome.rs @@ -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, 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)); + } }