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 url_style: String,
118 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 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 pub xslt_params: Vec<(String, String)>,
135 pub nodefaultresources: bool,
136 pub searchpaths: Vec<String>,
139 pub is_html_out: bool,
140 pub svg_fragments: Vec<(String, String)>,
141 pub schemadocs: bool,
142 pub whatsout: String,
145 pub page_opts: PageOpts,
146 pub pages: Vec<PageJob>,
147}
148
149pub(crate) struct ParallelResult {
152 pub(crate) main_output: Option<String>,
155 pub(crate) pages_rendered: usize,
157}
158
159fn 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
174fn 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
194struct 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
239enum Pending {
241 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
253pub(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 #[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 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 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 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 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 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 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 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 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 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 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 note_status(LogStatus::Fatal, None);
497 },
498 Some(_) => {},
499 }
500 },
501 },
502 }
503 }
504
505 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
514fn 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
534pub 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 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
571fn 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 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 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 assert!(render_jobs() >= 1);
746 }
747}