mem_isolate/lib.rs
1//! # `mem-isolate`: *Run unsafe code safely*
2//!
3//! It runs your function via a `fork()`, waits for the result, and
4//! returns it.
5//!
6//! This grants your code access to an exact copy of memory and state at the
7//! time just before the call, but guarantees that the function will not affect
8//! the parent process's memory footprint in any way. It forces functions to be
9//! *pure*, even if they aren't.
10//!
11//! ```
12//! use mem_isolate::execute_in_isolated_process;
13//!
14//! // No heap, stack, or program memory out here...
15//! let result = mem_isolate::execute_in_isolated_process(|| {
16//! // ...Can be affected by anything in here
17//! Box::leak(Box::new(vec![42; 1024]));
18//! });
19//! ```
20//!
21//! To keep things simple, this crate exposes only two public interfaces:
22//!
23//! * [`execute_in_isolated_process`] - The function that executes your code in
24//! an isolated process.
25//! * [`MemIsolateError`] - The error type that function returns ☝️
26//!
27//! For more code examples, see [`examples/`](https://github.com/brannondorsey/mem-isolate/tree/main/examples).
28//! [This one](https://github.com/brannondorsey/mem-isolate/blob/main/examples/error-handling-basic.rs)
29//! in particular shows how you should think about error handling.
30//!
31//! For more information, see the [README](https://github.com/brannondorsey/mem-isolate).
32//!
33//! ## Supported platforms
34//!
35//! Because of its heavy use of POSIX system calls, this crate only
36//! supports Unix-like operating systems (e.g. Linux, macOS, BSD).
37//!
38//! Windows and wasm support are not planned at this time.
39#![warn(missing_docs)]
40
41#[cfg(not(any(target_family = "unix")))]
42compile_error!(
43 "Because of its heavy use of POSIX system calls, this crate only supports Unix-like operating systems (e.g. Linux, macOS, BSD)"
44);
45
46use std::fs::File;
47use std::io::{Read, Write};
48use std::os::unix::io::FromRawFd;
49
50#[cfg(test)]
51mod tests;
52
53mod c;
54use c::{
55 ForkReturn, PipeFds, SystemFunctions, child_process_exited_on_its_own,
56 child_process_killed_by_signal,
57};
58
59pub mod errors;
60pub use errors::MemIsolateError;
61use errors::{CallableDidNotExecuteError, CallableExecutedError, CallableStatusUnknownError};
62
63// Re-export the serde traits our public API depends on
64pub use serde::{Serialize, de::DeserializeOwned};
65
66/// Executes a user-supplied `callable` in a forked child process so that any
67/// memory changes during execution do not affect the parent. The child
68/// serializes its result (using bincode) and writes it through a pipe, which
69/// the parent reads and deserializes.
70///
71/// # Example
72///
73/// ```rust
74/// use mem_isolate::execute_in_isolated_process;
75///
76/// let leaky_fn = || {
77/// // Leak 1KiB of memory
78/// let data: Vec<u8> = Vec::with_capacity(1024);
79/// let data = Box::new(data);
80/// Box::leak(data);
81/// };
82///
83/// let _ = execute_in_isolated_process(leaky_fn);
84/// // However, the memory is not leaked in the parent process here
85/// ```
86///
87/// # Error Handling
88///
89/// Error handling is organized into three levels:
90///
91/// 1. The first level describes the effect of the error on the `callable` (e.g.
92/// did your callable function execute or not)
93/// 2. The second level describes what `mem-isolate` operation caused the error
94/// (e.g. did serialization fail)
95/// 3. The third level is the underlying OS error if it is available (e.g. an
96/// `io::Error`)
97///
98/// For most applications, you'll care only about the first level:
99///
100/// ```rust
101/// use mem_isolate::{execute_in_isolated_process, MemIsolateError};
102///
103/// // Function that might cause memory issues
104/// let result = execute_in_isolated_process(|| {
105/// // Some operation
106/// "Success!".to_string()
107/// });
108///
109/// match result {
110/// Ok(value) => println!("Callable succeeded: {}", value),
111/// Err(MemIsolateError::CallableDidNotExecute(_)) => {
112/// // Safe to retry, callable never executed
113/// println!("Callable did not execute, can safely retry");
114/// },
115/// Err(MemIsolateError::CallableExecuted(_)) => {
116/// // Do not retry unless idempotent
117/// println!("Callable executed but result couldn't be returned");
118/// },
119/// Err(MemIsolateError::CallableStatusUnknown(_)) => {
120/// // Retry only if idempotent
121/// println!("Unknown if callable executed, retry only if idempotent");
122/// }
123/// }
124/// ```
125///
126/// For a more detailed look at error handling, see the documentation in the
127/// [`errors`] module.
128///
129/// ## Important Note on Closures
130///
131/// When using closures that capture and mutate variables from their environment,
132/// these mutations **only occur in the isolated child process** and do not affect
133/// the parent process's memory. For example, it may seem surprising that the
134/// following code will leave the parent's `counter` variable unchanged:
135///
136/// ```rust
137/// use mem_isolate::execute_in_isolated_process;
138///
139/// let mut counter = 0;
140/// let result = execute_in_isolated_process(|| {
141/// counter += 1; // This increment only happens in the child process
142/// counter // Returns 1
143/// });
144/// assert_eq!(counter, 0); // Parent's counter remains unchanged
145/// ```
146///
147/// This is the intended behavior as the function's purpose is to isolate all
148/// memory effects of the callable. However, this can be surprising, especially
149/// for [`FnMut`] or [`FnOnce`] closures.
150pub fn execute_in_isolated_process<F, T>(callable: F) -> Result<T, MemIsolateError>
151where
152 F: FnOnce() -> T,
153 T: Serialize + DeserializeOwned,
154{
155 use CallableDidNotExecuteError::*;
156 use CallableExecutedError::*;
157 use CallableStatusUnknownError::*;
158 use MemIsolateError::*;
159
160 // Use the appropriate implementation based on build config
161 #[cfg(not(test))]
162 let sys = c::RealSystemFunctions;
163
164 #[cfg(test)]
165 let sys = if c::mock::is_mocking_enabled() {
166 // Use the mock from thread-local storage
167 c::mock::get_current_mock()
168 } else {
169 // Create a new fallback mock if no mock is active
170 c::mock::MockableSystemFunctions::with_fallback()
171 };
172
173 // Create a pipe.
174 let PipeFds { read_fd, write_fd } = match sys.pipe() {
175 Ok(pipe_fds) => pipe_fds,
176 Err(err) => {
177 return Err(CallableDidNotExecute(PipeCreationFailed(err)));
178 }
179 };
180
181 // Statuses 3-63 are normally fair game
182 const CHILD_EXIT_HAPPY: i32 = 0;
183 const CHILD_EXIT_IF_READ_CLOSE_FAILED: i32 = 3;
184 const CHILD_EXIT_IF_WRITE_FAILED: i32 = 4;
185
186 match sys.fork() {
187 Err(err) => Err(CallableDidNotExecute(ForkFailed(err))),
188 Ok(ForkReturn::Child) => {
189 // NOTE: We chose to panic in the child if we can't communicate an error back to the parent.
190 // The parent can then interpret this an an UnexpectedChildDeath.
191
192 // Droping the writer will close the write_fd, so we take it early and explicitly to
193 // prevent use after free with two unsafe calls scattered around the child code below.
194 let mut writer = unsafe { File::from_raw_fd(write_fd) };
195
196 // Close the read end of the pipe
197 if let Err(close_err) = sys.close(read_fd) {
198 let err = CallableDidNotExecute(ChildPipeCloseFailed(Some(close_err)));
199 let encoded = bincode::serialize(&err).expect("failed to serialize error");
200 writer
201 .write_all(&encoded)
202 .expect("failed to write error to pipe");
203 writer.flush().expect("failed to flush error to pipe");
204 sys._exit(CHILD_EXIT_IF_READ_CLOSE_FAILED);
205 }
206
207 // Execute the callable and handle serialization
208 let result = callable();
209 let encoded = match bincode::serialize(&Ok::<T, MemIsolateError>(result)) {
210 Ok(encoded) => encoded,
211 Err(err) => {
212 let err = CallableExecuted(SerializationFailed(err.to_string()));
213 bincode::serialize(&Err::<T, MemIsolateError>(err))
214 .expect("failed to serialize error")
215 }
216 };
217
218 // Write the result to the pipe
219 let write_result = writer.write_all(&encoded).and_then(|_| writer.flush());
220
221 if let Err(_err) = write_result {
222 // If we can't write to the pipe, we can't communicate the error either
223 // Parent will detect this as an UnexpectedChildDeath
224 sys._exit(CHILD_EXIT_IF_WRITE_FAILED);
225 }
226
227 // Exit immediately; use _exit to avoid running atexit()/on_exit() handlers
228 // and flushing stdio buffers, which are exact clones of the parent in the child process.
229 sys._exit(CHILD_EXIT_HAPPY);
230 // The code after _exit is unreachable because _exit never returns
231 }
232 Ok(ForkReturn::Parent(child_pid)) => {
233 // Close the write end of the pipe
234 if let Err(close_err) = sys.close(write_fd) {
235 return Err(CallableStatusUnknown(ParentPipeCloseFailed(close_err)));
236 }
237
238 // Wait for the child process to exit
239 let waitpid_bespoke_status = match sys.waitpid(child_pid) {
240 Ok(status) => status,
241 Err(wait_err) => {
242 return Err(CallableStatusUnknown(WaitFailed(wait_err)));
243 }
244 };
245
246 if let Some(exit_status) = child_process_exited_on_its_own(waitpid_bespoke_status) {
247 match exit_status {
248 CHILD_EXIT_HAPPY => {}
249 CHILD_EXIT_IF_READ_CLOSE_FAILED => {
250 return Err(CallableDidNotExecute(ChildPipeCloseFailed(None)));
251 }
252 CHILD_EXIT_IF_WRITE_FAILED => {
253 return Err(CallableExecuted(ChildPipeWriteFailed(None)));
254 }
255 unhandled_status => {
256 return Err(CallableStatusUnknown(UnexpectedChildExitStatus(
257 unhandled_status,
258 )));
259 }
260 }
261 } else if let Some(signal) = child_process_killed_by_signal(waitpid_bespoke_status) {
262 return Err(CallableStatusUnknown(ChildProcessKilledBySignal(signal)));
263 } else {
264 return Err(CallableStatusUnknown(UnexpectedWaitpidReturnValue(
265 waitpid_bespoke_status,
266 )));
267 }
268
269 // Read from the pipe by wrapping the read fd as a File
270 let mut buffer = Vec::new();
271 {
272 let mut reader = unsafe { File::from_raw_fd(read_fd) };
273 if let Err(err) = reader.read_to_end(&mut buffer) {
274 return Err(CallableStatusUnknown(ParentPipeReadFailed(err)));
275 }
276 } // The read_fd will automatically be closed when the File is dropped
277
278 if buffer.is_empty() {
279 // TODO: How can we more rigorously know this? Maybe we write to a mem map before and after execution?
280 return Err(CallableStatusUnknown(CallableProcessDiedDuringExecution));
281 }
282 // Update the deserialization to handle child errors
283 match bincode::deserialize::<Result<T, MemIsolateError>>(&buffer) {
284 Ok(Ok(result)) => Ok(result),
285 Ok(Err(err)) => Err(err),
286 Err(err) => Err(CallableExecuted(DeserializationFailed(err.to_string()))),
287 }
288 }
289 }
290}