Skip to main content

latexml/
render_workers.rs

1//! Process-parallel page rendering for pass B of the post pipeline
2//! (`docs/performance/STREAMING_POST_DESIGN_2026-07-06.md` §6).
3//!
4//! In-process page threads are blocked twice (`ObjectDB` is `!Send`;
5//! libxslt serializes every transform behind a process-wide lock), so the
6//! chosen shape is **process-level page-range workers**: the parent saves the
7//! completed ObjectDB as a SQLite file, partitions the spilled pages into
8//! contiguous chunks, and re-invokes its own binary once per chunk with
9//! `LATEXML_RENDER_WORKER=<manifest.json>`. Each child attaches the db
10//! readonly (WAL — N readers share one page cache via mmap), runs the SAME
11//! per-page pipeline as the serial driver
12//! (`crate::post::render_spilled_page`), and reports its diagnostic tally
13//! as trailing `Status:` lines on stderr. The parent folds child logs and
14//! counts deterministically, in chunk order, into its own `LOG_BUFFER` /
15//! `REPORT` — so the combined verdict and the persisted `--log` stay lossless
16//! (canvas signal-integrity rule: a child that dies without a status line is
17//! folded as FATAL, never as success).
18//!
19//! Env knob: `LATEXML_RENDER_JOBS` (usize). Default 1 = the serial path,
20//! byte-identical to before this module existed.
21
22use std::path::{Path, PathBuf};
23
24use latexml_core::{
25  Info,
26  common::error::{
27    LogStatus, ReportCounts, emit_error, emit_fatal, get_status_code, note_status,
28    snapshot_report_counts,
29  },
30  s,
31  util::logger::{CapturedDiagnostics, replay_captured},
32};
33use latexml_post::object_db::{DbAttachOptions, ObjectDB};
34use serde::{Deserialize, Serialize};
35
36/// Fewer spilled pages than this and the db save + child spawn overhead beats
37/// the parallel win — the serial path is taken regardless of the jobs knob.
38pub(crate) const MIN_PAGES_FOR_PARALLEL: usize = 64;
39
40/// The `LATEXML_RENDER_JOBS` knob: how many page-render worker processes pass
41/// B may spawn. Default (and any unparsable value) is 1 = serial. Read once
42/// per conversion, so no hot-path caching is needed.
43pub(crate) fn render_jobs() -> usize {
44  std::env::var("LATEXML_RENDER_JOBS")
45    .ok()
46    .and_then(|v| v.parse::<usize>().ok())
47    .filter(|&n| n >= 1)
48    .unwrap_or(1)
49}
50
51/// The serializable subset of `PostDocumentOptions` a worker needs to
52/// reconstruct the page parse — ALL of its fields, since even `destination`
53/// (later overridden per page) feeds the site-directory fallback inside
54/// `PostDocument::new`.
55#[derive(Serialize, Deserialize, Clone, Default)]
56pub struct PageOpts {
57  pub destination:           Option<String>,
58  pub destination_directory: Option<String>,
59  pub site_directory:        Option<String>,
60  pub source:                Option<String>,
61  pub source_directory:      Option<String>,
62  pub searchpaths:           Option<Vec<String>>,
63  pub validate:              bool,
64  pub nocache:               bool,
65}
66
67impl From<&latexml_post::document::PostDocumentOptions> for PageOpts {
68  fn from(o: &latexml_post::document::PostDocumentOptions) -> Self {
69    PageOpts {
70      destination:           o.destination.clone(),
71      destination_directory: o.destination_directory.clone(),
72      site_directory:        o.site_directory.clone(),
73      source:                o.source.clone(),
74      source_directory:      o.source_directory.clone(),
75      searchpaths:           o.searchpaths.clone(),
76      validate:              o.validate,
77      nocache:               o.nocache,
78    }
79  }
80}
81
82impl From<PageOpts> for latexml_post::document::PostDocumentOptions {
83  fn from(o: PageOpts) -> Self {
84    latexml_post::document::PostDocumentOptions {
85      destination:           o.destination,
86      destination_directory: o.destination_directory,
87      site_directory:        o.site_directory,
88      source:                o.source,
89      source_directory:      o.source_directory,
90      searchpaths:           o.searchpaths,
91      validate:              o.validate,
92      nocache:               o.nocache,
93    }
94  }
95}
96
97/// One spilled page for a worker to render: the spill path plus the metadata
98/// that does not survive the XML round-trip (mirrors `post::SpilledPage`
99/// minus the parent-only placeholder flags).
100#[derive(Serialize, Deserialize, Clone)]
101pub struct PageJob {
102  pub path:                  PathBuf,
103  pub destination:           Option<String>,
104  pub destination_directory: Option<String>,
105}
106
107/// Everything a page-render worker needs to rebuild the pass-B processor set
108/// exactly as the parent would have, plus its page range. Written as
109/// `render-manifest-{i}.json` beside the saved `render.db`.
110#[derive(Serialize, Deserialize, Clone)]
111pub struct RenderManifest {
112  /// The saved ObjectDB (SQLite, attached readonly by each worker).
113  pub dbfile:                    PathBuf,
114  pub navigation_toc:            Option<String>,
115  pub graphicimages:             bool,
116  pub graphics_svg_threshold_kb: u32,
117  pub pmml:                      bool,
118  pub cmml:                      bool,
119  pub keep_xmath:                bool,
120  /// Already-inverted from the CLI's `noinvisibletimes`.
121  pub invisible_times:           bool,
122  pub plane1:                    bool,
123  pub hackplane1:                bool,
124  pub mathtex:                   bool,
125  pub intent_literal:            bool,
126  pub stylesheet:                Option<String>,
127  /// The fully-resolved XSLT parameter map (CSS/JS/LATEXML_VERSION/user
128  /// overrides), captured after the parent computed it.
129  pub xslt_params:               Vec<(String, String)>,
130  pub nodefaultresources:        bool,
131  /// The resolved stylesheet/resource search paths the parent's `XSLT::new`
132  /// received.
133  pub searchpaths:               Vec<String>,
134  pub is_html_out:               bool,
135  pub svg_fragments:             Vec<(String, String)>,
136  pub schemadocs:                bool,
137  /// [`latexml_post::extract::Whatsout`] as its canonical CLI tag
138  /// (`as_cli`/`from_cli` round-trip).
139  pub whatsout:                  String,
140  pub page_opts:                 PageOpts,
141  pub pages:                     Vec<PageJob>,
142}
143
144/// The parent-side result of a parallel render in which workers were actually
145/// spawned (successfully or not — failures are already folded as diagnostics).
146pub(crate) struct ParallelResult {
147  /// The first page's finalized output, read back from its destination file —
148  /// the same content the serial driver would have returned as `main_output`.
149  pub(crate) main_output:    Option<String>,
150  /// Total pages written, summed from the workers' `Status:pages:` lines.
151  pub(crate) pages_rendered: usize,
152}
153
154/// Remove the per-run handoff artifacts (db + WAL sidecars + manifests).
155/// Best-effort: they live in the page-spill tempdir, which is removed wholesale
156/// when the parent drops it, so a failure here only delays the cleanup.
157fn cleanup_handoff(dbfile: &Path, manifests: &[PathBuf]) {
158  let _ = std::fs::remove_file(dbfile);
159  for suffix in ["-wal", "-shm"] {
160    let mut side = dbfile.as_os_str().to_owned();
161    side.push(suffix);
162    let _ = std::fs::remove_file(PathBuf::from(side));
163  }
164  for m in manifests {
165    let _ = std::fs::remove_file(m);
166  }
167}
168
169/// Strip ANSI `ESC[...m` color sequences (same logic as the logger's private
170/// helper). Child stderr is a pipe, so the TTY-gated logger should emit none —
171/// this is belt-and-suspenders for the log fold (signal-integrity rule).
172fn strip_ansi(s: &str) -> String {
173  let mut result = String::with_capacity(s.len());
174  let mut in_escape = false;
175  for c in s.chars() {
176    if in_escape {
177      if c == 'm' {
178        in_escape = false;
179      }
180    } else if c == '\u{1b}' {
181      in_escape = true;
182    } else {
183      result.push(c);
184    }
185  }
186  result
187}
188
189/// A worker's stderr, parsed: the trailing `Status:` lines are consumed into
190/// structured fields and everything else is the log text to forward + fold.
191struct ChildReport {
192  log:    String,
193  counts: Option<ReportCounts>,
194  status: Option<usize>,
195  pages:  usize,
196}
197
198fn parse_child_report(stderr_text: &str) -> ChildReport {
199  let mut log = String::new();
200  let mut counts = None;
201  let mut status = None;
202  let mut pages = 0usize;
203  for line in stderr_text.lines() {
204    if let Some(rest) = line.strip_prefix("Status:counts:") {
205      let mut it = rest.split(',').map(|v| v.trim().parse::<usize>().ok());
206      let (d, i, w, e, f) = (
207        it.next().flatten(),
208        it.next().flatten(),
209        it.next().flatten(),
210        it.next().flatten(),
211        it.next().flatten(),
212      );
213      if let (Some(debug), Some(info), Some(warning), Some(error), Some(fatal)) = (d, i, w, e, f) {
214        counts = Some(ReportCounts {
215          debug,
216          info,
217          warning,
218          error,
219          fatal: fatal > 0,
220        });
221      }
222    } else if let Some(rest) = line.strip_prefix("Status:conversion:") {
223      status = rest.trim().parse::<usize>().ok();
224    } else if let Some(rest) = line.strip_prefix("Status:pages:") {
225      pages = rest.trim().parse::<usize>().unwrap_or(0);
226    } else {
227      log.push_str(line);
228      log.push('\n');
229    }
230  }
231  ChildReport { log, counts, status, pages }
232}
233
234/// One spawned (or spawn-failed) worker awaiting its fold.
235enum Pending {
236  /// The drain thread owns the child and returns its full `Output`; the pid
237  /// is kept so a parent-side timeout can kill the fleet.
238  Spawned(
239    std::thread::JoinHandle<std::io::Result<std::process::Output>>,
240    u32,
241  ),
242  Failed(String),
243}
244
245/// Spawn `jobs` page-range workers over `pages` and fold their results.
246///
247/// Returns `None` when setup failed BEFORE any child was spawned (reported at
248/// Info severity) — the caller falls back to the serial render, which is still
249/// fully correct since the spilled pages are untouched. Once children run
250/// there is no fallback: a worker that fails, dies, or omits its status line
251/// is folded as an Error + FATAL status (fail toward flagging, never silent
252/// success).
253pub(crate) fn parallel_render(
254  mut manifest: RenderManifest,
255  pages: Vec<PageJob>,
256  jobs: usize,
257  spill_dir: &Path,
258  db: &ObjectDB,
259) -> Option<ParallelResult> {
260  let total_pages = pages.len();
261  let exe = match std::env::current_exe() {
262    Ok(e) => e,
263    Err(e) => {
264      Info!(
265        "post",
266        "parallel-render",
267        s!("parallel render disabled (current_exe: {})", e)
268      );
269      return None;
270    },
271  };
272  let dbfile = spill_dir.join("render.db");
273  if let Err(e) = db.save_as(&dbfile) {
274    Info!(
275      "post",
276      "parallel-render",
277      s!("parallel render disabled (db save: {})", e)
278    );
279    return None;
280  }
281  manifest.dbfile = dbfile.clone();
282
283  // Clamp the fleet to ACTUAL headroom (witness OOM 2026-08-03: a joint run
284  // carries ~15 GB of core residual into post, and 8 workers each
285  // eager-loading a 1.82M-object db blew a 30 GB cgroup at spawn — the
286  // post-only measurement survived only because its parent was fresh). Free
287  // what the allocators will give back first, then estimate each worker at
288  // ~3x the on-disk db (eager JSON decode + libxml holder + one page) plus a
289  // fixed floor, and size the fleet from the smaller of MemAvailable and the
290  // distance to this run's own memory fuse. Degrade to fewer workers — or
291  // decline to engage — rather than let the kernel choose a victim.
292  #[cfg(target_os = "linux")]
293  unsafe {
294    libc::malloc_trim(0);
295  }
296  #[cfg(not(feature = "dhat-heap"))]
297  unsafe {
298    libmimalloc_sys::mi_collect(true);
299  }
300  let db_bytes = std::fs::metadata(&dbfile).map(|m| m.len()).unwrap_or(0);
301  let per_worker = db_bytes.saturating_mul(3).max(256 * 1024 * 1024);
302  let mut budget = latexml_core::watchdog::available_memory_bytes().unwrap_or(u64::MAX);
303  if let Some(cap) = latexml_core::stomach::resolve_rss_cap() {
304    // Budget against the cooperative FUSE (75% of the hard cap), where the
305    // graceful stop fires — not the cap itself, where the watchdog kills.
306    let fuse = cap / 4 * 3;
307    let rss = latexml_core::watchdog::process_rss_kb().unwrap_or(0) * 1024;
308    budget = budget.min(fuse.saturating_sub(rss));
309  }
310  let affordable = (budget / per_worker) as usize;
311  let jobs = jobs.min(total_pages).min(affordable);
312  if jobs < 2 {
313    Info!(
314      "post",
315      "parallel-render",
316      s!(
317        "parallel render declined: headroom {} MB affords {} worker(s) at ~{} MB each — staying serial",
318        budget / (1024 * 1024),
319        affordable,
320        per_worker / (1024 * 1024)
321      )
322    );
323    cleanup_handoff(&dbfile, &[]);
324    return None;
325  }
326
327  // Contiguous chunks preserve page order: worker 0 owns the first pages, so
328  // the fold below (and the first page's main-output read) is deterministic.
329  let chunk_size = total_pages.div_ceil(jobs);
330  let first_destination = pages.first().and_then(|p| p.destination.clone());
331  let mut manifest_paths: Vec<PathBuf> = Vec::with_capacity(jobs);
332  for (i, chunk) in pages.chunks(chunk_size).enumerate() {
333    let mut m = manifest.clone();
334    m.pages = chunk.to_vec();
335    let mpath = spill_dir.join(format!("render-manifest-{i}.json"));
336    let write_result = serde_json::to_string(&m)
337      .map_err(|e| e.to_string())
338      .and_then(|json| std::fs::write(&mpath, json).map_err(|e| e.to_string()));
339    if let Err(e) = write_result {
340      Info!(
341        "post",
342        "parallel-render",
343        s!("parallel render disabled (manifest write: {})", e)
344      );
345      cleanup_handoff(&dbfile, &manifest_paths);
346      return None;
347    }
348    manifest_paths.push(mpath);
349  }
350
351  // Parent-side deadline check before committing to the spawn (the workers
352  // carry no deadline of their own; the parent polls between waits below and
353  // kills the fleet on a breach).
354  if let Err(e) = latexml_core::stomach::check_timeout() {
355    e.log_fatal();
356    cleanup_handoff(&dbfile, &manifest_paths);
357    return Some(ParallelResult {
358      main_output:    None,
359      pages_rendered: 0,
360    });
361  }
362
363  // The engagement marker (also what the parity test asserts on — without it
364  // a silently-ignored jobs knob would let the test pass vacuously serial).
365  Info!(
366    "post",
367    "parallel-render",
368    s!(
369      "parallel page render engaged: {} worker(s) over {} pages",
370      manifest_paths.len(),
371      total_pages
372    )
373  );
374
375  let mut pending: Vec<Pending> = Vec::with_capacity(manifest_paths.len());
376  for mpath in &manifest_paths {
377    let spawned = std::process::Command::new(&exe)
378      .env("LATEXML_RENDER_WORKER", mpath)
379      .stdin(std::process::Stdio::null())
380      .stdout(std::process::Stdio::piped())
381      .stderr(std::process::Stdio::piped())
382      .spawn();
383    match spawned {
384      Ok(child) => {
385        let pid = child.id();
386        // Each child gets a drain thread calling `wait_with_output` — piped
387        // stderr MUST be consumed while the parent polls, or a chatty child
388        // blocks on a full pipe and the poll below never sees it exit.
389        let handle = std::thread::Builder::new()
390          .name(format!("render-worker-drain-{pid}"))
391          .spawn(move || child.wait_with_output());
392        match handle {
393          Ok(h) => pending.push(Pending::Spawned(h, pid)),
394          Err(e) => pending.push(Pending::Failed(format!("drain thread spawn failed: {e}"))),
395        }
396      },
397      Err(e) => pending.push(Pending::Failed(format!("worker spawn failed: {e}"))),
398    }
399  }
400
401  // Poll (rather than block) so the parent's cooperative timeout keeps
402  // running between waits; on a breach, kill the fleet so the join below
403  // returns promptly instead of riding out the children.
404  loop {
405    let all_done = pending
406      .iter()
407      .all(|p| !matches!(p, Pending::Spawned(h, _) if !h.is_finished()));
408    if all_done {
409      break;
410    }
411    if let Err(e) = latexml_core::stomach::check_timeout() {
412      e.log_fatal();
413      #[cfg(unix)]
414      for p in &pending {
415        if let Pending::Spawned(h, pid) = p
416          && !h.is_finished()
417        {
418          // SAFETY: plain kill(2) on a child pid this process spawned.
419          unsafe {
420            libc::kill(*pid as i32, libc::SIGKILL);
421          }
422        }
423      }
424      break;
425    }
426    std::thread::sleep(std::time::Duration::from_millis(100));
427  }
428
429  // Fold IN CHUNK ORDER (deterministic log/tally, mirroring the graphics
430  // worker fold): forward each child's stderr, replay its log + counts into
431  // the parent LOG_BUFFER/REPORT, and flag anything that did not report.
432  let mut pages_rendered = 0usize;
433  for (i, p) in pending.into_iter().enumerate() {
434    match p {
435      Pending::Failed(e) => {
436        emit_error("post", "render_worker", &format!("worker {i}: {e}"));
437        note_status(LogStatus::Fatal, None);
438      },
439      Pending::Spawned(handle, _) => match handle.join() {
440        Err(_) => {
441          emit_error(
442            "post",
443            "render_worker",
444            &format!("worker {i}: drain thread panicked"),
445          );
446          note_status(LogStatus::Fatal, None);
447        },
448        Ok(Err(e)) => {
449          emit_error(
450            "post",
451            "render_worker",
452            &format!("worker {i}: wait failed: {e}"),
453          );
454          note_status(LogStatus::Fatal, None);
455        },
456        Ok(Ok(output)) => {
457          let stderr_text = strip_ansi(&String::from_utf8_lossy(&output.stderr));
458          let report = parse_child_report(&stderr_text);
459          if !report.log.is_empty() {
460            // The child's stderr was piped, so nothing reached the live
461            // stderr yet — forward it now, then fold the same text + counts
462            // into the captured log and the REPORT tally.
463            eprint!("{}", report.log);
464          }
465          replay_captured(CapturedDiagnostics {
466            log:    report.log,
467            counts: report.counts.unwrap_or_default(),
468          });
469          pages_rendered += report.pages;
470          match report.status {
471            None => {
472              // No status line = the child died before its final report (or
473              // never got that far). Fail toward flagging: this chunk's pages
474              // cannot be assumed rendered.
475              emit_error(
476                "post",
477                "render_worker",
478                &format!(
479                  "worker {i} exited (code {:?}) without a Status:conversion line",
480                  output.status.code()
481                ),
482              );
483              note_status(LogStatus::Fatal, None);
484            },
485            Some(s) if s >= 3 && !report.counts.is_some_and(|c| c.fatal) => {
486              // The child declared fatal but its counts line didn't carry the
487              // flag (or was missing) — max-fold the declared status anyway.
488              note_status(LogStatus::Fatal, None);
489            },
490            Some(_) => {},
491          }
492        },
493      },
494    }
495  }
496
497  // The serial driver returns the first page's finalized output as
498  // `main_output`; here that page is already on disk (written by worker 0),
499  // so read it back. A missing/unreadable file leaves `None` and the caller's
500  // fallback applies — the diagnostics above already flagged the failure.
501  let main_output = first_destination.and_then(|d| std::fs::read_to_string(&d).ok());
502  cleanup_handoff(&dbfile, &manifest_paths);
503  Some(ParallelResult { main_output, pages_rendered })
504}
505
506/// Print the worker's canonical trailing status report (the LAST lines on
507/// stderr): `Status:pages:`, then `Status:counts:`, then `Status:conversion:`
508/// — the parent parses them positionally-independently by prefix. Returns the
509/// process exit code (0 below fatal, 1 at fatal).
510fn print_status_report(pages: usize) -> i32 {
511  let c = snapshot_report_counts();
512  let status = get_status_code();
513  eprintln!("Status:pages:{pages}");
514  eprintln!(
515    "Status:counts:{},{},{},{},{}",
516    c.debug,
517    c.info,
518    c.warning,
519    c.error,
520    usize::from(c.fatal)
521  );
522  eprintln!("Status:conversion:{status}");
523  if status < 3 { 0 } else { 1 }
524}
525
526/// Entry point of the hidden worker mode (`LATEXML_RENDER_WORKER=<manifest>`),
527/// dispatched from the binary's `main` before any CLI handling. Renders the
528/// manifest's page range and ALWAYS ends its stderr with the status report —
529/// even when the manifest cannot be read — so the parent never mistakes a
530/// broken worker for a clean one.
531pub fn worker_main(manifest_path: &str) -> i32 {
532  latexml_core::util::logger::init(log::LevelFilter::Info).ok();
533  let manifest: RenderManifest = match std::fs::read_to_string(manifest_path)
534    .map_err(|e| e.to_string())
535    .and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
536  {
537    Ok(m) => m,
538    Err(e) => {
539      emit_fatal(
540        "post",
541        "render_worker",
542        &format!("cannot read the render manifest {manifest_path}: {e}"),
543      );
544      return print_status_report(0);
545    },
546  };
547  // Mirror `api.rs::on_worker`: a 256 MiB-stack thread for deeply nested
548  // math, and an explicit engine reset before the thread exits (the
549  // `#[thread_local]` roots do not Drop on a bare thread exit).
550  std::thread::Builder::new()
551    .stack_size(256 * 1024 * 1024)
552    .spawn(move || {
553      let pages = render_manifest_pages(manifest);
554      let code = print_status_report(pages);
555      latexml_core::reset_thread_engine();
556      code
557    })
558    .expect("spawn render worker thread")
559    .join()
560    .expect("render worker thread panicked")
561}
562
563/// Rebuild the pass-B processor set from the manifest — the EXACT construction
564/// the parent's `run_post_processing_inner` performs (same MathML
565/// primary/secondary parallel model, same XSLT wiring, same error paths) —
566/// then render every page in the manifest's range through the shared
567/// [`crate::post::render_spilled_page`]. Returns the number of pages written.
568fn render_manifest_pages(m: RenderManifest) -> usize {
569  use latexml_post::{
570    crossref::{CrossRef, UrlStyle},
571    processor::Processor,
572  };
573  let db = match ObjectDB::attach(&m.dbfile, DbAttachOptions {
574    readonly: true,
575    clean:    false,
576  }) {
577    Ok(db) => db,
578    Err(e) => {
579      emit_fatal(
580        "post",
581        "render_worker",
582        &format!("cannot attach the render db {}: {e}", m.dbfile.display()),
583      );
584      return 0;
585    },
586  };
587  let mut crossref = CrossRef::new(db, UrlStyle::File, true);
588  if let Some(navtoc) = m.navigation_toc.as_deref() {
589    crossref.set_navigation_toc(navtoc);
590  }
591  let graphics = m.graphicimages.then(|| {
592    latexml_post::graphics::Graphics::new(None, true)
593      .with_svg_threshold_kb(m.graphics_svg_threshold_kb)
594  });
595  let post = latexml_post::Post::new();
596  let mut processors: Vec<Box<dyn Processor>> = Vec::new();
597  if m.pmml {
598    let mut presentation = latexml_post::mathml::MathML::new_presentation()
599      .with_keep_xmath(m.keep_xmath)
600      .with_invisible_times(m.invisible_times)
601      .with_plane1(m.plane1, m.hackplane1)
602      .with_mathtex(m.mathtex)
603      .with_intent_literal(m.intent_literal);
604    if m.cmml {
605      presentation = presentation.with_secondaries(vec![Box::new(
606        latexml_post::mathml::MathML::new_content()
607          .with_keep_xmath(m.keep_xmath)
608          .with_invisible_times(m.invisible_times)
609          .with_plane1(m.plane1, m.hackplane1)
610          .secondary(),
611      )]);
612    }
613    processors.push(Box::new(presentation));
614  } else if m.cmml {
615    processors.push(Box::new(
616      latexml_post::mathml::MathML::new_content()
617        .with_keep_xmath(m.keep_xmath)
618        .with_invisible_times(m.invisible_times)
619        .with_plane1(m.plane1, m.hackplane1),
620    ));
621  }
622  if let Some(xsl_path) = m.stylesheet.as_deref() {
623    let params: rustc_hash::FxHashMap<String, String> = m.xslt_params.iter().cloned().collect();
624    match latexml_post::xslt::XSLT::new(
625      xsl_path,
626      params,
627      m.nodefaultresources,
628      None,
629      m.searchpaths.clone(),
630    ) {
631      Ok(xslt) => processors.push(Box::new(xslt)),
632      Err(e) => emit_error("post", "xslt", &format!("XSLT error: {e}")),
633    }
634  }
635  let ctx = crate::post::PageRenderCtx {
636    page_opts:     m.page_opts.clone().into(),
637    is_html_out:   m.is_html_out,
638    svg_fragments: m.svg_fragments.clone(),
639    schemadocs:    m.schemadocs,
640    whatsout:      latexml_post::extract::Whatsout::from_cli(&m.whatsout).unwrap_or_default(),
641  };
642  let mut procs = crate::post::PageProcessors {
643    crossref,
644    graphics,
645    post,
646    processors,
647  };
648  let mut pages_written = 0usize;
649  for job in &m.pages {
650    // Same OS give-back cadence as the serial render loop: each page cycles a
651    // DOM + XSLT result through the C heap, and a chunk can be tens of
652    // thousands of pages.
653    if pages_written > 0 && pages_written.is_multiple_of(512) {
654      #[cfg(target_os = "linux")]
655      unsafe {
656        libc::malloc_trim(0);
657      }
658      #[cfg(not(feature = "dhat-heap"))]
659      unsafe {
660        libmimalloc_sys::mi_collect(true);
661      }
662    }
663    match crate::post::render_spilled_page(
664      &job.path,
665      &mut procs,
666      &ctx,
667      job.destination.clone(),
668      job.destination_directory.clone(),
669    ) {
670      Ok(outputs) => {
671        for (dest, output) in outputs {
672          if let Some(path) = dest.as_deref() {
673            if let Some(parent) = Path::new(path).parent()
674              && !parent.as_os_str().is_empty()
675            {
676              let _ = std::fs::create_dir_all(parent);
677            }
678            pages_written += 1;
679            if let Err(e) = std::fs::write(path, &output) {
680              emit_error(
681                "post",
682                "write",
683                &format!("failed to write page {path}: {e}"),
684              );
685            }
686          }
687        }
688      },
689      // Already reported inside the pipeline; mirror the serial driver's
690      // abort-on-page-failure (the parent flags the shortfall through the
691      // folded error + the fatal-bearing status this worker will report).
692      Err(()) => break,
693    }
694  }
695  pages_written
696}
697
698#[cfg(test)]
699mod tests {
700  use super::*;
701
702  #[test]
703  fn child_report_parses_status_lines_and_keeps_log() {
704    let stderr_text = "Warning:post:x something odd\nInfo:post:y fine\nStatus:pages:41\nStatus:counts:0,2,1,3,1\nStatus:conversion:3\n";
705    let r = parse_child_report(stderr_text);
706    assert_eq!(r.pages, 41);
707    assert_eq!(r.status, Some(3));
708    let c = r.counts.expect("counts parsed");
709    assert_eq!((c.debug, c.info, c.warning, c.error), (0, 2, 1, 3));
710    assert!(c.fatal);
711    assert!(r.log.contains("something odd"));
712    assert!(
713      !r.log.contains("Status:"),
714      "status lines must not leak into the folded log"
715    );
716  }
717
718  #[test]
719  fn child_report_without_status_is_flagged_as_none() {
720    let r = parse_child_report("Error:post:z boom\n");
721    assert_eq!(r.status, None);
722    assert!(r.counts.is_none());
723    assert_eq!(r.pages, 0);
724  }
725
726  #[test]
727  fn ansi_is_stripped_from_child_stderr() {
728    assert_eq!(strip_ansi("\u{1b}[31mError:\u{1b}[0m x"), "Error: x");
729  }
730
731  #[test]
732  fn render_jobs_defaults_to_serial() {
733    // NOTE: does not set the env var (process-global); only checks the parse
734    // fallback contract via the default branch.
735    assert!(render_jobs() >= 1);
736  }
737}