Skip to main content

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}