rb_sys_test_helpers/
ruby_test_executor.rs

1use std::error::Error;
2use std::panic;
3use std::ptr::addr_of_mut;
4use std::sync::mpsc::{self, SyncSender};
5use std::sync::Once;
6use std::thread::{self, JoinHandle};
7use std::time::Duration;
8
9use crate::once_cell::OnceCell;
10#[cfg(ruby_gte_3_0)]
11use rb_sys::rb_ext_ractor_safe;
12use rb_sys::{
13    rb_errinfo, rb_inspect, rb_protect, rb_set_errinfo, rb_string_value_cstr, ruby_exec_node,
14    ruby_init_stack, ruby_process_options, ruby_setup, ruby_sysinit, Qnil, VALUE,
15};
16
17static mut GLOBAL_EXECUTOR: OnceCell<RubyTestExecutor> = OnceCell::new();
18
19pub struct RubyTestExecutor {
20    #[allow(clippy::type_complexity)]
21    sender: Option<SyncSender<Box<dyn FnOnce() -> Result<(), Box<dyn Error>> + Send>>>,
22    handle: Option<JoinHandle<Result<(), std::boxed::Box<dyn Error + Send>>>>,
23    timeout: Duration,
24}
25
26impl RubyTestExecutor {
27    pub fn start() -> Self {
28        let (sender, receiver) =
29            mpsc::sync_channel::<Box<dyn FnOnce() -> Result<(), Box<dyn Error>> + Send>>(0);
30
31        let handle = thread::spawn(move || -> Result<(), Box<dyn Error + Send>> {
32            for closure in receiver {
33                match closure() {
34                    Ok(()) => {}
35                    Err(err) => {
36                        // transmute to avoid the Send bound
37                        let err: Box<dyn Error + Send> = unsafe { std::mem::transmute(err) };
38                        return Err(err);
39                    }
40                }
41            }
42            Ok(())
43        });
44
45        let executor = Self {
46            sender: Some(sender),
47            handle: Some(handle),
48            timeout: Duration::from_secs(10),
49        };
50
51        executor
52            .run(|| {
53                static INIT: Once = Once::new();
54
55                INIT.call_once(|| unsafe {
56                    setup_ruby_unguarded();
57                })
58            })
59            .expect("Failed to setup Ruby");
60
61        executor
62    }
63
64    pub fn set_test_timeout(&mut self, timeout: Duration) {
65        self.timeout = timeout;
66    }
67
68    pub fn shutdown(&mut self) {
69        self.set_test_timeout(Duration::from_secs(3));
70
71        let _ = self.run(|| unsafe {
72            cleanup_ruby();
73        });
74
75        if let Some(sender) = self.sender.take() {
76            drop(sender);
77        }
78
79        if let Some(handle) = self.handle.take() {
80            let _ = handle.join().expect("Failed to join executor thread");
81        }
82    }
83
84    pub fn run<F, R>(&self, f: F) -> Result<R, Box<dyn Error>>
85    where
86        F: FnOnce() -> R + Send + 'static,
87        R: Send + 'static,
88    {
89        let (result_sender, result_receiver) = mpsc::sync_channel(1);
90
91        let closure = Box::new(move || -> Result<(), Box<dyn Error>> {
92            let result = panic::catch_unwind(panic::AssertUnwindSafe(f));
93            result_sender.send(result).map_err(Into::into)
94        });
95
96        if let Some(sender) = self.sender.as_ref() {
97            sender.send(closure)?;
98        } else {
99            return Err("Ruby test executor is shutdown".into());
100        }
101
102        match result_receiver.recv_timeout(self.timeout) {
103            Ok(Ok(result)) => Ok(result),
104            Ok(Err(err)) => std::panic::resume_unwind(err),
105            Err(_err) => Err(format!("Ruby test timed out after {:?}", self.timeout).into()),
106        }
107    }
108
109    pub fn run_test<F, R>(&self, f: F) -> Result<R, Box<dyn Error>>
110    where
111        F: FnOnce() -> R + Send + 'static,
112        R: Send + 'static,
113    {
114        self.run(f)
115    }
116}
117
118impl Drop for RubyTestExecutor {
119    fn drop(&mut self) {
120        self.shutdown();
121    }
122}
123
124pub fn global_executor() -> &'static RubyTestExecutor {
125    #[allow(unknown_lints)]
126    #[allow(static_mut_refs)]
127    unsafe { &GLOBAL_EXECUTOR }.get_or_init(RubyTestExecutor::start)
128}
129
130/// Setup the Ruby VM, without cleaning up afterwards.
131///
132/// ### Safety
133/// This function is not thread-safe and caller must ensure it's only called once.
134pub unsafe fn setup_ruby_unguarded() {
135    trick_the_linker();
136
137    // Call ruby_sysinit which handles platform-specific initialization
138    // (rb_w32_sysinit on Windows) and sets up standard file descriptors
139    let mut argc = 0;
140    let mut argv: [*mut std::os::raw::c_char; 0] = [];
141    let mut argv_ptr = argv.as_mut_ptr();
142    ruby_sysinit(&mut argc, &mut argv_ptr);
143
144    let mut stack_marker: VALUE = 0;
145    ruby_init_stack(addr_of_mut!(stack_marker) as *mut _);
146
147    match ruby_setup() {
148        0 => {}
149        code => panic!("Failed to setup Ruby (error code: {})", code),
150    };
151
152    unsafe extern "C" fn do_ruby_process_options(_: VALUE) -> VALUE {
153        let mut argv: [*mut i8; 3] = [
154            "ruby\0".as_ptr() as _,
155            "-e\0".as_ptr() as _,
156            "\0".as_ptr() as _,
157        ];
158
159        ruby_process_options(argv.len() as _, argv.as_mut_ptr() as _) as _
160    }
161
162    let mut protect_status = 0;
163
164    let node = rb_protect(
165        Some(do_ruby_process_options),
166        Qnil as _,
167        &mut protect_status as _,
168    );
169
170    if protect_status != 0 {
171        let err = rb_errinfo();
172        let mut msg = rb_inspect(err);
173        let msg = rb_string_value_cstr(&mut msg);
174
175        let msg = std::ffi::CStr::from_ptr(msg).to_string_lossy().into_owned();
176        rb_set_errinfo(Qnil as _);
177        panic!("Failed to process Ruby options: {}", msg);
178    }
179
180    match ruby_exec_node(node as _) {
181        0 => {}
182        code => panic!("Failed to execute Ruby (error code: {})", code),
183    };
184}
185
186/// Cleanup the Ruby VM.
187///
188/// ### Safety
189/// This function is not thread-safe and caller must ensure it's only called once.
190pub unsafe fn cleanup_ruby() {
191    let ret = rb_sys::ruby_cleanup(0);
192
193    if ret != 0 {
194        panic!("Failed to cleanup Ruby (error code: {})", ret);
195    }
196}
197
198pub struct RubyCleanupGuard;
199
200impl Drop for RubyCleanupGuard {
201    fn drop(&mut self) {
202        unsafe { cleanup_ruby() };
203    }
204}
205
206/// Setup the Ruby VM and return a guard that will cleanup the VM when dropped.
207///
208/// ### Safety
209/// This function is not thread-safe and caller must ensure it's only called once.
210#[must_use]
211pub unsafe fn setup_ruby() -> RubyCleanupGuard {
212    setup_ruby_unguarded();
213
214    RubyCleanupGuard
215}
216
217fn trick_the_linker() {
218    // Force the compiler to not optimize out rb_ext_ractor_safe...
219    #[cfg(ruby_gte_3_0)]
220    {
221        #[allow(clippy::cmp_null)]
222        let ensure_ractor_safe = rb_ext_ractor_safe as *const () != std::ptr::null();
223        assert!(ensure_ractor_safe);
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use rb_sys::ruby_vm_at_exit;
231    use rusty_fork::rusty_fork_test;
232
233    rusty_fork_test! {
234        #[test]
235        fn test_shutdown() {
236            static mut RUBY_VM_AT_EXIT_CALLED: Option<&str> = None;
237
238            let executor = RubyTestExecutor::start();
239
240            unsafe extern "C" fn set_called(_: *mut rb_sys::ruby_vm_t) {
241                RUBY_VM_AT_EXIT_CALLED = Some("hell yeah it was");
242            }
243
244            executor.run_test(|| {
245                unsafe { ruby_vm_at_exit(Some(set_called))}
246            }).unwrap();
247
248            drop(executor);
249
250            assert_eq!(Some("hell yeah it was"), unsafe { RUBY_VM_AT_EXIT_CALLED });
251        }
252    }
253
254    rusty_fork_test! {
255        #[test]
256        fn test_timeout() {
257            let mut executor = RubyTestExecutor::start();
258            executor.set_test_timeout(Duration::from_millis(10));
259
260            let result = executor
261                .run_test(|| {
262                    std::thread::sleep(Duration::from_millis(1000));
263                });
264
265            assert_eq!("Ruby test timed out after 10ms", format!("{}", result.unwrap_err()));
266        }
267    }
268}