rb_sys_test_helpers/
lib.rs

1#![allow(rustdoc::bare_urls)]
2#![doc = include_str!("../readme.md")]
3mod once_cell;
4mod ruby_exception;
5mod ruby_test_executor;
6mod utils;
7
8use rb_sys::{rb_errinfo, rb_intern, rb_set_errinfo, Qnil, VALUE};
9use ruby_test_executor::global_executor;
10use std::{any::Any, error::Error, mem::MaybeUninit, panic::UnwindSafe};
11
12pub use rb_sys_test_helpers_macros::*;
13pub use ruby_exception::RubyException;
14pub use ruby_test_executor::{cleanup_ruby, setup_ruby, setup_ruby_unguarded};
15
16/// Run a given function with inside of a Ruby VM.
17///
18/// Doing this properly it is not trivial, so this function abstracts away the
19/// details. Under the hood, it ensures:
20///
21/// 1. The Ruby VM is setup and initialized once and only once.
22/// 2. All code runs on the same OS thread.
23/// 3. Exceptions are properly handled and propagated as Rust `Result<T,
24///    RubyException>` values.
25///
26/// ### Example
27///
28/// ```
29/// use rb_sys_test_helpers::with_ruby_vm;
30/// use std::ffi::CStr;
31///
32/// with_ruby_vm(|| unsafe {
33///     let mut hello = rb_sys::rb_utf8_str_new_cstr("hello \0".as_ptr() as _);
34///     rb_sys::rb_str_cat(hello, "world\0".as_ptr() as _, 5);
35///     let result = rb_sys::rb_string_value_cstr(&mut hello);
36///     let result = CStr::from_ptr(result).to_string_lossy().into_owned();
37///
38///     assert_eq!(result, "hello world");
39/// });
40/// ```
41pub fn with_ruby_vm<R, F>(f: F) -> Result<R, Box<dyn Error>>
42where
43    R: Send + 'static,
44    F: FnOnce() -> R + UnwindSafe + Send + 'static,
45{
46    global_executor().run_test(f)
47}
48
49/// A guard that enables GC stress mode and restores the previous value when dropped.
50///
51/// This is useful for testing GC-related bugs by forcing garbage collection to run
52/// more frequently. When the guard is created, it saves the current `GC.stress` value
53/// and sets it to `true`. When the guard is dropped, it restores the original value.
54///
55/// ### Example
56///
57/// ```
58/// use rb_sys_test_helpers::{GcStressGuard, with_ruby_vm};
59///
60/// with_ruby_vm(|| {
61///     // GC stress is enabled for the scope of the guard
62///     let _guard = GcStressGuard::new();
63///
64///     // Do some work that should be tested under GC stress
65///     unsafe {
66///         let _ = rb_sys::rb_utf8_str_new_cstr("test\0".as_ptr() as _);
67///     }
68///
69///     // GC stress is automatically disabled when _guard goes out of scope
70/// });
71/// ```
72pub struct GcStressGuard {
73    old_gc_stress: VALUE,
74}
75
76impl GcStressGuard {
77    /// Creates a new `GcStressGuard`, enabling GC stress mode.
78    ///
79    /// The previous value of `GC.stress` is saved and will be restored when
80    /// the guard is dropped.
81    pub fn new() -> Self {
82        unsafe {
83            let stress_intern = rb_intern("stress\0".as_ptr() as _);
84            let stress_eq_intern = rb_intern("stress=\0".as_ptr() as _);
85            let gc_module =
86                rb_sys::rb_const_get(rb_sys::rb_cObject, rb_intern("GC\0".as_ptr() as _));
87
88            let old_gc_stress = rb_sys::rb_funcall(gc_module, stress_intern, 0);
89            rb_sys::rb_funcall(gc_module, stress_eq_intern, 1, rb_sys::Qtrue);
90
91            Self { old_gc_stress }
92        }
93    }
94}
95
96impl Default for GcStressGuard {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl Drop for GcStressGuard {
103    fn drop(&mut self) {
104        unsafe {
105            let stress_eq_intern = rb_intern("stress=\0".as_ptr() as _);
106            let gc_module =
107                rb_sys::rb_const_get(rb_sys::rb_cObject, rb_intern("GC\0".as_ptr() as _));
108            rb_sys::rb_funcall(gc_module, stress_eq_intern, 1, self.old_gc_stress);
109        }
110    }
111}
112
113/// Runs a test with GC stress enabled to help find GC bugs.
114///
115/// This is a convenience function that creates a [`GcStressGuard`] for the duration
116/// of the closure. If you need more control over when GC stress is enabled/disabled,
117/// use `GcStressGuard` directly.
118///
119/// ### Example
120///
121/// ```
122/// use rb_sys_test_helpers::{with_gc_stress, with_ruby_vm};
123/// use std::ffi::CStr;
124///
125/// with_ruby_vm(|| unsafe {
126///     let hello_world = with_gc_stress(|| unsafe {
127///         let mut rstring = rb_sys::rb_utf8_str_new_cstr("hello world\0".as_ptr() as _);
128///         let result = rb_sys::rb_string_value_cstr(&mut rstring);
129///         CStr::from_ptr(result).to_string_lossy().into_owned()
130///     });
131///
132///    assert_eq!(hello_world, "hello world");
133/// });
134/// ```
135pub fn with_gc_stress<R, F>(f: F) -> R
136where
137    F: FnOnce() -> R,
138{
139    let _guard = GcStressGuard::new();
140    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
141
142    match result {
143        Ok(result) => result,
144        Err(err) => std::panic::resume_unwind(err),
145    }
146}
147
148/// Type alias for panic payloads captured by `protect()`.
149type PanicPayload = Box<dyn Any + Send + 'static>;
150
151/// The result of running a closure inside `ffi_closure`. This captures both
152/// successful results and Rust panics, allowing us to propagate panics safely
153/// across FFI boundaries.
154enum ClosureResult<T> {
155    /// The closure completed successfully with a value.
156    Ok(T),
157    /// The closure panicked with this payload.
158    Panic(PanicPayload),
159}
160
161/// Catches a Ruby exception and returns it as a `Result` (using [`rb_sys::rb_protect`]).
162///
163/// This function also safely handles Rust panics that occur inside the closure.
164/// Panics are caught before they cross the FFI boundary (which would be undefined
165/// behavior) and are re-thrown after `rb_protect` returns.
166///
167/// ### Example
168///
169/// ```
170/// use rb_sys_test_helpers::{protect, with_ruby_vm, RubyException};
171///
172/// with_ruby_vm(|| unsafe {
173///     let result: Result<&str, RubyException> = protect(|| {
174///         rb_sys::rb_raise(rb_sys::rb_eRuntimeError, "oh no\0".as_ptr() as _);
175///         "this will never be returned"
176///     });
177///
178///     assert!(result.is_err());
179///     assert!(result.unwrap_err().message().unwrap().contains("oh no"));
180/// });
181/// ```
182pub fn protect<F, T>(f: F) -> Result<T, RubyException>
183where
184    F: FnMut() -> T + std::panic::UnwindSafe,
185{
186    unsafe extern "C" fn ffi_closure<T, F: FnMut() -> T>(args: VALUE) -> VALUE {
187        let args: *mut (Option<*mut F>, *mut Option<ClosureResult<T>>) = args as _;
188        let args = *args;
189        let (mut func, outbuf) = args;
190        let func = func.take().unwrap();
191        let func = &mut *func;
192
193        // Catch panics before they cross the FFI boundary (which is UB).
194        // The panic will be re-thrown after rb_protect returns.
195        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(func));
196
197        let closure_result = match result {
198            Ok(value) => ClosureResult::Ok(value),
199            Err(panic_payload) => ClosureResult::Panic(panic_payload),
200        };
201
202        outbuf.write_volatile(Some(closure_result));
203        outbuf as _
204    }
205
206    unsafe {
207        let mut state = 0;
208        let func_ref = &Some(f) as *const _;
209        let mut outbuf: MaybeUninit<Option<ClosureResult<T>>> = MaybeUninit::new(None);
210        let args = &(Some(func_ref), outbuf.as_mut_ptr() as *mut _) as *const _ as VALUE;
211        rb_sys::rb_protect(Some(ffi_closure::<T, F>), args, &mut state);
212
213        if state == 0 {
214            match outbuf.assume_init() {
215                Some(ClosureResult::Ok(value)) => Ok(value),
216                Some(ClosureResult::Panic(payload)) => {
217                    // Re-throw the panic now that we're safely on the Rust side
218                    std::panic::resume_unwind(payload)
219                }
220                None => Err(RubyException::new(rb_errinfo())),
221            }
222        } else {
223            let err = rb_errinfo();
224            rb_set_errinfo(Qnil as _);
225            Err(RubyException::new(err))
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use rusty_fork::rusty_fork_test;
234
235    #[test]
236    fn test_protect_returns_correct_value() -> Result<(), Box<dyn Error>> {
237        let ret = with_ruby_vm(|| protect(|| "my val"))?;
238
239        assert_eq!(ret, Ok("my val"));
240
241        Ok(())
242    }
243
244    #[test]
245    fn test_protect_capture_ruby_exception() {
246        with_ruby_vm(|| unsafe {
247            let result = protect(|| {
248                rb_sys::rb_raise(rb_sys::rb_eRuntimeError, "hello world\0".as_ptr() as _);
249            });
250
251            assert!(result.is_err());
252        })
253        .unwrap();
254    }
255
256    rusty_fork_test! {
257        #[test]
258        fn test_protect_propagates_rust_panic_with_readable_output() {
259            use std::panic;
260
261            let result = with_ruby_vm(|| {
262                panic::catch_unwind(panic::AssertUnwindSafe(|| {
263                    protect(|| {
264                        panic!("this is a test panic message that should be visible");
265                    })
266                }))
267            });
268
269            // The test should complete (not abort) and the panic should be caught
270            let outer_result = result.expect("with_ruby_vm should not fail");
271
272            // The panic should have been propagated, not swallowed or aborted
273            assert!(outer_result.is_err(), "panic should have been caught by catch_unwind");
274
275            // Try to extract the panic message
276            let panic_payload = outer_result.unwrap_err();
277            let panic_msg = panic_payload
278                .downcast_ref::<&str>()
279                .map(|s| s.to_string())
280                .or_else(|| panic_payload.downcast_ref::<String>().cloned())
281                .unwrap_or_else(|| "unknown panic".to_string());
282
283            assert!(
284                panic_msg.contains("test panic message"),
285                "panic message should be preserved, got: {}",
286                panic_msg
287            );
288        }
289    }
290
291    // Test that Result-returning closures work correctly with protect()
292    #[test]
293    fn test_protect_with_result_ok() {
294        with_ruby_vm(|| {
295            let result = protect(|| -> Result<i32, &'static str> { Ok(42) });
296
297            match result {
298                Ok(Ok(value)) => assert_eq!(value, 42),
299                Ok(Err(e)) => panic!("inner error: {}", e),
300                Err(e) => panic!("ruby exception: {:?}", e),
301            }
302        })
303        .unwrap();
304    }
305
306    #[test]
307    fn test_protect_with_result_err() {
308        with_ruby_vm(|| {
309            let result = protect(|| -> Result<i32, &'static str> { Err("test error") });
310
311            match result {
312                Ok(Ok(_)) => panic!("expected error"),
313                Ok(Err(e)) => assert_eq!(e, "test error"),
314                Err(e) => panic!("ruby exception: {:?}", e),
315            }
316        })
317        .unwrap();
318    }
319
320    #[test]
321    fn test_gc_stress_guard() {
322        with_ruby_vm(|| unsafe {
323            let stress_intern = rb_intern("stress\0".as_ptr() as _);
324            let gc_module =
325                rb_sys::rb_const_get(rb_sys::rb_cObject, rb_intern("GC\0".as_ptr() as _));
326
327            // Verify GC.stress is initially false
328            let initial_stress = rb_sys::rb_funcall(gc_module, stress_intern, 0);
329            assert_eq!(initial_stress, rb_sys::Qfalse as VALUE);
330
331            {
332                let _guard = GcStressGuard::new();
333
334                // Verify GC.stress is now true
335                let stress_during = rb_sys::rb_funcall(gc_module, stress_intern, 0);
336                assert_eq!(stress_during, rb_sys::Qtrue as VALUE);
337            }
338
339            // Verify GC.stress is restored to false after guard is dropped
340            let final_stress = rb_sys::rb_funcall(gc_module, stress_intern, 0);
341            assert_eq!(final_stress, rb_sys::Qfalse as VALUE);
342        })
343        .unwrap();
344    }
345
346    #[test]
347    fn test_with_gc_stress_restores_on_panic() {
348        with_ruby_vm(|| unsafe {
349            let stress_intern = rb_intern("stress\0".as_ptr() as _);
350            let gc_module =
351                rb_sys::rb_const_get(rb_sys::rb_cObject, rb_intern("GC\0".as_ptr() as _));
352
353            // Verify GC.stress is initially false
354            let initial_stress = rb_sys::rb_funcall(gc_module, stress_intern, 0);
355            assert_eq!(initial_stress, rb_sys::Qfalse as VALUE);
356
357            // Panic inside with_gc_stress and catch it
358            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
359                with_gc_stress(|| {
360                    panic!("test panic");
361                })
362            }));
363
364            assert!(result.is_err(), "should have panicked");
365
366            // Verify GC.stress is restored to false after panic
367            let final_stress = rb_sys::rb_funcall(gc_module, stress_intern, 0);
368            assert_eq!(final_stress, rb_sys::Qfalse as VALUE);
369        })
370        .unwrap();
371    }
372}