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 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 match process.get_stack_trace(false) {
64 Ok(_) => return Ok(process),
65 Err(err) => err,
66 }
67 }
68 Err(err) => err,
69 };
70
71 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 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 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 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}