execute_in_isolated_process

Function execute_in_isolated_process 

Source
pub fn execute_in_isolated_process<F, T>(
    callable: F,
) -> Result<T, MemIsolateError>
where F: FnOnce() -> T, T: Serialize + DeserializeOwned,
Expand description

Executes a user-supplied callable in a forked child process so that any memory changes during execution do not affect the parent. The child serializes its result (using bincode) and writes it through a pipe, which the parent reads and deserializes.

§Example

use mem_isolate::execute_in_isolated_process;

let leaky_fn = || {
    // Leak 1KiB of memory
    let data: Vec<u8> = Vec::with_capacity(1024);
    let data = Box::new(data);
    Box::leak(data);
};

let _ = execute_in_isolated_process(leaky_fn);
// However, the memory is not leaked in the parent process here

§Errors

Error handling is organized into three levels:

  1. The first level describes the effect of the error on the callable (e.g. did your callable function execute or not)
  2. The second level describes what mem-isolate operation caused the error (e.g. did serialization fail)
  3. The third level is the underlying OS error if it is available (e.g. an io::Error)

For most applications, you’ll care only about the first level:

use mem_isolate::{execute_in_isolated_process, MemIsolateError};

// Function that might cause memory issues
let result = execute_in_isolated_process(|| {
    // Some operation
    "Success!".to_string()
});

match result {
    Ok(value) => println!("Callable succeeded: {}", value),
    Err(MemIsolateError::CallableDidNotExecute(_)) => {
        // Safe to retry, callable never executed
        println!("Callable did not execute, can safely retry");
    },
    Err(MemIsolateError::CallableExecuted(_)) => {
        // Do not retry unless idempotent
        println!("Callable executed but result couldn't be returned");
    },
    Err(MemIsolateError::CallableStatusUnknown(_)) => {
        // Retry only if idempotent
        println!("Unknown if callable executed, retry only if idempotent");
    }
}

For a more detailed look at error handling, see the documentation in the errors module.

§Important Note on Closures

When using closures that capture and mutate variables from their environment, these mutations only occur in the isolated child process and do not affect the parent process’s memory. For example, it may seem surprising that the following code will leave the parent’s counter variable unchanged:

use mem_isolate::execute_in_isolated_process;

let mut counter = 0;
let result = execute_in_isolated_process(|| {
    counter += 1;  // This increment only happens in the child process
    counter        // Returns 1
});
assert_eq!(counter, 0);  // Parent's counter remains unchanged

This is the intended behavior as the function’s purpose is to isolate all memory effects of the callable. However, this can be surprising, especially for FnMut or FnOnce closures.

§Limitations

§Performance & Usability
  • Works only on POSIX systems (Linux, macOS, BSD)
  • Data returned from the callable function must be serialized to and from the child process (using serde), which can be expensive for large data.
  • Excluding serialization/deserialization cost, execute_in_isolated_process() introduces runtime overhead on the order of ~1ms compared to a direct invocation of the callable.

In performance-critical systems, these overheads can be no joke. However, for many use cases, this is an affordable trade-off for the memory safety and snapshotting behavior that mem-isolate provides.

§Safety & Correctness

The use of fork(), which this crate uses under the hood, has a slew of potentially dangerous side effects and surprises if you’re not careful.

  • For single-threaded use only: It is generally unsound to fork() in multi-threaded environments, especially when mutexes are involved. Only the thread that calls fork() will be cloned and live on in the new process. This can easily lead to deadlocks and hung child processes if other threads are holding resource locks that the child process expects to acquire.
  • Signals delivered to the parent process won’t be automatically forwarded to the child process running your callable during its execution. See one of the examples/blocking-signals-* files for an example of how to handle this.
  • Channels can’t be used to communicate between the parent and child processes. Consider using shared mmaps, pipes, or the filesystem instead.
  • Shared mmaps break the isolation guarantees of this crate. The child process will be able to mutate mmap(..., MAP_SHARED, ...) regions created by the parent process.
  • Panics in your callable won’t panic the rest of your program, as they would without mem-isolate. That’s as useful as it is harmful, depending on your use case, but it’s worth noting.
  • Mutable references, static variables, and raw pointers accessible to your callable won’t be modified as you would expect them to. That’s kind of the whole point of this crate… ;)

Failing to understand or respect these limitations will make your code more susceptible to both undefined behavior (UB) and heap corruption, not less.