mem_isolate/
lib.rs

1//! # `mem-isolate`: *Contain memory leaks and fragmentation*
2//!
3//! It runs your function via a `fork()`, waits for the result, and returns it.
4//!
5//! This grants your code access to an exact copy of memory and state at the
6//! time just before the call, but guarantees that the function will not affect
7//! the parent process's memory footprint in any way.
8//!
9//! It forces functions to be *memory pure* (pure with respect to memory), even
10//! if they aren't.
11//!
12//! ```
13//! use mem_isolate::execute_in_isolated_process;
14//!
15//! // No heap, stack, or program memory out here...
16//! let result = mem_isolate::execute_in_isolated_process(|| {
17//!     // ...Can be affected by anything in here
18//!     Box::leak(Box::new(vec![42; 1024]));
19//! });
20//! ```
21//!
22//! To keep things simple, this crate exposes only two public interfaces:
23//!
24//! * [`execute_in_isolated_process`] - The function that executes your code in
25//!   an isolated process.
26//! * [`MemIsolateError`] - The error type that function returns ☝️
27//!
28//! For more code examples, see
29//! [`examples/`](https://github.com/brannondorsey/mem-isolate/tree/main/examples).
30//! [This
31//! one](https://github.com/brannondorsey/mem-isolate/blob/main/examples/error-handling-basic.rs)
32//! in particular shows how you should think about error handling.
33//!
34//! For more information, see the
35//! [README](https://github.com/brannondorsey/mem-isolate).
36//!
37//! ## Limitations
38//!
39//! #### Performance & Usability
40//!
41//! * Works only on POSIX systems (Linux, macOS, BSD)
42//! * Data returned from the `callable` function must be serialized to and from
43//!   the child process (using `serde`), which can be expensive for large data.
44//! * Excluding serialization/deserialization cost,
45//!   `execute_in_isolated_process()` introduces runtime overhead on the order
46//!   of ~1ms compared to a direct invocation of the `callable`.
47//!
48//! In performance-critical systems, these overheads can be no joke. However,
49//! for many use cases, this is an affordable trade-off for the memory safety
50//! and snapshotting behavior that `mem-isolate` provides.
51//!
52//! #### Safety & Correctness
53//!
54//! The use of `fork()`, which this crate uses under the hood, has a slew of
55//! potentially dangerous side effects and surprises if you're not careful.
56//!
57//! * For **single-threaded use only:** It is generally unsound to `fork()` in
58//!   multi-threaded environments, especially when mutexes are involved. Only
59//!   the thread that calls `fork()` will be cloned and live on in the new
60//!   process. This can easily lead to deadlocks and hung child processes if
61//!   other threads are holding resource locks that the child process expects to
62//!   acquire.
63//! * **Signals** delivered to the parent process won't be automatically
64//!   forwarded to the child process running your `callable` during its
65//!   execution. See one of the `examples/blocking-signals-*` files for [an
66//!   example](https://github.com/brannondorsey/mem-isolate/blob/main/examples/blocking-signals-minimal.rs)
67//!   of how to handle this.
68//! * **[Channels](https://doc.rust-lang.org/std/sync/mpsc/fn.channel.html)**
69//!   can't be used to communicate between the parent and child processes.
70//!   Consider using shared mmaps, pipes, or the filesystem instead.
71//! * **Shared mmaps** break the isolation guarantees of this crate. The child
72//!   process will be able to mutate `mmap(..., MAP_SHARED, ...)` regions
73//!   created by the parent process.
74//! * **Panics** in your `callable` won't panic the rest of your program, as
75//!   they would without `mem-isolate`. That's as useful as it is harmful,
76//!   depending on your use case, but it's worth noting.
77//! * **Mutable references, static variables, and raw pointers** accessible to
78//!   your `callable` won't be modified as you would expect them to. That's kind
79//!   of the whole point of this crate... ;)
80//!
81//! Failing to understand or respect these limitations will make your code more
82//! susceptible to both undefined behavior (UB) and heap corruption, not less.
83//!
84//! ## Feature Flags
85//!
86//! The following crate [feature
87//! flags](https://doc.rust-lang.org/cargo/reference/manifest.html#the-features-section)
88//! are available:
89//!
90//! * `tracing`: Enable [tracing](https://docs.rs/tracing) instrumentation.
91//!   Instruments all high-level functions in
92//!   [`lib.rs`](https://github.com/brannondorsey/mem-isolate/blob/main/src/lib.rs)
93//!   and creates spans for child and parent processes in
94//!   [`execute_in_isolated_process`]. Events are mostly `debug!` and `error!`
95//!   level. See
96//!   [`examples/tracing.rs`](https://github.com/brannondorsey/mem-isolate/blob/main/examples/tracing.rs)
97//!   for an example.
98//!
99//! By default, no additional features are enabled.
100#![warn(missing_docs)]
101#![warn(clippy::pedantic, clippy::unwrap_used)]
102#![warn(missing_debug_implementations)]
103
104#[cfg(not(any(target_family = "unix")))]
105compile_error!(
106    "Because of its heavy use of POSIX system calls, this crate only supports Unix-like operating systems (e.g. Linux, macOS, BSD)"
107);
108
109mod macros;
110use macros::{debug, error};
111#[cfg(feature = "tracing")]
112// Don't import event macros like debug, error, etc. directly to avoid conflicts
113// with our macros (see just above^)
114use tracing::{Level, instrument, span};
115
116use libc::{EINTR, c_int};
117use std::fmt::Debug;
118use std::fs::File;
119use std::io::{Read, Write};
120use std::os::unix::io::FromRawFd;
121
122#[cfg(test)]
123mod tests;
124
125mod c;
126use c::{
127    ForkReturn, PipeFds, SystemFunctions, WaitpidStatus, child_process_exited_on_its_own,
128    child_process_killed_by_signal,
129};
130
131pub mod errors;
132pub use errors::MemIsolateError;
133use errors::{
134    CallableDidNotExecuteError::{ChildPipeCloseFailed, ForkFailed, PipeCreationFailed},
135    CallableExecutedError::{ChildPipeWriteFailed, DeserializationFailed, SerializationFailed},
136    CallableStatusUnknownError::{
137        CallableProcessDiedDuringExecution, ChildProcessKilledBySignal, ParentPipeCloseFailed,
138        ParentPipeReadFailed, UnexpectedChildExitStatus, UnexpectedWaitpidReturnValue, WaitFailed,
139    },
140};
141
142use MemIsolateError::{CallableDidNotExecute, CallableExecuted, CallableStatusUnknown};
143
144// Re-export the serde traits our public API depends on
145pub use serde::{Serialize, de::DeserializeOwned};
146
147// Child process exit status codes
148const CHILD_EXIT_HAPPY: i32 = 0;
149const CHILD_EXIT_IF_READ_CLOSE_FAILED: i32 = 3;
150const CHILD_EXIT_IF_WRITE_FAILED: i32 = 4;
151
152#[cfg(feature = "tracing")]
153const HIGHEST_LEVEL: Level = Level::ERROR;
154
155/// Executes a user-supplied `callable` in a forked child process so that any
156/// memory changes during execution do not affect the parent. The child
157/// serializes its result (using bincode) and writes it through a pipe, which
158/// the parent reads and deserializes.
159///
160/// # Example
161///
162/// ```rust
163/// use mem_isolate::execute_in_isolated_process;
164///
165/// let leaky_fn = || {
166///     // Leak 1KiB of memory
167///     let data: Vec<u8> = Vec::with_capacity(1024);
168///     let data = Box::new(data);
169///     Box::leak(data);
170/// };
171///
172/// let _ = execute_in_isolated_process(leaky_fn);
173/// // However, the memory is not leaked in the parent process here
174/// ```
175///
176/// # Errors
177///
178/// Error handling is organized into three levels:
179///
180/// 1. The first level describes the effect of the error on the `callable` (e.g.
181///    did your callable function execute or not)
182/// 2. The second level describes what `mem-isolate` operation caused the error
183///    (e.g. did serialization fail)
184/// 3. The third level is the underlying OS error if it is available (e.g. an
185///    `io::Error`)
186///
187/// For most applications, you'll care only about the first level:
188///
189/// ```rust
190/// use mem_isolate::{execute_in_isolated_process, MemIsolateError};
191///
192/// // Function that might cause memory issues
193/// let result = execute_in_isolated_process(|| {
194///     // Some operation
195///     "Success!".to_string()
196/// });
197///
198/// match result {
199///     Ok(value) => println!("Callable succeeded: {}", value),
200///     Err(MemIsolateError::CallableDidNotExecute(_)) => {
201///         // Safe to retry, callable never executed
202///         println!("Callable did not execute, can safely retry");
203///     },
204///     Err(MemIsolateError::CallableExecuted(_)) => {
205///         // Do not retry unless idempotent
206///         println!("Callable executed but result couldn't be returned");
207///     },
208///     Err(MemIsolateError::CallableStatusUnknown(_)) => {
209///         // Retry only if idempotent
210///         println!("Unknown if callable executed, retry only if idempotent");
211///     }
212/// }
213/// ```
214///
215/// For a more detailed look at error handling, see the documentation in the
216/// [`errors`] module.
217///
218/// # Important Note on Closures
219///
220/// When using closures that capture and mutate variables from their
221/// environment, these mutations **only occur in the isolated child process**
222/// and do not affect the parent process's memory. For example, it may seem
223/// surprising that the following code will leave the parent's `counter`
224/// variable unchanged:
225///
226/// ```rust
227/// use mem_isolate::execute_in_isolated_process;
228///
229/// let mut counter = 0;
230/// let result = execute_in_isolated_process(|| {
231///     counter += 1;  // This increment only happens in the child process
232///     counter        // Returns 1
233/// });
234/// assert_eq!(counter, 0);  // Parent's counter remains unchanged
235/// ```
236///
237/// This is the intended behavior as the function's purpose is to isolate all
238/// memory effects of the callable. However, this can be surprising, especially
239/// for [`FnMut`] or [`FnOnce`] closures.
240///
241/// # Limitations
242///
243/// #### Performance & Usability
244///
245/// * Works only on POSIX systems (Linux, macOS, BSD)
246/// * Data returned from the `callable` function must be serialized to and from
247///   the child process (using `serde`), which can be expensive for large data.
248/// * Excluding serialization/deserialization cost,
249///   `execute_in_isolated_process()` introduces runtime overhead on the order
250///   of ~1ms compared to a direct invocation of the `callable`.
251///
252/// In performance-critical systems, these overheads can be no joke. However,
253/// for many use cases, this is an affordable trade-off for the memory safety
254/// and snapshotting behavior that `mem-isolate` provides.
255///
256/// #### Safety & Correctness
257///
258/// The use of `fork()`, which this crate uses under the hood, has a slew of
259/// potentially dangerous side effects and surprises if you're not careful.
260///
261/// * For **single-threaded use only:** It is generally unsound to `fork()` in
262///   multi-threaded environments, especially when mutexes are involved. Only
263///   the thread that calls `fork()` will be cloned and live on in the new
264///   process. This can easily lead to deadlocks and hung child processes if
265///   other threads are holding resource locks that the child process expects to
266///   acquire.
267/// * **Signals** delivered to the parent process won't be automatically
268///   forwarded to the child process running your `callable` during its
269///   execution. See one of the `examples/blocking-signals-*` files for [an
270///   example](https://github.com/brannondorsey/mem-isolate/blob/main/) of how
271///   to handle this.
272/// * **[Channels](https://doc.rust-lang.org/std/sync/mpsc/fn.channel.html)**
273///   can't be used to communicate between the parent and child processes.
274///   Consider using shared mmaps, pipes, or the filesystem instead.
275/// * **Shared mmaps** break the isolation guarantees of this crate. The child
276///   process will be able to mutate `mmap(..., MAP_SHARED, ...)` regions
277///   created by the parent process.
278/// * **Panics** in your `callable` won't panic the rest of your program, as
279///   they would without `mem-isolate`. That's as useful as it is harmful,
280///   depending on your use case, but it's worth noting.
281/// * **Mutable references, static variables, and raw pointers** accessible to
282///   your `callable` won't be modified as you would expect them to. That's kind
283///   of the whole point of this crate... ;)
284///
285/// Failing to understand or respect these limitations will make your code more
286/// susceptible to both undefined behavior (UB) and heap corruption, not less.
287#[cfg_attr(feature = "tracing", instrument(skip(callable)))]
288pub fn execute_in_isolated_process<F, T>(callable: F) -> Result<T, MemIsolateError>
289where
290    F: FnOnce() -> T,
291    T: Serialize + DeserializeOwned,
292{
293    #[cfg(feature = "tracing")]
294    let parent_span = span!(HIGHEST_LEVEL, "parent").entered();
295
296    let sys = get_system_functions();
297    let PipeFds { read_fd, write_fd } = create_pipe(&sys)?;
298
299    match fork(&sys)? {
300        ForkReturn::Child => {
301            #[cfg(feature = "tracing")]
302            let _child_span = {
303                std::mem::drop(parent_span);
304                span!(HIGHEST_LEVEL, "child").entered()
305            };
306            // NOTE: Fallible actions in the child must either serialize
307            // and send their error over the pipe, or exit with a code
308            // that can be inerpreted by the parent.
309            // TODO: Consider removing the serializations and just
310            // using exit codes as the only way to communicate errors.
311            // TODO: Get rid of all of the .expect()s
312
313            let mut writer = unsafe { File::from_raw_fd(write_fd) };
314            close_read_end_of_pipe_in_child_or_exit(&sys, &mut writer, read_fd);
315
316            let result = execute_callable(callable);
317            let encoded = serialize_result_or_error_value(result);
318            write_and_flush_or_exit(&sys, &mut writer, &encoded);
319            exit_happy(&sys)
320        }
321        ForkReturn::Parent(child_pid) => {
322            close_write_end_of_pipe_in_parent(&sys, write_fd)?;
323
324            let waitpid_bespoke_status = wait_for_child(&sys, child_pid)?;
325            error_if_child_unhappy(waitpid_bespoke_status)?;
326
327            let buffer: Vec<u8> = read_all_of_child_result_pipe(read_fd)?;
328            deserialize_result(&buffer)
329        }
330    }
331}
332
333#[must_use]
334#[cfg_attr(feature = "tracing", instrument)]
335fn get_system_functions() -> impl SystemFunctions {
336    // Use the appropriate implementation based on build config
337    #[cfg(not(test))]
338    let sys = c::RealSystemFunctions;
339
340    #[cfg(test)]
341    let sys = if c::mock::is_mocking_enabled() {
342        // Use the mock from thread-local storage
343        c::mock::get_current_mock()
344    } else {
345        // Create a new fallback mock if no mock is active
346        c::mock::MockableSystemFunctions::with_fallback()
347    };
348
349    debug!("using {:?}", sys);
350    sys
351}
352
353#[cfg_attr(feature = "tracing", instrument)]
354fn create_pipe<S: SystemFunctions>(sys: &S) -> Result<PipeFds, MemIsolateError> {
355    let pipe_fds = match sys.pipe() {
356        Ok(pipe_fds) => pipe_fds,
357        Err(err) => {
358            let err = CallableDidNotExecute(PipeCreationFailed(err));
359            error!("error creating pipe, propagating {:?}", err);
360            return Err(err);
361        }
362    };
363    debug!("pipe created: {:?}", pipe_fds);
364    Ok(pipe_fds)
365}
366
367#[cfg_attr(feature = "tracing", instrument)]
368fn fork<S: SystemFunctions>(sys: &S) -> Result<ForkReturn, MemIsolateError> {
369    match sys.fork() {
370        Ok(result) => Ok(result),
371        Err(err) => {
372            let err = CallableDidNotExecute(ForkFailed(err));
373            error!("error forking, propagating {:?}", err);
374            Err(err)
375        }
376    }
377}
378
379#[cfg_attr(feature = "tracing", instrument(skip(callable)))]
380fn execute_callable<F, T>(callable: F) -> T
381where
382    F: FnOnce() -> T,
383{
384    debug!("starting execution of user-supplied callable");
385    #[allow(clippy::let_and_return)]
386    let result = {
387        #[cfg(feature = "tracing")]
388        let _span = span!(HIGHEST_LEVEL, "inside_callable").entered();
389        callable()
390    };
391    debug!("finished execution of user-supplied callable");
392    result
393}
394
395#[cfg_attr(feature = "tracing", instrument)]
396fn wait_for_child<S: SystemFunctions>(
397    sys: &S,
398    child_pid: c_int,
399) -> Result<WaitpidStatus, MemIsolateError> {
400    debug!("waiting for child process");
401    let waitpid_bespoke_status = loop {
402        match sys.waitpid(child_pid) {
403            Ok(status) => break status,
404            Err(wait_err) => {
405                if wait_err.raw_os_error() == Some(EINTR) {
406                    debug!("waitpid interrupted with EINTR, retrying");
407                    continue;
408                }
409                let err = CallableStatusUnknown(WaitFailed(wait_err));
410                error!("error waiting for child process, propagating {:?}", err);
411                return Err(err);
412            }
413        }
414    };
415
416    debug!(
417        "wait completed, received status: {:?}",
418        waitpid_bespoke_status
419    );
420    Ok(waitpid_bespoke_status)
421}
422
423#[cfg_attr(feature = "tracing", instrument)]
424fn error_if_child_unhappy(waitpid_bespoke_status: WaitpidStatus) -> Result<(), MemIsolateError> {
425    let result = if let Some(exit_status) = child_process_exited_on_its_own(waitpid_bespoke_status)
426    {
427        match exit_status {
428            CHILD_EXIT_HAPPY => Ok(()),
429            CHILD_EXIT_IF_READ_CLOSE_FAILED => {
430                Err(CallableDidNotExecute(ChildPipeCloseFailed(None)))
431            }
432            CHILD_EXIT_IF_WRITE_FAILED => Err(CallableExecuted(ChildPipeWriteFailed(None))),
433            unhandled_status => Err(CallableStatusUnknown(UnexpectedChildExitStatus(
434                unhandled_status,
435            ))),
436        }
437    } else if let Some(signal) = child_process_killed_by_signal(waitpid_bespoke_status) {
438        Err(CallableStatusUnknown(ChildProcessKilledBySignal(signal)))
439    } else {
440        Err(CallableStatusUnknown(UnexpectedWaitpidReturnValue(
441            waitpid_bespoke_status,
442        )))
443    };
444
445    if let Ok(()) = result {
446        debug!("child process exited happily on its own");
447    } else {
448        error!("child process signaled an error, propagating {:?}", result);
449    }
450
451    result
452}
453
454#[cfg_attr(feature = "tracing", instrument)]
455fn deserialize_result<T: DeserializeOwned>(buffer: &[u8]) -> Result<T, MemIsolateError> {
456    match bincode::deserialize::<Result<T, MemIsolateError>>(buffer) {
457        Ok(Ok(result)) => {
458            debug!("successfully deserialized happy result");
459            Ok(result)
460        }
461        Ok(Err(err)) => {
462            debug!("successfully deserialized error result: {:?}", err);
463            Err(err)
464        }
465        Err(err) => {
466            let err = CallableExecuted(DeserializationFailed(err.to_string()));
467            error!("failed to deserialize result, propagating {:?}", err);
468            Err(err)
469        }
470    }
471}
472
473/// Doesn't matter if the value is an error or not, we just want to serialize it either way
474///
475/// # Panics
476///
477/// Panics if the serialization of a [`MemIsolateError`] fails
478#[cfg_attr(feature = "tracing", instrument(skip(result)))]
479fn serialize_result_or_error_value<T: Serialize>(result: T) -> Vec<u8> {
480    match bincode::serialize(&Ok::<T, MemIsolateError>(result)) {
481        Ok(encoded) => {
482            debug!(
483                "serialization successful, resulted in {} bytes",
484                encoded.len()
485            );
486            encoded
487        }
488        Err(err) => {
489            let err = CallableExecuted(SerializationFailed(err.to_string()));
490            error!(
491                "serialization failed, now attempting to serialize error: {:?}",
492                err
493            );
494            #[allow(clippy::let_and_return)]
495            let encoded = bincode::serialize(&Err::<T, MemIsolateError>(err))
496                .expect("failed to serialize error");
497            debug!(
498                "serialization of error successful, resulting in {} bytes",
499                encoded.len()
500            );
501            encoded
502        }
503    }
504}
505
506#[cfg_attr(feature = "tracing", instrument)]
507fn write_and_flush_or_exit<S, W>(sys: &S, writer: &mut W, buffer: &[u8])
508where
509    S: SystemFunctions,
510    W: Write + Debug,
511{
512    let result = writer.write_all(buffer).and_then(|()| writer.flush());
513    #[allow(unused_variables)]
514    if let Err(err) = result {
515        error!("error writing to pipe: {:?}", err);
516        // If we can't write to the pipe, we can't communicate the error either
517        // so we rely on the parent correctly interpreting the exit code
518        let exit_code = CHILD_EXIT_IF_WRITE_FAILED;
519        debug!("exiting child process with exit code: {}", exit_code);
520        #[allow(clippy::used_underscore_items)]
521        sys._exit(exit_code);
522    } else {
523        debug!("wrote and flushed to pipe successfully");
524    }
525}
526
527fn exit_happy<S: SystemFunctions>(sys: &S) -> ! {
528    // NOTE: We don't wrap this in #[cfg_attr(feature = "tracing", instrument)]
529    // because doing so results in a compiler error because of the `!` return type
530    // No idea why its usage is fine without the cfg_addr...
531    #[cfg(feature = "tracing")]
532    let _span = {
533        const FN_NAME: &str = stringify!(exit_happy);
534        span!(HIGHEST_LEVEL, FN_NAME).entered()
535    };
536
537    let exit_code = CHILD_EXIT_HAPPY;
538    debug!("exiting child process with exit code: {}", exit_code);
539
540    #[allow(clippy::used_underscore_items)]
541    sys._exit(exit_code);
542}
543
544#[cfg_attr(feature = "tracing", instrument)]
545fn read_all_of_child_result_pipe(read_fd: c_int) -> Result<Vec<u8>, MemIsolateError> {
546    // Read from the pipe by wrapping the read fd as a File
547    let mut buffer = Vec::new();
548    {
549        let mut reader = unsafe { File::from_raw_fd(read_fd) };
550        if let Err(err) = reader.read_to_end(&mut buffer) {
551            let err = CallableStatusUnknown(ParentPipeReadFailed(err));
552            error!("error reading from pipe, propagating {:?}", err);
553            return Err(err);
554        }
555    } // The read_fd will automatically be closed when the File is dropped
556
557    if buffer.is_empty() {
558        // TODO: How can we more rigorously know this? Maybe we write to a mem map before and after execution?
559        let err = CallableStatusUnknown(CallableProcessDiedDuringExecution);
560        error!("buffer unexpectedly empty, propagating {:?}", err);
561        return Err(err);
562    }
563
564    debug!("successfully read {} bytes from pipe", buffer.len());
565    Ok(buffer)
566}
567
568#[cfg_attr(feature = "tracing", instrument)]
569fn close_write_end_of_pipe_in_parent<S: SystemFunctions>(
570    sys: &S,
571    write_fd: c_int,
572) -> Result<(), MemIsolateError> {
573    if let Err(err) = sys.close(write_fd) {
574        let err = CallableStatusUnknown(ParentPipeCloseFailed(err));
575        error!("error closing write end of pipe, propagating {:?}", err);
576        return Err(err);
577    }
578    debug!("write end of pipe closed successfully");
579    Ok(())
580}
581
582#[cfg_attr(feature = "tracing", instrument)]
583fn close_read_end_of_pipe_in_child_or_exit<S: SystemFunctions>(
584    sys: &S,
585    writer: &mut (impl Write + Debug),
586    read_fd: c_int,
587) {
588    if let Err(close_err) = sys.close(read_fd) {
589        let err = CallableDidNotExecute(ChildPipeCloseFailed(Some(close_err)));
590        error!(
591            "error closing read end of pipe, now attempting to serialize error: {:?}",
592            err
593        );
594
595        let encoded = bincode::serialize(&err).expect("failed to serialize error");
596        writer
597            .write_all(&encoded)
598            .expect("failed to write error to pipe");
599        writer.flush().expect("failed to flush error to pipe");
600
601        let exit_code = CHILD_EXIT_IF_READ_CLOSE_FAILED;
602        error!("exiting child process with exit code: {}", exit_code);
603        #[allow(clippy::used_underscore_items)]
604        sys._exit(exit_code);
605    } else {
606        debug!("read end of pipe closed successfully");
607    }
608}