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, Background, 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        let valid_flags = match &header[..4] {
90            b"NPV1" => header[16] <= 1,
91            b"NPV2" => header[16] <= 3,
92            _ => false,
93        };
94        if !valid_flags || !options.valid() || length == 0 || length > MAX_SOURCE {
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 background = if header[16] & 2 == 0 {
103            Background::Theme
104        } else {
105            Background::Transparent
106        };
107        let pixels = std::str::from_utf8(&source)
108            .ok()
109            .and_then(|s| mermaid::render(s, header[16] & 1 != 0, background, options));
110        if let Some(p) = pixels {
111            for n in [
112                p.width,
113                p.height,
114                p.rgba.len() as u32,
115                p.original_width,
116                p.original_height,
117            ] {
118                output.write_all(&n.to_le_bytes())?;
119            }
120            output.write_all(&p.rgba)?;
121        } else {
122            output.write_all(&[0; 20])?;
123        }
124        output.flush()?;
125    }
126    Ok(())
127}
128
129fn response(input: &mut impl Read, bounds: (u32, u32)) -> io::Result<Option<Raster>> {
130    let mut header = [0; 20];
131    input.read_exact(&mut header)?;
132    let number = |slice: &[u8]| u32::from_le_bytes(slice.try_into().unwrap());
133    let (width, height, length) = (
134        number(&header[..4]),
135        number(&header[4..8]),
136        number(&header[8..12]) as usize,
137    );
138    let original_width = number(&header[12..16]);
139    let original_height = number(&header[16..20]);
140    if width == 0 && height == 0 && length == 0 && original_width == 0 && original_height == 0 {
141        return Ok(None);
142    }
143    let pixels = u64::from(width) * u64::from(height);
144    if original_width == 0
145        || original_height == 0
146        || original_width > crate::svg::MAX_DIAGRAM_EDGE
147        || original_height > crate::svg::MAX_DIAGRAM_EDGE
148        || u64::from(original_width) * u64::from(original_height) > crate::svg::MAX_DIAGRAM_AREA
149        || width == 0
150        || height == 0
151        || width > bounds.0
152        || height > bounds.1
153        || pixels > MAX_PIXELS as u64
154        || pixels * 4 != length as u64
155    {
156        return Err(io::Error::new(
157            io::ErrorKind::InvalidData,
158            "invalid Mermaid pixels",
159        ));
160    }
161    let mut rgba = vec![0; length];
162    input.read_exact(&mut rgba)?;
163    Ok(Some(Raster {
164        original_width,
165        original_height,
166        width,
167        height,
168        rgba,
169    }))
170}
171
172type Request = (Vec<u8>, (u32, u32));
173/// One child process with bounded messages, cancellation and failure cleanup.
174/// Applications own the scheduling queue and decide when to recycle or retire it.
175pub struct Worker {
176    child: Child,
177    requests: Option<mpsc::SyncSender<Request>>,
178    replies: mpsc::Receiver<io::Result<Option<Raster>>>,
179    thread: Option<JoinHandle<()>>,
180    completed: usize,
181}
182impl Worker {
183    pub fn requests_completed(&self) -> usize {
184        self.completed
185    }
186    pub fn spawn(program: &Path, worker_argument: &str) -> io::Result<Self> {
187        let child = Command::new(program)
188            .arg(worker_argument)
189            .env_clear()
190            .stdin(Stdio::piped())
191            .stdout(Stdio::piped())
192            .stderr(Stdio::null())
193            .spawn()?;
194        Self::connect(child)
195    }
196
197    fn connect(mut child: Child) -> io::Result<Self> {
198        let mut input = child.stdin.take().unwrap();
199        let mut output = child.stdout.take().unwrap();
200        let (requests, incoming) = mpsc::sync_channel::<Request>(1);
201        let (replies, outgoing) = mpsc::sync_channel(1);
202        let thread = thread::Builder::new()
203            .name("mermaid-io".into())
204            .spawn(move || {
205                while let Ok((message, bounds)) = incoming.recv() {
206                    let result = input
207                        .write_all(&message)
208                        .and_then(|()| input.flush())
209                        .and_then(|()| response(&mut output, bounds));
210                    let failed = result.is_err();
211                    if replies.send(result).is_err() || failed {
212                        break;
213                    }
214                }
215            });
216        match thread {
217            Ok(thread) => Ok(Self {
218                child,
219                requests: Some(requests),
220                replies: outgoing,
221                thread: Some(thread),
222                completed: 0,
223            }),
224            Err(error) => {
225                kill_and_reap(&mut child);
226                Err(error)
227            }
228        }
229    }
230
231    /// Render one diagram off the UI thread. Any error stops and reaps the
232    /// worker; subsequent calls fail until the application creates a new handle.
233    pub fn render(
234        &mut self,
235        source: &str,
236        light: bool,
237        options: RasterOptions,
238        timeout: Duration,
239        cancel: &AtomicBool,
240    ) -> io::Result<Option<Raster>> {
241        self.render_with_background(source, light, Background::Theme, options, timeout, cancel)
242    }
243
244    /// Render with an explicit canvas treatment. Transparency is applied before
245    /// rasterization, preserving antialiased edges and intentional diagram fills.
246    /// Uses the same limits, cancellation and worker cleanup as [`Self::render`].
247    pub fn render_with_background(
248        &mut self,
249        source: &str,
250        light: bool,
251        background: Background,
252        options: RasterOptions,
253        timeout: Duration,
254        cancel: &AtomicBool,
255    ) -> io::Result<Option<Raster>> {
256        let result = self.render_inner(source, light, background, options, timeout, cancel);
257        if result.is_err() {
258            self.stop();
259        }
260        result
261    }
262    fn render_inner(
263        &mut self,
264        source: &str,
265        light: bool,
266        background: Background,
267        options: RasterOptions,
268        timeout: Duration,
269        cancel: &AtomicBool,
270    ) -> io::Result<Option<Raster>> {
271        if self.requests.is_none() {
272            return Err(io::Error::other("Diagram worker stopped"));
273        }
274        if cancel.load(Ordering::Relaxed) {
275            return Err(io::ErrorKind::Interrupted.into());
276        }
277        if !options.valid() {
278            return Err(io::Error::new(
279                io::ErrorKind::InvalidInput,
280                "Invalid raster options",
281            ));
282        }
283        if !mermaid::admissible(source) {
284            return Ok(None);
285        }
286        if self.completed >= MAX_REQUESTS {
287            return Err(io::Error::other("Diagram worker request limit reached"));
288        }
289        let deadline = Instant::now()
290            .checked_add(timeout)
291            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid render timeout"))?;
292        let mut guard = RequestGuard::new(cancel, deadline, MAX_RESIDENT_BYTES);
293        let bounds = options.bounds;
294        let mut message = Vec::with_capacity(25 + source.len());
295        let transparent = background == Background::Transparent;
296        message.extend_from_slice(if transparent { b"NPV2" } else { b"NPV1" });
297        message.extend_from_slice(&bounds.0.to_le_bytes());
298        message.extend_from_slice(&bounds.1.to_le_bytes());
299        message.extend_from_slice(&(source.len() as u32).to_le_bytes());
300        message.push(u8::from(light) | (u8::from(transparent) << 1));
301        message.extend_from_slice(&options.scale.to_le_bytes());
302        message.extend_from_slice(source.as_bytes());
303        self.requests
304            .as_ref()
305            .unwrap()
306            .try_send((message, bounds))
307            .map_err(|_| io::Error::other("Mermaid worker unavailable"))?;
308        loop {
309            match guard.check(self.child.id(), false) {
310                // Preserve the diagram worker's best-effort RSS monitoring.
311                #[cfg(target_os = "macos")]
312                Err(RequestError::MemoryUnavailable) => {}
313                result => result.map_err(request_error)?,
314            }
315            let remaining = guard.remaining();
316            match self
317                .replies
318                .recv_timeout(remaining.min(Duration::from_millis(20)))
319            {
320                Ok(result) => {
321                    // A queued reply does not override cancellation or a deadline
322                    // reached while this thread was waiting to be scheduled.
323                    guard.check_completion().map_err(request_error)?;
324                    self.completed += 1;
325                    return result;
326                }
327                Err(mpsc::RecvTimeoutError::Timeout) => {}
328                Err(_) => return Err(io::Error::other("Diagram worker stopped")),
329            }
330        }
331    }
332    fn stop(&mut self) {
333        // The worker starts no descendants. Killing it closes both pipes and
334        // unblocks the I/O thread even after a partial write or partial reply.
335        kill_and_reap(&mut self.child);
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
349fn request_error(error: RequestError) -> io::Error {
350    match error {
351        RequestError::Cancelled => io::Error::new(io::ErrorKind::Interrupted, "Diagram cancelled"),
352        RequestError::TimedOut => io::Error::new(io::ErrorKind::TimedOut, "Diagram timed out"),
353        #[cfg(target_os = "macos")]
354        RequestError::MemoryExceeded => io::Error::other("Diagram exceeded its memory limit"),
355        #[cfg(target_os = "macos")]
356        RequestError::MemoryUnavailable => io::Error::other("Cannot monitor diagram worker memory"),
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::MAX_EDGE;
364    #[cfg(unix)]
365    use std::sync::Arc;
366    #[test]
367    fn worker_protocol_rejects_invalid_or_truncated_data_before_allocating() {
368        let mut data = Vec::new();
369        for n in [2u32, 1, 8, 1, 1] {
370            data.extend_from_slice(&n.to_le_bytes());
371        }
372        data.extend_from_slice(&[255, 0, 0, 128, 0, 0, 255, 255]);
373        let p = response(&mut &data[..], (MAX_EDGE, MAX_EDGE))
374            .unwrap()
375            .unwrap();
376        assert_eq!((p.width, p.height), (2, 1));
377        assert_eq!(p.rgba, data[20..]);
378        // Large logical dimensions do not increase the pixel allocation.
379        data[12..16].copy_from_slice(&10000_u32.to_le_bytes());
380        data[16..20].copy_from_slice(&1500_u32.to_le_bytes());
381        let p = response(&mut &data[..], (MAX_EDGE, MAX_EDGE))
382            .unwrap()
383            .unwrap();
384        assert_eq!((p.original_width, p.original_height), (10000, 1500));
385        assert_eq!(p.rgba.len(), 8);
386        data[12..16].copy_from_slice(&(crate::svg::MAX_DIAGRAM_EDGE + 1).to_le_bytes());
387        assert!(response(&mut &data[..], (MAX_EDGE, MAX_EDGE)).is_err());
388        data[12..16].copy_from_slice(&32000_u32.to_le_bytes());
389        data[16..20].copy_from_slice(&32000_u32.to_le_bytes());
390        assert!(response(&mut &data[..], (MAX_EDGE, MAX_EDGE)).is_err());
391        data[12..16].copy_from_slice(&1_u32.to_le_bytes());
392        data[16..20].copy_from_slice(&1_u32.to_le_bytes());
393        assert!(response(&mut &data[..27], (MAX_EDGE, MAX_EDGE)).is_err());
394        assert!(response(&mut &data[..], (1, 1)).is_err());
395        assert!(
396            response(&mut &[0; 20][..], (MAX_EDGE, MAX_EDGE))
397                .unwrap()
398                .is_none()
399        );
400        data[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
401        assert!(response(&mut &data[..], (MAX_EDGE, MAX_EDGE)).is_err());
402        let mut request = Vec::from(&b"NPV1"[..]);
403        for n in [MAX_EDGE, MAX_EDGE, MAX_SOURCE as u32 + 1] {
404            request.extend_from_slice(&n.to_le_bytes());
405        }
406        request.push(0);
407        request.extend_from_slice(&1_f64.to_le_bytes());
408        let mut output = Vec::new();
409        assert!(serve(&mut &request[..], &mut output).is_err());
410        assert!(output.is_empty());
411        // The original protocol only accepts the light bit; the transparency
412        // extension still rejects unknown flags before reading a source body.
413        request[12..16].copy_from_slice(&1_u32.to_le_bytes());
414        for (magic, flags) in [(b"NPV1", 2), (b"NPV2", 4), (b"NPV2", 255)] {
415            request[..4].copy_from_slice(magic);
416            request[16] = flags;
417            let error = serve(&mut &request[..], &mut output).unwrap_err();
418            assert_eq!(error.kind(), io::ErrorKind::InvalidData);
419            assert!(output.is_empty());
420        }
421    }
422    #[cfg(unix)]
423    #[test]
424    fn a_reply_after_a_short_deadline_is_rejected() {
425        let child = Command::new("sleep")
426            .arg("30")
427            .stdin(Stdio::piped())
428            .stdout(Stdio::piped())
429            .spawn()
430            .unwrap();
431        let mut worker = Worker::connect(child).unwrap();
432        let (send, receive) = mpsc::sync_channel(1);
433        worker.replies = receive;
434        let delayed = thread::spawn(move || {
435            thread::sleep(Duration::from_millis(10));
436            let _ = send.send(Ok(None));
437        });
438        let error = worker
439            .render(
440                "flowchart LR; A-->B",
441                false,
442                RasterOptions::scaled(1.),
443                Duration::from_millis(1),
444                &AtomicBool::new(false),
445            )
446            .err()
447            .unwrap();
448        assert_eq!(error.kind(), io::ErrorKind::TimedOut);
449        assert!(worker.requests.is_none());
450        assert_eq!(worker.requests_completed(), 0);
451        delayed.join().unwrap();
452    }
453
454    #[cfg(unix)]
455    #[test]
456    fn timeouts_and_cancellation_kill_and_reap_partial_replies() {
457        for cancel_after in [None, Some(Duration::from_millis(30))] {
458            let child = Command::new("sh")
459                .args(["-c", "printf '\\001'; exec sleep 30"])
460                .stdin(Stdio::piped())
461                .stdout(Stdio::piped())
462                .stderr(Stdio::null())
463                .spawn()
464                .unwrap();
465            let mut worker = Worker::connect(child).unwrap();
466            let pid = worker.child.id();
467            let cancel = Arc::new(AtomicBool::new(false));
468            let cancellation = cancel_after.map(|duration| {
469                let cancel = cancel.clone();
470                thread::spawn(move || {
471                    thread::sleep(duration);
472                    cancel.store(true, Ordering::Relaxed);
473                })
474            });
475            let started = Instant::now();
476            let error = worker
477                .render(
478                    "flowchart LR; A-->B",
479                    false,
480                    RasterOptions::scaled(1.),
481                    Duration::from_millis(100),
482                    &cancel,
483                )
484                .err()
485                .unwrap();
486            assert_eq!(
487                error.kind(),
488                if cancel_after.is_some() {
489                    io::ErrorKind::Interrupted
490                } else {
491                    io::ErrorKind::TimedOut
492                }
493            );
494            assert!(worker.requests.is_none());
495            if let Some(thread) = cancellation {
496                thread.join().unwrap();
497            }
498            assert!(started.elapsed() < Duration::from_secs(3));
499            // SAFETY: signal zero only checks whether the reaped child exists.
500            assert_eq!(unsafe { libc::kill(pid as libc::pid_t, 0) }, -1);
501            assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH));
502            assert!(
503                worker
504                    .render(
505                        "flowchart LR; A-->B",
506                        false,
507                        RasterOptions::scaled(1.),
508                        Duration::from_millis(100),
509                        &AtomicBool::new(false)
510                    )
511                    .is_err()
512            );
513        }
514    }
515    /// The sandbox applies to a whole process, so `sandbox_probe` installs it in
516    /// a child run of this test binary.
517    #[cfg(target_os = "macos")]
518    #[test]
519    fn macos_sandbox_denies_file_reads_writes_network_and_processes() {
520        let file = std::env::temp_dir().join(format!("document-graphics-{}", std::process::id()));
521        std::fs::write(&file, b"private").unwrap();
522        let output = Command::new(std::env::current_exe().unwrap())
523            .args(["worker::tests::sandbox_probe", "--exact", "--ignored"])
524            .env("DOCUMENT_GRAPHICS_SANDBOX_PROBE", &file)
525            .output()
526            .unwrap();
527        let contents = std::fs::read(&file).unwrap();
528        std::fs::remove_file(&file).unwrap();
529        let report = String::from_utf8_lossy(&output.stdout);
530        assert!(
531            output.status.success() && report.contains("1 passed"),
532            "{report}{}",
533            String::from_utf8_lossy(&output.stderr)
534        );
535        assert_eq!(contents, b"private");
536    }
537    #[cfg(target_os = "macos")]
538    #[test]
539    #[ignore = "run in a child process by the macOS sandbox test"]
540    fn sandbox_probe() {
541        // Never restrict the test runner itself, only the child started above.
542        let Some(file) = std::env::var_os("DOCUMENT_GRAPHICS_SANDBOX_PROBE") else {
543            return;
544        };
545        // Each operation works before the sandbox, so failing after is its doing.
546        let spawn = || Command::new("/usr/bin/true").status();
547        assert!(std::fs::read(&file).is_ok() && spawn().is_ok());
548        assert!(std::net::TcpListener::bind("127.0.0.1:0").is_ok());
549        restrict_worker().unwrap();
550        assert!(std::fs::read(&file).is_err(), "read a file");
551        assert!(std::fs::write(&file, b"changed").is_err(), "wrote a file");
552        assert!(
553            std::net::TcpListener::bind("127.0.0.1:0").is_err(),
554            "opened a socket"
555        );
556        assert!(spawn().is_err(), "started a process");
557    }
558}