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, 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    #[cfg(windows)]
138    {
139        let mut argc = 0;
140        let mut argv: [*mut std::os::raw::c_char; 0] = [];
141        let mut argv = argv.as_mut_ptr();
142        rb_sys::rb_w32_sysinit(&mut argc, &mut argv);
143    }
144
145    let mut stack_marker: VALUE = 0;
146    ruby_init_stack(addr_of_mut!(stack_marker) as *mut _);
147
148    match ruby_setup() {
149        0 => {}
150        code => panic!("Failed to setup Ruby (error code: {})", code),
151    };
152
153    unsafe extern "C" fn do_ruby_process_options(_: VALUE) -> VALUE {
154        let mut argv: [*mut i8; 3] = [
155            "ruby\0".as_ptr() as _,
156            "-e\0".as_ptr() as _,
157            "\0".as_ptr() as _,
158        ];
159
160        ruby_process_options(argv.len() as _, argv.as_mut_ptr() as _) as _
161    }
162
163    let mut protect_status = 0;
164
165    let node = rb_protect(
166        Some(do_ruby_process_options),
167        Qnil as _,
168        &mut protect_status as _,
169    );
170
171    if protect_status != 0 {
172        let err = rb_errinfo();
173        let mut msg = rb_inspect(err);
174        let msg = rb_string_value_cstr(&mut msg);
175
176        let msg = std::ffi::CStr::from_ptr(msg).to_string_lossy().into_owned();
177        rb_set_errinfo(Qnil as _);
178        panic!("Failed to process Ruby options: {}", msg);
179    }
180
181    match ruby_exec_node(node as _) {
182        0 => {}
183        code => panic!("Failed to execute Ruby (error code: {})", code),
184    };
185}
186
187/// Cleanup the Ruby VM.
188///
189/// ### Safety
190/// This function is not thread-safe and caller must ensure it's only called once.
191pub unsafe fn cleanup_ruby() {
192    let ret = rb_sys::ruby_cleanup(0);
193
194    if ret != 0 {
195        panic!("Failed to cleanup Ruby (error code: {})", ret);
196    }
197}
198
199pub struct RubyCleanupGuard;
200
201impl Drop for RubyCleanupGuard {
202    fn drop(&mut self) {
203        unsafe { cleanup_ruby() };
204    }
205}
206
207/// Setup the Ruby VM and return a guard that will cleanup the VM when dropped.
208///
209/// ### Safety
210/// This function is not thread-safe and caller must ensure it's only called once.
211#[must_use]
212pub unsafe fn setup_ruby() -> RubyCleanupGuard {
213    setup_ruby_unguarded();
214
215    RubyCleanupGuard
216}
217
218fn trick_the_linker() {
219    // Force the compiler to not optimize out rb_ext_ractor_safe...
220    #[cfg(ruby_gte_3_0)]
221    {
222        #[allow(clippy::cmp_null)]
223        let ensure_ractor_safe = rb_ext_ractor_safe as *const () != std::ptr::null();
224        assert!(ensure_ractor_safe);
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use rb_sys::ruby_vm_at_exit;
232    use rusty_fork::rusty_fork_test;
233
234    rusty_fork_test! {
235        #[test]
236        fn test_shutdown() {
237            static mut RUBY_VM_AT_EXIT_CALLED: Option<&str> = None;
238
239            let executor = RubyTestExecutor::start();
240
241            unsafe extern "C" fn set_called(_: *mut rb_sys::ruby_vm_t) {
242                RUBY_VM_AT_EXIT_CALLED = Some("hell yeah it was");
243            }
244
245            executor.run_test(|| {
246                unsafe { ruby_vm_at_exit(Some(set_called))}
247            }).unwrap();
248
249            drop(executor);
250
251            assert_eq!(Some("hell yeah it was"), unsafe { RUBY_VM_AT_EXIT_CALLED });
252        }
253    }
254
255    rusty_fork_test! {
256        #[test]
257        fn test_timeout() {
258            let mut executor = RubyTestExecutor::start();
259            executor.set_test_timeout(Duration::from_millis(10));
260
261            let result = executor
262                .run_test(|| {
263                    std::thread::sleep(Duration::from_millis(1000));
264                });
265
266            assert_eq!("Ruby test timed out after 10ms", format!("{}", result.unwrap_err()));
267        }
268    }
269}