Skip to main content

document_graphics/
worker.rs

1//! Isolated rendering, shared wire protocol and process supervision.
2use crate::{
3    MAX_PIXELS, Raster, RasterOptions,
4    mermaid::{self, MAX_SOURCE},
5};
6use std::{
7    io::{self, Read, Write},
8    path::Path,
9    process::{Child, Command, Stdio},
10    sync::{
11        atomic::{AtomicBool, Ordering},
12        mpsc,
13    },
14    thread::{self, JoinHandle},
15    time::{Duration, Instant},
16};
17/// A worker exits after this many requests; applications can recycle it sooner.
18pub const MAX_REQUESTS: usize = 32;
19#[cfg(target_os = "macos")]
20const MAX_RESIDENT_BYTES: u64 = 256 * 1024 * 1024;
21
22#[cfg(target_os = "macos")]
23fn resident_bytes(pid: u32) -> Option<u64> {
24    let mut info = std::mem::MaybeUninit::<libc::proc_taskinfo>::uninit();
25    let size = std::mem::size_of::<libc::proc_taskinfo>() as i32;
26    // SAFETY: proc_pidinfo initializes this exact structure on a full-size result.
27    let count = unsafe {
28        libc::proc_pidinfo(
29            pid as i32,
30            libc::PROC_PIDTASKINFO,
31            0,
32            info.as_mut_ptr().cast(),
33            size,
34        )
35    };
36    if count == size {
37        Some(unsafe { info.assume_init() }.pti_resident_size)
38    } else {
39        None
40    }
41}
42
43/// Private child entry point; invoke before application initialization.
44pub fn worker_main() -> io::Result<()> {
45    restrict_worker()?;
46    serve(&mut io::stdin().lock(), &mut io::stdout().lock())
47}
48
49fn restrict_worker() -> io::Result<()> {
50    #[cfg(unix)]
51    {
52        // SAFETY: valid pointers to process-local resource limits. Fail closed if
53        // the CPU/core-dump backstops cannot be applied to this dedicated child.
54        for (resource, seconds) in [(libc::RLIMIT_CPU, 8), (libc::RLIMIT_CORE, 0)] {
55            let limit = libc::rlimit {
56                rlim_cur: seconds,
57                rlim_max: seconds,
58            };
59            if unsafe { libc::setrlimit(resource, &limit) } != 0 {
60                return Err(io::Error::last_os_error());
61            }
62        }
63        // Lower priority is best effort; it does not enforce a resource limit.
64        unsafe {
65            libc::setpriority(libc::PRIO_PROCESS, 0, 10);
66        }
67    }
68    #[cfg(target_os = "macos")]
69    {
70        use std::ffi::{CStr, c_char};
71        unsafe extern "C" {
72            fn sandbox_init(profile: *const c_char, flags: u64, error: *mut *mut c_char) -> i32;
73            fn sandbox_free_error(error: *mut c_char);
74        }
75        // All fonts are embedded; this worker needs no document paths, external
76        // images, system fonts, writes, network, or child processes. Fail closed.
77        let profile = c"(version 1)(allow default)(deny network*)(deny file-write*)(deny file-read-data)(deny process-exec)(deny process-fork)";
78        let mut error = std::ptr::null_mut();
79        // SAFETY: static NUL-terminated profile and valid out-pointer.
80        if unsafe { sandbox_init(profile.as_ptr(), 0, &mut error) } != 0 {
81            let message = if error.is_null() {
82                "Cannot restrict diagram worker".into()
83            } else {
84                unsafe { CStr::from_ptr(error) }
85                    .to_string_lossy()
86                    .into_owned()
87            };
88            if !error.is_null() {
89                unsafe { sandbox_free_error(error) };
90            }
91            return Err(io::Error::other(message));
92        }
93    }
94    Ok(())
95}
96
97fn serve(input: &mut impl Read, output: &mut impl Write) -> io::Result<()> {
98    for _ in 0..MAX_REQUESTS {
99        let mut header = [0; 25];
100        match input.read(&mut header[..1])? {
101            0 => return Ok(()),
102            _ => input.read_exact(&mut header[1..])?,
103        }
104        let number = |slice: &[u8]| u32::from_le_bytes(slice.try_into().unwrap());
105        let length = number(&header[12..16]) as usize;
106        let options = RasterOptions {
107            bounds: (number(&header[4..8]), number(&header[8..12])),
108            scale: f64::from_le_bytes(header[17..25].try_into().unwrap()),
109        };
110        if &header[..4] != b"NPV1"
111            || !options.valid()
112            || length == 0
113            || length > MAX_SOURCE
114            || header[16] > 1
115        {
116            return Err(io::Error::new(
117                io::ErrorKind::InvalidData,
118                "Invalid diagram request",
119            ));
120        }
121        let mut source = vec![0; length];
122        input.read_exact(&mut source)?;
123        let pixels = std::str::from_utf8(&source)
124            .ok()
125            .and_then(|s| mermaid::render(s, header[16] == 1, options));
126        if let Some(p) = pixels {
127            for n in [
128                p.width,
129                p.height,
130                p.rgba.len() as u32,
131                p.original_width,
132                p.original_height,
133            ] {
134                output.write_all(&n.to_le_bytes())?;
135            }
136            output.write_all(&p.rgba)?;
137        } else {
138            output.write_all(&[0; 20])?;
139        }
140        output.flush()?;
141    }
142    Ok(())
143}
144
145fn response(input: &mut impl Read, bounds: (u32, u32)) -> io::Result<Option<Raster>> {
146    let mut header = [0; 20];
147    input.read_exact(&mut header)?;
148    let number = |slice: &[u8]| u32::from_le_bytes(slice.try_into().unwrap());
149    let (width, height, length) = (
150        number(&header[..4]),
151        number(&header[4..8]),
152        number(&header[8..12]) as usize,
153    );
154    let original_width = number(&header[12..16]);
155    let original_height = number(&header[16..20]);
156    if width == 0 && height == 0 && length == 0 && original_width == 0 && original_height == 0 {
157        return Ok(None);
158    }
159    let pixels = u64::from(width) * u64::from(height);
160    if original_width == 0
161        || original_height == 0
162        || original_width > 8192
163        || original_height > 8192
164        || u64::from(original_width) * u64::from(original_height) > 4_000_000
165        || width == 0
166        || height == 0
167        || width > bounds.0
168        || height > bounds.1
169        || pixels > MAX_PIXELS as u64
170        || pixels * 4 != length as u64
171    {
172        return Err(io::Error::new(
173            io::ErrorKind::InvalidData,
174            "invalid Mermaid pixels",
175        ));
176    }
177    let mut rgba = vec![0; length];
178    input.read_exact(&mut rgba)?;
179    Ok(Some(Raster {
180        original_width,
181        original_height,
182        width,
183        height,
184        rgba,
185    }))
186}
187
188type Request = (Vec<u8>, (u32, u32));
189/// One child process with bounded messages, cancellation and failure cleanup.
190/// Applications own the scheduling queue and decide when to recycle or retire it.
191pub struct Worker {
192    child: Child,
193    requests: Option<mpsc::SyncSender<Request>>,
194    replies: mpsc::Receiver<io::Result<Option<Raster>>>,
195    thread: Option<JoinHandle<()>>,
196    completed: usize,
197}
198impl Worker {
199    pub fn requests_completed(&self) -> usize {
200        self.completed
201    }
202    pub fn spawn(program: &Path, worker_argument: &str) -> io::Result<Self> {
203        let child = Command::new(program)
204            .arg(worker_argument)
205            .env_clear()
206            .stdin(Stdio::piped())
207            .stdout(Stdio::piped())
208            .stderr(Stdio::null())
209            .spawn()?;
210        Self::connect(child)
211    }
212
213    fn connect(mut child: Child) -> io::Result<Self> {
214        let mut input = child.stdin.take().unwrap();
215        let mut output = child.stdout.take().unwrap();
216        let (requests, incoming) = mpsc::sync_channel::<Request>(1);
217        let (replies, outgoing) = mpsc::sync_channel(1);
218        let thread = thread::Builder::new()
219            .name("mermaid-io".into())
220            .spawn(move || {
221                while let Ok((message, bounds)) = incoming.recv() {
222                    let result = input
223                        .write_all(&message)
224                        .and_then(|()| input.flush())
225                        .and_then(|()| response(&mut output, bounds));
226                    let failed = result.is_err();
227                    if replies.send(result).is_err() || failed {
228                        break;
229                    }
230                }
231            });
232        match thread {
233            Ok(thread) => Ok(Self {
234                child,
235                requests: Some(requests),
236                replies: outgoing,
237                thread: Some(thread),
238                completed: 0,
239            }),
240            Err(error) => {
241                let _ = child.kill();
242                let _ = child.wait();
243                Err(error)
244            }
245        }
246    }
247
248    /// Render one diagram off the UI thread. Any error stops and reaps the
249    /// worker; subsequent calls fail until the application creates a new handle.
250    pub fn render(
251        &mut self,
252        source: &str,
253        light: bool,
254        options: RasterOptions,
255        timeout: Duration,
256        cancel: &AtomicBool,
257    ) -> io::Result<Option<Raster>> {
258        let result = self.render_inner(source, light, options, timeout, cancel);
259        if result.is_err() {
260            self.stop();
261        }
262        result
263    }
264    fn render_inner(
265        &mut self,
266        source: &str,
267        light: bool,
268        options: RasterOptions,
269        timeout: Duration,
270        cancel: &AtomicBool,
271    ) -> io::Result<Option<Raster>> {
272        if self.requests.is_none() {
273            return Err(io::Error::other("Diagram worker stopped"));
274        }
275        if cancel.load(Ordering::Relaxed) {
276            return Err(io::ErrorKind::Interrupted.into());
277        }
278        if !options.valid() {
279            return Err(io::Error::new(
280                io::ErrorKind::InvalidInput,
281                "Invalid raster options",
282            ));
283        }
284        if !mermaid::admissible(source) {
285            return Ok(None);
286        }
287        if self.completed >= MAX_REQUESTS {
288            return Err(io::Error::other("Diagram worker request limit reached"));
289        }
290        let bounds = options.bounds;
291        let mut message = Vec::with_capacity(25 + source.len());
292        message.extend_from_slice(b"NPV1");
293        message.extend_from_slice(&bounds.0.to_le_bytes());
294        message.extend_from_slice(&bounds.1.to_le_bytes());
295        message.extend_from_slice(&(source.len() as u32).to_le_bytes());
296        message.push(u8::from(light));
297        message.extend_from_slice(&options.scale.to_le_bytes());
298        message.extend_from_slice(source.as_bytes());
299        self.requests
300            .as_ref()
301            .unwrap()
302            .try_send((message, bounds))
303            .map_err(|_| io::Error::other("Mermaid worker unavailable"))?;
304        let deadline = Instant::now()
305            .checked_add(timeout)
306            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid render timeout"))?;
307        loop {
308            if cancel.load(Ordering::Relaxed) {
309                return Err(io::Error::new(
310                    io::ErrorKind::Interrupted,
311                    "Diagram cancelled",
312                ));
313            }
314            if Instant::now() >= deadline {
315                return Err(io::Error::new(io::ErrorKind::TimedOut, "Diagram timed out"));
316            }
317            #[cfg(target_os = "macos")]
318            if resident_bytes(self.child.id()).is_some_and(|n| n > MAX_RESIDENT_BYTES) {
319                return Err(io::Error::other("Diagram exceeded its memory limit"));
320            }
321            match self.replies.recv_timeout(Duration::from_millis(20)) {
322                Ok(result) => {
323                    self.completed += 1;
324                    return result;
325                }
326                Err(mpsc::RecvTimeoutError::Timeout) => {}
327                Err(_) => return Err(io::Error::other("Diagram worker stopped")),
328            }
329        }
330    }
331    fn stop(&mut self) {
332        // The worker starts no descendants. Killing it closes both pipes and
333        // unblocks the I/O thread even after a partial write or partial reply.
334        let _ = self.child.kill();
335        let _ = self.child.wait();
336        self.requests.take();
337        if let Some(thread) = self.thread.take() {
338            let _ = thread.join();
339        }
340    }
341}
342
343impl Drop for Worker {
344    fn drop(&mut self) {
345        self.stop();
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::MAX_EDGE;
353    #[cfg(unix)]
354    use std::sync::Arc;
355    #[test]
356    fn worker_protocol_rejects_invalid_or_truncated_data_before_allocating() {
357        let mut data = Vec::new();
358        for n in [2u32, 1, 8, 1, 1] {
359            data.extend_from_slice(&n.to_le_bytes());
360        }
361        data.extend_from_slice(&[255, 0, 0, 128, 0, 0, 255, 255]);
362        let p = response(&mut &data[..], (MAX_EDGE, MAX_EDGE))
363            .unwrap()
364            .unwrap();
365        assert_eq!((p.width, p.height), (2, 1));
366        assert_eq!(p.rgba, data[20..]);
367        assert!(response(&mut &data[..27], (MAX_EDGE, MAX_EDGE)).is_err());
368        assert!(response(&mut &data[..], (1, 1)).is_err());
369        assert!(
370            response(&mut &[0; 20][..], (MAX_EDGE, MAX_EDGE))
371                .unwrap()
372                .is_none()
373        );
374        data[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
375        assert!(response(&mut &data[..], (MAX_EDGE, MAX_EDGE)).is_err());
376        let mut request = Vec::from(&b"NPV1"[..]);
377        for n in [MAX_EDGE, MAX_EDGE, MAX_SOURCE as u32 + 1] {
378            request.extend_from_slice(&n.to_le_bytes());
379        }
380        request.push(0);
381        request.extend_from_slice(&1_f64.to_le_bytes());
382        let mut output = Vec::new();
383        assert!(serve(&mut &request[..], &mut output).is_err());
384        assert!(output.is_empty());
385    }
386    #[cfg(unix)]
387    #[test]
388    fn timeouts_and_cancellation_kill_and_reap_partial_replies() {
389        for cancel_after in [None, Some(Duration::from_millis(30))] {
390            let child = Command::new("sh")
391                .args(["-c", "printf '\\001'; exec sleep 30"])
392                .stdin(Stdio::piped())
393                .stdout(Stdio::piped())
394                .stderr(Stdio::null())
395                .spawn()
396                .unwrap();
397            let mut worker = Worker::connect(child).unwrap();
398            let pid = worker.child.id();
399            let cancel = Arc::new(AtomicBool::new(false));
400            let cancellation = cancel_after.map(|duration| {
401                let cancel = cancel.clone();
402                thread::spawn(move || {
403                    thread::sleep(duration);
404                    cancel.store(true, Ordering::Relaxed);
405                })
406            });
407            let started = Instant::now();
408            let error = worker
409                .render(
410                    "flowchart LR; A-->B",
411                    false,
412                    RasterOptions::scaled(1.),
413                    Duration::from_millis(100),
414                    &cancel,
415                )
416                .err()
417                .unwrap();
418            assert_eq!(
419                error.kind(),
420                if cancel_after.is_some() {
421                    io::ErrorKind::Interrupted
422                } else {
423                    io::ErrorKind::TimedOut
424                }
425            );
426            assert!(worker.requests.is_none());
427            if let Some(thread) = cancellation {
428                thread.join().unwrap();
429            }
430            assert!(started.elapsed() < Duration::from_secs(3));
431            // SAFETY: signal zero only checks whether the reaped child exists.
432            assert_eq!(unsafe { libc::kill(pid as libc::pid_t, 0) }, -1);
433            assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH));
434            assert!(
435                worker
436                    .render(
437                        "flowchart LR; A-->B",
438                        false,
439                        RasterOptions::scaled(1.),
440                        Duration::from_millis(100),
441                        &AtomicBool::new(false)
442                    )
443                    .is_err()
444            );
445        }
446    }
447    /// The sandbox applies to a whole process, so `sandbox_probe` installs it in
448    /// a child run of this test binary.
449    #[cfg(target_os = "macos")]
450    #[test]
451    fn macos_sandbox_denies_file_reads_writes_network_and_processes() {
452        let file = std::env::temp_dir().join(format!("document-graphics-{}", std::process::id()));
453        std::fs::write(&file, b"private").unwrap();
454        let output = Command::new(std::env::current_exe().unwrap())
455            .args(["worker::tests::sandbox_probe", "--exact", "--ignored"])
456            .env("DOCUMENT_GRAPHICS_SANDBOX_PROBE", &file)
457            .output()
458            .unwrap();
459        let contents = std::fs::read(&file).unwrap();
460        std::fs::remove_file(&file).unwrap();
461        let report = String::from_utf8_lossy(&output.stdout);
462        assert!(
463            output.status.success() && report.contains("1 passed"),
464            "{report}{}",
465            String::from_utf8_lossy(&output.stderr)
466        );
467        assert_eq!(contents, b"private");
468    }
469    #[cfg(target_os = "macos")]
470    #[test]
471    #[ignore = "run in a child process by the macOS sandbox test"]
472    fn sandbox_probe() {
473        // Never restrict the test runner itself, only the child started above.
474        let Some(file) = std::env::var_os("DOCUMENT_GRAPHICS_SANDBOX_PROBE") else {
475            return;
476        };
477        // Each operation works before the sandbox, so failing after is its doing.
478        let spawn = || Command::new("/usr/bin/true").status();
479        assert!(std::fs::read(&file).is_ok() && spawn().is_ok());
480        assert!(std::net::TcpListener::bind("127.0.0.1:0").is_ok());
481        restrict_worker().unwrap();
482        assert!(std::fs::read(&file).is_err(), "read a file");
483        assert!(std::fs::write(&file, b"changed").is_err(), "wrote a file");
484        assert!(
485            std::net::TcpListener::bind("127.0.0.1:0").is_err(),
486            "opened a socket"
487        );
488        assert!(spawn().is_err(), "started a process");
489    }
490}