1use 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
36pub(crate) const MIN_PAGES_FOR_PARALLEL: usize = 64;
39
40pub(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#[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#[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#[derive(Serialize, Deserialize, Clone)]
111pub struct RenderManifest {
112 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 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 pub xslt_params: Vec<(String, String)>,
130 pub nodefaultresources: bool,
131 pub searchpaths: Vec<String>,
134 pub is_html_out: bool,
135 pub svg_fragments: Vec<(String, String)>,
136 pub schemadocs: bool,
137 pub whatsout: String,
140 pub page_opts: PageOpts,
141 pub pages: Vec<PageJob>,
142}
143
144pub(crate) struct ParallelResult {
147 pub(crate) main_output: Option<String>,
150 pub(crate) pages_rendered: usize,
152}
153
154fn 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
169fn 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
189struct 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
234enum Pending {
236 Spawned(
239 std::thread::JoinHandle<std::io::Result<std::process::Output>>,
240 u32,
241 ),
242 Failed(String),
243}
244
245pub(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 #[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 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 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 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 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 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 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 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 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 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 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 note_status(LogStatus::Fatal, None);
489 },
490 Some(_) => {},
491 }
492 },
493 },
494 }
495 }
496
497 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
506fn 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
526pub 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 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
563fn 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 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 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 assert!(render_jobs() >= 1);
736 }
737}