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