fix(xtask): wait for Chrome child processes to settle PDF after launcher exits (#3103)

This commit is contained in:
jonathanwxh-cell
2026-10-20 21:13:46 +08:00
parent ec0a965074
commit 811a3a84c1
+132 -11
View File
@@ -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));
}
}