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