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
307            #[cfg(test)]
308            // Disable mocking in the child process, fixes test failures on macOS
309            c::mock::disable_mocking();
310
311            // NOTE: Fallible actions in the child must either serialize
312            // and send their error over the pipe, or exit with a code
313            // that can be inerpreted by the parent.
314            // TODO: Consider removing the serializations and just
315            // using exit codes as the only way to communicate errors.
316            // TODO: Get rid of all of the .expect()s
317
318            let mut writer = unsafe { File::from_raw_fd(write_fd) };
319            close_read_end_of_pipe_in_child_or_exit(&sys, &mut writer, read_fd);
320
321            let result = execute_callable(callable);
322            let encoded = serialize_result_or_error_value(result);
323            write_and_flush_or_exit(&sys, &mut writer, &encoded);
324            exit_happy(&sys)
325        }
326        ForkReturn::Parent(child_pid) => {
327            close_write_end_of_pipe_in_parent(&sys, write_fd)?;
328
329            // Read the data from the pipe before waiting for the child to exit
330            // to prevent deadlocks. Don't bubble up errors before the waitpid
331            // to avoid creating zombie processes.
332            let pipe_result = read_all_of_child_result_pipe(read_fd);
333
334            let waitpid_bespoke_status = wait_for_child(&sys, child_pid)?;
335            error_if_child_unhappy(waitpid_bespoke_status)?;
336
337            // Defer the buffer emptiness check until after the waitpid to preserve
338            // the behavior that child processes that panic will result in a
339            // CallableProcessDiedDuringExecution error.
340            let buffer = pipe_result?;
341            error_if_buffer_is_empty(&buffer)?;
342            deserialize_result(&buffer)
343        }
344    }
345}
346
347#[must_use]
348#[cfg_attr(feature = "tracing", instrument)]
349fn get_system_functions() -> impl SystemFunctions {
350    // Use the appropriate implementation based on build config
351    #[cfg(not(test))]
352    let sys = c::RealSystemFunctions;
353
354    #[cfg(test)]
355    let sys = if c::mock::is_mocking_enabled() {
356        // Use the mock from thread-local storage
357        c::mock::get_current_mock()
358    } else {
359        // Create a new fallback mock if no mock is active
360        c::mock::MockableSystemFunctions::with_fallback()
361    };
362
363    debug!("using {:?}", sys);
364    sys
365}
366
367#[cfg_attr(feature = "tracing", instrument)]
368fn create_pipe<S: SystemFunctions>(sys: &S) -> Result<PipeFds, MemIsolateError> {
369    let pipe_fds = match sys.pipe() {
370        Ok(pipe_fds) => pipe_fds,
371        Err(err) => {
372            let err = CallableDidNotExecute(PipeCreationFailed(err));
373            error!("error creating pipe, propagating {:?}", err);
374            return Err(err);
375        }
376    };
377    debug!("pipe created: {:?}", pipe_fds);
378    Ok(pipe_fds)
379}
380
381#[cfg_attr(feature = "tracing", instrument)]
382fn fork<S: SystemFunctions>(sys: &S) -> Result<ForkReturn, MemIsolateError> {
383    match sys.fork() {
384        Ok(result) => Ok(result),
385        Err(err) => {
386            let err = CallableDidNotExecute(ForkFailed(err));
387            error!("error forking, propagating {:?}", err);
388            Err(err)
389        }
390    }
391}
392
393#[cfg_attr(feature = "tracing", instrument(skip(callable)))]
394fn execute_callable<F, T>(callable: F) -> T
395where
396    F: FnOnce() -> T,
397{
398    debug!("starting execution of user-supplied callable");
399    #[allow(clippy::let_and_return)]
400    let result = {
401        #[cfg(feature = "tracing")]
402        let _span = span!(HIGHEST_LEVEL, "inside_callable").entered();
403        callable()
404    };
405    debug!("finished execution of user-supplied callable");
406    result
407}
408
409#[cfg_attr(feature = "tracing", instrument)]
410fn wait_for_child<S: SystemFunctions>(
411    sys: &S,
412    child_pid: c_int,
413) -> Result<WaitpidStatus, MemIsolateError> {
414    debug!("waiting for child process");
415    let waitpid_bespoke_status = loop {
416        match sys.waitpid(child_pid) {
417            Ok(status) => break status,
418            Err(wait_err) => {
419                if wait_err.raw_os_error() == Some(EINTR) {
420                    debug!("waitpid interrupted with EINTR, retrying");
421                    continue;
422                }
423                let err = CallableStatusUnknown(WaitFailed(wait_err));
424                error!("error waiting for child process, propagating {:?}", err);
425                return Err(err);
426            }
427        }
428    };
429
430    debug!(
431        "wait completed, received status: {:?}",
432        waitpid_bespoke_status
433    );
434    Ok(waitpid_bespoke_status)
435}
436
437#[cfg_attr(feature = "tracing", instrument)]
438fn error_if_child_unhappy(waitpid_bespoke_status: WaitpidStatus) -> Result<(), MemIsolateError> {
439    let result = if let Some(exit_status) = child_process_exited_on_its_own(waitpid_bespoke_status)
440    {
441        debug!("child process exited with status: {}", exit_status);
442        match exit_status {
443            CHILD_EXIT_HAPPY => Ok(()),
444            CHILD_EXIT_IF_READ_CLOSE_FAILED => {
445                Err(CallableDidNotExecute(ChildPipeCloseFailed(None)))
446            }
447            CHILD_EXIT_IF_WRITE_FAILED => Err(CallableExecuted(ChildPipeWriteFailed(None))),
448            unhandled_status => Err(CallableStatusUnknown(UnexpectedChildExitStatus(
449                unhandled_status,
450            ))),
451        }
452    } else if let Some(signal) = child_process_killed_by_signal(waitpid_bespoke_status) {
453        Err(CallableStatusUnknown(ChildProcessKilledBySignal(signal)))
454    } else {
455        Err(CallableStatusUnknown(UnexpectedWaitpidReturnValue(
456            waitpid_bespoke_status,
457        )))
458    };
459
460    if let Ok(()) = result {
461        debug!("child process exited happily on its own");
462    } else {
463        error!("child process signaled an error, propagating {:?}", result);
464    }
465
466    result
467}
468
469#[cfg_attr(feature = "tracing", instrument)]
470fn deserialize_result<T: DeserializeOwned>(buffer: &[u8]) -> Result<T, MemIsolateError> {
471    match bincode::deserialize::<Result<T, MemIsolateError>>(buffer) {
472        Ok(Ok(result)) => {
473            debug!("successfully deserialized happy result");
474            Ok(result)
475        }
476        Ok(Err(err)) => {
477            debug!("successfully deserialized error result: {:?}", err);
478            Err(err)
479        }
480        Err(err) => {
481            let err = CallableExecuted(DeserializationFailed(err.to_string()));
482            error!("failed to deserialize result, propagating {:?}", err);
483            Err(err)
484        }
485    }
486}
487
488/// Doesn't matter if the value is an error or not, we just want to serialize it either way
489///
490/// # Panics
491///
492/// Panics if the serialization of a [`MemIsolateError`] fails
493#[cfg_attr(feature = "tracing", instrument(skip(result)))]
494fn serialize_result_or_error_value<T: Serialize>(result: T) -> Vec<u8> {
495    match bincode::serialize(&Ok::<T, MemIsolateError>(result)) {
496        Ok(encoded) => {
497            debug!(
498                "serialization successful, resulted in {} bytes",
499                encoded.len()
500            );
501            encoded
502        }
503        Err(err) => {
504            let err = CallableExecuted(SerializationFailed(err.to_string()));
505            error!(
506                "serialization failed, now attempting to serialize error: {:?}",
507                err
508            );
509            #[allow(clippy::let_and_return)]
510            let encoded = bincode::serialize(&Err::<T, MemIsolateError>(err))
511                .expect("failed to serialize error");
512            debug!(
513                "serialization of error successful, resulting in {} bytes",
514                encoded.len()
515            );
516            encoded
517        }
518    }
519}
520
521#[cfg_attr(feature = "tracing", instrument)]
522fn write_and_flush_or_exit<S, W>(sys: &S, writer: &mut W, buffer: &[u8])
523where
524    S: SystemFunctions,
525    W: Write + Debug,
526{
527    let result = writer.write_all(buffer).and_then(|()| writer.flush());
528    #[allow(unused_variables)]
529    if let Err(err) = result {
530        error!("error writing to pipe: {:?}", err);
531        // If we can't write to the pipe, we can't communicate the error either
532        // so we rely on the parent correctly interpreting the exit code
533        let exit_code = CHILD_EXIT_IF_WRITE_FAILED;
534        debug!("exiting child process with exit code: {}", exit_code);
535        #[allow(clippy::used_underscore_items)]
536        sys._exit(exit_code);
537    } else {
538        debug!("wrote and flushed to pipe successfully");
539    }
540}
541
542fn exit_happy<S: SystemFunctions>(sys: &S) -> ! {
543    // NOTE: We don't wrap this in #[cfg_attr(feature = "tracing", instrument)]
544    // because doing so results in a compiler error because of the `!` return type
545    // No idea why its usage is fine without the cfg_addr...
546    #[cfg(feature = "tracing")]
547    let _span = {
548        const FN_NAME: &str = stringify!(exit_happy);
549        span!(HIGHEST_LEVEL, FN_NAME).entered()
550    };
551
552    let exit_code = CHILD_EXIT_HAPPY;
553    debug!("exiting child process with exit code: {}", exit_code);
554
555    #[allow(clippy::used_underscore_items)]
556    sys._exit(exit_code);
557}
558
559#[cfg_attr(feature = "tracing", instrument)]
560fn read_all_of_child_result_pipe(read_fd: c_int) -> Result<Vec<u8>, MemIsolateError> {
561    // Read from the pipe by wrapping the read fd as a File
562    let mut buffer = Vec::new();
563    {
564        let mut reader = unsafe { File::from_raw_fd(read_fd) };
565        if let Err(err) = reader.read_to_end(&mut buffer) {
566            let err = CallableStatusUnknown(ParentPipeReadFailed(err));
567            error!("error reading from pipe, propagating {:?}", err);
568            return Err(err);
569        }
570    } // The read_fd will automatically be closed when the File is dropped
571    debug!("successfully read {} bytes from pipe", buffer.len());
572    Ok(buffer)
573}
574
575#[cfg_attr(feature = "tracing", instrument)]
576fn error_if_buffer_is_empty(buffer: &[u8]) -> Result<(), MemIsolateError> {
577    if buffer.is_empty() {
578        let err = CallableStatusUnknown(CallableProcessDiedDuringExecution);
579        error!("buffer unexpectedly empty, propagating {:?}", err);
580        return Err(err);
581    }
582    Ok(())
583}
584
585#[cfg_attr(feature = "tracing", instrument)]
586fn close_write_end_of_pipe_in_parent<S: SystemFunctions>(
587    sys: &S,
588    write_fd: c_int,
589) -> Result<(), MemIsolateError> {
590    if let Err(err) = sys.close(write_fd) {
591        let err = CallableStatusUnknown(ParentPipeCloseFailed(err));
592        error!("error closing write end of pipe, propagating {:?}", err);
593        return Err(err);
594    }
595    debug!("write end of pipe closed successfully");
596    Ok(())
597}
598
599#[cfg_attr(feature = "tracing", instrument)]
600fn close_read_end_of_pipe_in_child_or_exit<S: SystemFunctions>(
601    sys: &S,
602    writer: &mut (impl Write + Debug),
603    read_fd: c_int,
604) {
605    if let Err(close_err) = sys.close(read_fd) {
606        let err = CallableDidNotExecute(ChildPipeCloseFailed(Some(close_err)));
607        error!(
608            "error closing read end of pipe, now attempting to serialize error: {:?}",
609            err
610        );
611
612        let encoded = bincode::serialize(&err).expect("failed to serialize error");
613        writer
614            .write_all(&encoded)
615            .expect("failed to write error to pipe");
616        writer.flush().expect("failed to flush error to pipe");
617
618        let exit_code = CHILD_EXIT_IF_READ_CLOSE_FAILED;
619        error!("exiting child process with exit code: {}", exit_code);
620        #[allow(clippy::used_underscore_items)]
621        sys._exit(exit_code);
622    } else {
623        debug!("read end of pipe closed successfully");
624    }
625}