rbspy/core/
ruby_spy.rs

1#[cfg(windows)]
2use anyhow::format_err;
3use anyhow::{Context, Error, Result};
4use spytools::ProcessInfo;
5
6use crate::core::process::{Pid, Process, ProcessRetry};
7use crate::core::types::{MemoryCopyError, StackTrace};
8
9use super::address_finder::RubyVM;
10
11pub struct RubySpy {
12    process: Process,
13    vm: super::address_finder::RubyVM,
14    on_cpu_only: bool,
15}
16
17impl RubySpy {
18    pub fn new(pid: Pid, force_version: Option<String>, on_cpu_only: bool) -> Result<Self> {
19        #[cfg(all(windows, target_arch = "x86_64"))]
20        if is_wow64_process(pid).context("check wow64 process")? {
21            return Err(format_err!(
22                "Unable to profile 32-bit Ruby with 64-bit rbspy"
23            ));
24        }
25        let process =
26            Process::new_with_retry(pid).context("Failed to find process. Is it running?")?;
27
28        let process_info = ProcessInfo::new::<spytools::process::RubyProcessType>(&process)?;
29
30        let vm = crate::core::address_finder::inspect_ruby_process(
31            &process,
32            &process_info,
33            force_version,
34        )
35        .context("get ruby VM state")?;
36
37        Ok(Self {
38            process,
39            vm,
40            on_cpu_only,
41        })
42    }
43
44    /// Creates a RubySpy object, retrying up to max_retries times.
45    ///
46    /// Retrying is useful for a few reasons:
47    /// a) Sometimes rbenv takes a while to exec the right Ruby binary.
48    /// b) Dynamic linking takes a nonzero amount of time, so even after the right Ruby binary is
49    ///    exec'd we still need to wait for the right memory maps to be in place
50    /// c) On Mac, it can take a while between when the process is 'exec'ed and when we can get a
51    ///    Mach port for the process, which is how rbspy communicates with it
52    pub fn retry_new(
53        pid: Pid,
54        max_retries: u64,
55        force_version: Option<String>,
56        on_cpu_only: bool,
57    ) -> Result<Self, Error> {
58        let mut retries = 0;
59        loop {
60            let err = match Self::new(pid, force_version.clone(), on_cpu_only) {
61                Ok(mut process) => {
62                    // verify that we can load a stack trace before returning success
63                    match process.get_stack_trace(false) {
64                        Ok(_) => return Ok(process),
65                        Err(err) => err,
66                    }
67                }
68                Err(err) => err,
69            };
70
71            // If we failed, retry a couple times before returning the last error
72            retries += 1;
73            if retries >= max_retries {
74                return Err(err);
75            }
76            info!(
77                "Failed to connect to process; will retry. Last error: {}",
78                err
79            );
80            std::thread::sleep(std::time::Duration::from_millis(20));
81        }
82    }
83
84    pub fn get_stack_trace(&mut self, lock_process: bool) -> Result<Option<StackTrace>> {
85        // First, try OS-specific checks to determine whether the process is on CPU or not.
86        // This comes before locking the process because in most operating systems locking
87        // will stop the process and interfere with the on-CPU check.
88        if self.on_cpu_only && !self.is_on_cpu()? {
89            return Ok(None);
90        }
91        match self.get_trace_from_current_thread(lock_process) {
92            Ok(Some(mut trace)) => {
93                return {
94                    trace.pid = Some(self.process.pid);
95                    Ok(Some(trace))
96                };
97            }
98            Ok(None) => Ok(None),
99            Err(e) => {
100                if self.process.exe().is_err() {
101                    return Err(MemoryCopyError::ProcessEnded.into());
102                }
103                return Err(e.into());
104            }
105        }
106    }
107
108    fn get_trace_from_current_thread(&self, lock_process: bool) -> Result<Option<StackTrace>> {
109        let _lock;
110        if lock_process {
111            _lock = self
112                .process
113                .lock()
114                .context("locking process during stack trace retrieval")?;
115        }
116
117        (&self.vm.ruby_version.get_stack_trace_fn)(
118            self.vm.current_thread_addr_location,
119            self.vm.ruby_vm_addr_location,
120            self.vm.global_symbols_addr_location,
121            &self.process,
122            self.process.pid,
123            self.on_cpu_only,
124        )
125    }
126
127    fn is_on_cpu(&self) -> Result<bool> {
128        if self
129            .process
130            .threads()?
131            .iter()
132            .any(|thread| thread.active().unwrap_or(false))
133        {
134            return Ok(true);
135        }
136
137        Ok(false)
138    }
139
140    pub fn inspect(&self) -> &RubyVM {
141        &self.vm
142    }
143}
144
145#[cfg(all(windows, target_arch = "x86_64"))]
146fn is_wow64_process(pid: Pid) -> Result<bool> {
147    use std::os::windows::io::RawHandle;
148    use winapi::shared::minwindef::{BOOL, FALSE, PBOOL};
149    use winapi::um::processthreadsapi::OpenProcess;
150    use winapi::um::winnt::PROCESS_QUERY_INFORMATION;
151    use winapi::um::wow64apiset::IsWow64Process;
152
153    let handle = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid) };
154
155    if handle == (0 as RawHandle) {
156        return Err(format_err!(
157            "Unable to fetch process handle for process {}",
158            pid
159        ));
160    }
161
162    let mut is_wow64: BOOL = 0;
163
164    if unsafe { IsWow64Process(handle, &mut is_wow64 as PBOOL) } == FALSE {
165        return Err(format_err!("Could not determine process bitness! {}", pid));
166    }
167
168    Ok(is_wow64 != 0)
169}
170
171#[cfg(test)]
172mod tests {
173    use crate::core::process::tests::RubyScript;
174    #[cfg(any(unix, windows))]
175    use crate::core::process::Pid;
176    use crate::core::ruby_spy::RubySpy;
177    #[cfg(target_os = "macos")]
178    use std::process::Command;
179
180    #[test]
181    #[cfg(all(windows, target_arch = "x86_64"))]
182    fn test_is_wow64_process() {
183        let programs = vec![
184            "C:\\Program Files (x86)\\Internet Explorer\\iexplore.exe",
185            "C:\\Program Files\\Internet Explorer\\iexplore.exe",
186        ];
187
188        let results: Vec<bool> = programs
189            .iter()
190            .map(|path| {
191                let mut cmd = std::process::Command::new(path)
192                    .spawn()
193                    .expect("iexplore failed to start");
194
195                let is_wow64 = crate::core::ruby_spy::is_wow64_process(cmd.id()).unwrap();
196                cmd.kill().expect("couldn't clean up test process");
197                is_wow64
198            })
199            .collect();
200
201        assert_eq!(results, vec![true, false]);
202    }
203
204    #[test]
205    fn test_initialize_with_nonexistent_process() {
206        match RubySpy::new(65535, None, false) {
207            Ok(_) => assert!(
208                false,
209                "Expected error because process probably doesn't exist"
210            ),
211            _ => {}
212        }
213    }
214
215    #[test]
216    #[cfg(target_os = "linux")]
217    fn test_initialize_with_disallowed_process() {
218        match RubySpy::new(1, None, false) {
219            Ok(_) => assert!(
220                false,
221                "Expected error because we shouldn't be allowed to profile the init process"
222            ),
223            _ => {}
224        }
225    }
226
227    #[test]
228    #[cfg(target_os = "macos")]
229    fn test_get_disallowed_process() {
230        // getting the ruby version isn't allowed on Mac if the process isn't running as root
231        let mut process = Command::new("/usr/bin/ruby").spawn().unwrap();
232        let pid = process.id() as Pid;
233
234        match RubySpy::new(pid, None, false) {
235            Ok(_) => assert!(
236                false,
237                "Expected error because we shouldn't be allowed to profile system processes"
238            ),
239            _ => {}
240        }
241
242        process.kill().expect("couldn't clean up test process");
243    }
244
245    #[test]
246    fn test_get_trace_on_cpu() {
247        #[cfg(target_os = "macos")]
248        if !nix::unistd::Uid::effective().is_root() {
249            println!("Skipping test because we're not running as root");
250            return;
251        }
252
253        let cmd = RubyScript::new("./ci/ruby-programs/infinite_on_cpu.rb");
254        let pid = cmd.id() as Pid;
255        let mut spy = RubySpy::retry_new(pid, 100, None, false).expect("couldn't initialize spy");
256        spy.get_stack_trace(false)
257            .expect("couldn't get stack trace");
258    }
259
260    #[test]
261    fn test_get_trace_off_cpu() {
262        #[cfg(target_os = "macos")]
263        if !nix::unistd::Uid::effective().is_root() {
264            println!("Skipping test because we're not running as root");
265            return;
266        }
267
268        let coordination_dir = tempfile::tempdir().unwrap();
269        let coordination_dir_name = coordination_dir.path().to_str().unwrap();
270        let coordination_file_path = format!("{}/ready", coordination_dir_name);
271        let cp = std::path::Path::new(&coordination_file_path);
272        assert!(!cp.exists());
273
274        let cmd = RubyScript::new_with_args(
275            "./ci/ruby-programs/infinite_off_cpu.rb",
276            &[coordination_file_path.clone()],
277        );
278        let pid = cmd.id() as Pid;
279
280        loop {
281            if cp.exists() {
282                break;
283            }
284            std::thread::sleep(std::time::Duration::from_millis(100));
285        }
286
287        let mut spy = RubySpy::retry_new(pid, 100, None, true).expect("couldn't initialize spy");
288        let trace = spy
289            .get_stack_trace(false)
290            .expect("couldn't get stack trace");
291        assert!(trace.is_none());
292    }
293
294    #[test]
295    fn test_get_trace_when_process_has_exited() {
296        #[cfg(target_os = "macos")]
297        if !nix::unistd::Uid::effective().is_root() {
298            println!("Skipping test because we're not running as root");
299            return;
300        }
301
302        let mut cmd = RubyScript::new("./ci/ruby-programs/infinite_on_cpu.rb");
303        let mut getter = RubySpy::retry_new(cmd.id(), 100, None, false).unwrap();
304
305        cmd.kill().expect("couldn't clean up test process");
306
307        let mut i = 0;
308        loop {
309            match getter.get_stack_trace(false) {
310                Err(e) => {
311                    if let Some(crate::core::types::MemoryCopyError::ProcessEnded) =
312                        e.downcast_ref()
313                    {
314                        // This is the expected error
315                        return;
316                    }
317                }
318                _ => {}
319            };
320            std::thread::sleep(std::time::Duration::from_millis(100));
321            i += 1;
322            if i > 50 {
323                panic!("Didn't get ProcessEnded in a reasonable amount of time");
324            }
325        }
326    }
327}