cuttlefish_host/render_worker.rs
1//! Rendering PDF pages in a subprocess, so a crash cannot take the daemon down.
2//!
3//! # Why this exists
4//!
5//! pdfium is a large C++ library, and it segfaults on input that other parsers
6//! accept — this project has a PDF that `lopdf` reads without complaint and
7//! pdfium dies on. That is not a bug to be fixed here; it is what handing
8//! untrusted bytes to a C++ parser is like.
9//!
10//! In-process, a segfault kills the daemon. Not the job — the *daemon*, and with
11//! it every other job running alongside, plus their results. The whole system is
12//! otherwise built so that a failing job fails alone: capabilities are checked
13//! per job, contexts are per job, a wasm trap ends one job. A renderer that can
14//! take down the process is the one thing that breaks that property.
15//!
16//! So rendering happens in a child process. A crash there becomes a signal on a
17//! wait status, which is a normal error the offending job reports and everything
18//! else survives.
19//!
20//! # How the child is chosen
21//!
22//! Normally the child is *this same executable*, re-invoked with a hidden
23//! argument, which guarantees it is exactly the build the parent is running — a
24//! separately shipped binary can drift out of sync in ways that appear only at
25//! runtime.
26//!
27//! That does not work for tests: a libtest binary cannot re-exec itself, because
28//! libtest would read the worker's arguments as test filters. So
29//! `WORKER_EXE_ENV` can name an executable instead, and
30//! the crate ships a `cuttlefish-render-worker` binary for that purpose.
31//!
32//! The cost is one process spawn per rendered page. Against the render itself
33//! and the vision-model inference that follows it, that is not measurable.
34
35// `Write` is only reached by the rendering path, which is feature-gated —
36// importing it unconditionally warns in a build without the feature.
37use std::io::Read;
38#[cfg(feature = "pdf-render")]
39use std::io::Write;
40use std::path::Path;
41use std::process::{Command, Stdio};
42
43/// The argument that turns this executable into a render worker.
44///
45/// Deliberately unlikely to collide with a real argument, and checked before any
46/// other parsing so a worker never runs the daemon's own startup.
47pub const WORKER_ARG: &str = "--__cuttlefish_render_worker";
48
49/// If this process was spawned as a render worker, do that work and exit.
50///
51/// Call first thing in `main`. Returns only when this is a normal invocation.
52///
53/// The protocol is deliberately minimal: page number and width on the command
54/// line, the PDF path too, PNG bytes on stdout, a message on stderr, and the
55/// exit code carrying success. Anything richer would need a serialization format
56/// on both sides of a boundary whose entire purpose is that one side may die
57/// unexpectedly.
58pub fn run_if_worker() {
59 let args: Vec<String> = std::env::args().collect();
60 let Some(position) = args.iter().position(|a| a == WORKER_ARG) else {
61 return;
62 };
63
64 // Recognised, but this build cannot render. Say exactly that. The
65 // alternative — returning and letting the caller parse these arguments
66 // as its own — produced `Error: usage: cuttlefishd <spec> ...` as a
67 // per-item failure, which names neither rendering nor the feature and
68 // sends the reader looking at their spec.
69 #[cfg(not(feature = "pdf-render"))]
70 {
71 let _ = position;
72 eprintln!(
73 "this binary was spawned as a PDF render worker but was built without the \
74 `pdf-render` feature. The daemon binary does the rendering, so it needs the \
75 feature too: `cargo build -p cuttlefishd --features pdf-render`. Enabling it \
76 only on cuttlefish-host is not enough."
77 );
78 std::process::exit(2);
79 }
80
81 #[cfg(feature = "pdf-render")]
82 {
83 let fail = |message: &str| -> ! {
84 eprintln!("{message}");
85 std::process::exit(2);
86 };
87
88 let (Some(path), Some(page), Some(width)) = (
89 args.get(position + 1),
90 args.get(position + 2).and_then(|s| s.parse::<u32>().ok()),
91 args.get(position + 3).and_then(|s| s.parse::<u16>().ok()),
92 ) else {
93 fail("render worker: expected <path> <page> <width>");
94 };
95
96 match crate::documents::render_page_in_process(Path::new(path), page, width) {
97 Ok(png) => {
98 if let Err(e) = std::io::stdout().write_all(&png) {
99 fail(&format!("render worker: writing output: {e}"));
100 }
101 let _ = std::io::stdout().flush();
102 std::process::exit(0);
103 }
104 Err(e) => fail(&format!("{e}")),
105 }
106 }
107}
108
109/// Names an executable to use as the render worker instead of re-execing this
110/// one.
111///
112/// Exists for tests: a libtest binary cannot re-exec itself as a worker, because
113/// libtest would try to read the worker's arguments as test filters.
114pub const WORKER_EXE_ENV: &str = "CUTTLEFISH_RENDER_WORKER";
115
116/// Render a page in a child process, returning PNG bytes.
117pub fn render_page(path: &Path, page: u32, width: u16) -> anyhow::Result<Vec<u8>> {
118 let exe = match std::env::var(WORKER_EXE_ENV) {
119 Ok(exe) if !exe.is_empty() => std::path::PathBuf::from(exe),
120 _ => std::env::current_exe().map_err(|e| {
121 anyhow::anyhow!("locating this executable to spawn a render worker: {e}")
122 })?,
123 };
124
125 let mut child = Command::new(exe)
126 .arg(WORKER_ARG)
127 .arg(path)
128 .arg(page.to_string())
129 .arg(width.to_string())
130 .stdout(Stdio::piped())
131 .stderr(Stdio::piped())
132 .spawn()
133 .map_err(|e| anyhow::anyhow!("spawning the render worker: {e}"))?;
134
135 let mut png = Vec::new();
136 let mut message = String::new();
137 if let Some(mut out) = child.stdout.take() {
138 out.read_to_end(&mut png)?;
139 }
140 if let Some(mut err) = child.stderr.take() {
141 err.read_to_string(&mut message)?;
142 }
143 let status = child.wait()?;
144
145 if status.success() {
146 return Ok(png);
147 }
148
149 // A signal means the renderer crashed rather than refused. Saying so
150 // distinguishes "this PDF is malformed in a way pdfium survives" from "this
151 // PDF killed the renderer", and the second is worth knowing about.
152 #[cfg(unix)]
153 {
154 use std::os::unix::process::ExitStatusExt;
155 if let Some(signal) = status.signal() {
156 anyhow::bail!(
157 "the PDF renderer crashed (signal {signal}) on {}. The page was \
158 not rendered, but the daemon is unaffected — this is why \
159 rendering runs in a separate process.",
160 path.display()
161 );
162 }
163 }
164
165 let message = message.trim();
166 if message.is_empty() {
167 anyhow::bail!(
168 "the PDF renderer failed with status {status} on {}",
169 path.display()
170 );
171 }
172 anyhow::bail!("{message}");
173}