Skip to main content

py_spy/
stack_trace.rs

1use std::sync::Arc;
2
3use anyhow::{Context, Error, Result};
4
5use remoteprocess::{Pid, ProcessMemory};
6use serde_derive::Serialize;
7
8use crate::config::{Config, LineNo};
9use crate::python_data_access::{copy_bytes, copy_string};
10use crate::python_interpreters::{
11    CodeObject, FrameObject, InterpreterState, ThreadState, TupleObject,
12};
13
14/// Call stack for a single python thread
15#[derive(Debug, Clone, Serialize)]
16pub struct StackTrace {
17    /// The process id than generated this stack trace
18    pub pid: Pid,
19    /// The python thread id for this stack trace
20    pub thread_id: u64,
21    // The python thread name for this stack trace
22    pub thread_name: Option<String>,
23    /// The OS thread id for this stack tracee
24    pub os_thread_id: Option<u64>,
25    /// Whether or not the thread was active
26    pub active: bool,
27    /// Whether or not the thread held the GIL
28    pub owns_gil: bool,
29    /// The frames
30    pub frames: Vec<Frame>,
31    /// process commandline / parent process info
32    pub process_info: Option<Arc<ProcessInfo>>,
33}
34
35/// Information about a single function call in a stack trace
36#[derive(Debug, Hash, Eq, PartialEq, Ord, PartialOrd, Clone, Serialize)]
37pub struct Frame {
38    /// The function name
39    pub name: String,
40    /// The full filename of the file
41    pub filename: String,
42    /// The module/shared library the
43    pub module: Option<String>,
44    /// A short, more readable, representation of the filename
45    pub short_filename: Option<String>,
46    /// The line number inside the file (or 0 for native frames without line information)
47    pub line: i32,
48    /// Local Variables associated with the frame
49    pub locals: Option<Vec<LocalVariable>>,
50    /// If this is an entry frame. Each entry frame corresponds to one native frame (Python 3.11)
51    pub is_entry: bool,
52    /// If the last frame was a shim. This is used in Python 3.12+ to detect entry frames.
53    pub is_shim_entry: bool,
54}
55
56#[derive(Debug, Hash, Eq, PartialEq, Ord, PartialOrd, Clone, Serialize)]
57pub struct LocalVariable {
58    pub name: String,
59    pub addr: usize,
60    pub arg: bool,
61    pub repr: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize)]
65pub struct ProcessInfo {
66    pub pid: Pid,
67    pub command_line: String,
68    pub parent: Option<Box<ProcessInfo>>,
69}
70
71/// Given an InterpreterState, this function returns a vector of stack traces for each thread
72pub fn get_stack_traces<I, P>(
73    interpreter_address: usize,
74    process: &P,
75    threadstate_address: usize,
76    config: Option<&Config>,
77) -> Result<Vec<StackTrace>, Error>
78where
79    I: InterpreterState,
80    P: ProcessMemory,
81{
82    let gil_thread_id = get_gil_threadid::<I, P>(threadstate_address, process)?;
83
84    let threadstate_ptr_ptr = I::threadstate_ptr_ptr(interpreter_address);
85    let mut threads: *const I::ThreadState = process
86        .copy_struct(threadstate_ptr_ptr as usize)
87        .context("Failed to copy PyThreadState head pointer")?;
88
89    let mut ret = Vec::new();
90
91    let lineno = config.map(|c| c.lineno).unwrap_or(LineNo::NoLine);
92    let dump_locals = config.map(|c| c.dump_locals).unwrap_or(0);
93
94    while !threads.is_null() {
95        let thread = process
96            .copy_pointer(threads)
97            .context("Failed to copy PyThreadState")?;
98
99        let mut trace = get_stack_trace(&thread, process, dump_locals > 0, lineno)?;
100        trace.owns_gil = trace.thread_id == gil_thread_id;
101
102        ret.push(trace);
103        // This seems to happen occasionally when scanning BSS addresses for valid interpreters
104        if ret.len() > 4096 {
105            return Err(format_err!("Max thread recursion depth reached"));
106        }
107        threads = thread.next();
108    }
109    Ok(ret)
110}
111
112/// Gets a stack trace for an individual thread
113pub fn get_stack_trace<T, P>(
114    thread: &T,
115    process: &P,
116    copy_locals: bool,
117    lineno: LineNo,
118) -> Result<StackTrace, Error>
119where
120    T: ThreadState,
121    P: ProcessMemory,
122{
123    // TODO: just return frames here? everything else probably should be returned out of scope
124    let mut frames = Vec::new();
125
126    // python 3.11+ has an extra level of indirection to get the Frame from the threadstate
127    let mut frame_address = thread.frame_address();
128    if let Some(addr) = frame_address {
129        frame_address = Some(process.copy_struct(addr)?);
130    }
131
132    let mut frame_ptr = thread.frame(frame_address);
133
134    // We are iterating in reverse, i.e. from last call to first call.
135    // Since Python 3.12, there are shim frames inserted before a block
136    // of Python frames. When we encounter one, update the last frame.
137    let set_last_frame_as_shim_entry = &mut |frames: &mut Vec<Frame>| {
138        if let Some(frame) = frames.last_mut() {
139            frame.is_shim_entry = true;
140        }
141    };
142
143    while !frame_ptr.is_null() {
144        let frame = process
145            .copy_pointer(frame_ptr)
146            .context("Failed to copy PyFrameObject")?;
147
148        let code = process
149            .copy_pointer(frame.code())
150            .context("Failed to copy PyCodeObject")?;
151
152        let filename = copy_string(code.filename(), process).context("Failed to copy filename");
153        let name = copy_string(code.name(), process).context("Failed to copy function name");
154
155        // just skip processing the current frame if we can't load the filename or function name.
156        // this can happen in python 3.13+ since the f_executable isn't guaranteed to be
157        // a PyCodeObject. We could check the type (and mimic the logic of PyCode_Check here)
158        // but that would require extra overhead of reading the ob_type per frame - and we
159        // would also have to figure out what the address of PyCode_Type is (which will be
160        // easier if something like https://github.com/python/cpython/issues/100987#issuecomment-1487227139
161        // is merged )
162        if filename.is_err() || name.is_err() {
163            frame_ptr = frame.back();
164            set_last_frame_as_shim_entry(&mut frames);
165            continue;
166        }
167        let filename = filename?;
168        let name = name?;
169
170        // skip <shim> entries in python 3.12+
171        // Unset file/function name in py3.13 means this is a shim.
172        if filename.is_empty() || filename == "<shim>" {
173            frame_ptr = frame.back();
174            set_last_frame_as_shim_entry(&mut frames);
175            continue;
176        }
177
178        let line = match lineno {
179            LineNo::NoLine => 0,
180            LineNo::First => code.first_lineno(),
181            LineNo::LastInstruction => match get_line_number(&code, frame.lasti(), process) {
182                Ok(line) => line,
183                Err(e) => {
184                    // Failling to get the line number really shouldn't be fatal here, but
185                    // can happen in extreme cases (https://github.com/benfred/py-spy/issues/164)
186                    // Rather than fail set the linenumber to 0. This is used by the native extensions
187                    // to indicate that we can't load a line number and it should be handled gracefully
188                    warn!(
189                        "Failed to get line number from {}.{}: {}",
190                        filename, name, e
191                    );
192                    0
193                }
194            },
195        };
196
197        let locals = if copy_locals {
198            Some(
199                get_locals(&code, frame_ptr, &frame, process)
200                    .context("Failed to get local variables")?,
201            )
202        } else {
203            None
204        };
205
206        let is_entry = frame.is_entry();
207
208        frames.push(Frame {
209            name,
210            filename,
211            line,
212            short_filename: None,
213            module: None,
214            locals,
215            is_entry,
216            is_shim_entry: false,
217        });
218        if frames.len() > 4096 {
219            return Err(format_err!("Max frame recursion depth reached"));
220        }
221
222        frame_ptr = frame.back();
223    }
224
225    // First frame is always a shim
226    set_last_frame_as_shim_entry(&mut frames);
227
228    Ok(StackTrace {
229        pid: 0,
230        frames,
231        thread_id: thread.thread_id(),
232        thread_name: None,
233        owns_gil: false,
234        active: true,
235        os_thread_id: thread.native_thread_id(),
236        process_info: None,
237    })
238}
239
240impl StackTrace {
241    pub fn status_str(&self) -> &str {
242        match (self.owns_gil, self.active) {
243            (_, false) => "idle",
244            (true, true) => "active+gil",
245            (false, true) => "active",
246        }
247    }
248
249    pub fn format_threadid(&self) -> String {
250        // native threadids in osx are kinda useless, use the pthread id instead
251        #[cfg(target_os = "macos")]
252        return format!("{:#X}", self.thread_id);
253
254        // otherwise use the native threadid if given
255        #[cfg(not(target_os = "macos"))]
256        match self.os_thread_id {
257            Some(tid) => format!("{}", tid),
258            None => format!("{:#X}", self.thread_id),
259        }
260    }
261}
262
263/// Returns the line number from a PyCodeObject (given the lasti index from a PyFrameObject)
264fn get_line_number<C: CodeObject, P: ProcessMemory>(
265    code: &C,
266    lasti: i32,
267    process: &P,
268) -> Result<i32, Error> {
269    let table =
270        copy_bytes(code.line_table(), process).context("Failed to copy line number table")?;
271    Ok(code.get_line_number(lasti, &table))
272}
273
274fn get_locals<C: CodeObject, F: FrameObject, P: ProcessMemory>(
275    code: &C,
276    frameptr: *const F,
277    frame: &F,
278    process: &P,
279) -> Result<Vec<LocalVariable>, Error> {
280    let local_count = code.nlocals() as usize;
281    let argcount = code.argcount() as usize;
282    let varnames = process
283        .copy_pointer(code.varnames())
284        .context("Failed to get varnames from PyCodeObject")?;
285
286    let ptr_size = std::mem::size_of::<*const i32>();
287    let locals_addr = frameptr as usize + std::mem::size_of_val(frame) - ptr_size;
288
289    let mut ret = Vec::new();
290
291    for i in 0..local_count {
292        let nameptr: *const C::StringObject =
293            process.copy_struct(varnames.address(code.varnames() as usize, i))?;
294
295        let name = copy_string(nameptr, process).context("Failed to copy local variable name")?;
296        let addr: usize = process.copy_struct(locals_addr + i * ptr_size)?;
297
298        // hack: handle things like None, True, False, small integer constants etc on Python 3.14
299        let addr = if addr & 1 == 1 { addr - 1 } else { addr };
300
301        if addr == 0 {
302            continue;
303        }
304        ret.push(LocalVariable {
305            name,
306            addr,
307            arg: i < argcount,
308            repr: None,
309        });
310    }
311    Ok(ret)
312}
313
314pub fn get_gil_threadid<I: InterpreterState, P: ProcessMemory>(
315    threadstate_address: usize,
316    process: &P,
317) -> Result<u64, Error> {
318    // happens during initialization when checking to see if we have a valid interpreter (before we've figured out the threadstate_address)
319    if threadstate_address == 0 {
320        return Ok(0);
321    }
322
323    let addr = if I::HAS_GIL_RUNTIME_STATE {
324        // get the gilruntimestate - note that this struct is identical between 3.12/3.13/3.14
325        let gil_state: crate::python_bindings::v3_13_0::_gil_runtime_state =
326            process.copy_struct(threadstate_address)?;
327        // check to see if the GIL is locked already
328        if gil_state.locked != 0 {
329            gil_state.last_holder as usize
330        } else {
331            0
332        }
333    } else {
334        process.copy_struct::<usize>(threadstate_address)?
335    };
336
337    // if the addr is 0, no thread is currently holding the GIL
338    let threadid = if addr != 0 {
339        let threadstate: I::ThreadState = process.copy_struct(addr)?;
340        threadstate.thread_id()
341    } else {
342        0
343    };
344
345    Ok(threadid)
346}
347
348impl ProcessInfo {
349    pub fn to_frame(&self) -> Frame {
350        Frame {
351            name: format!("process {}:\"{}\"", self.pid, self.command_line),
352            filename: String::from(""),
353            module: None,
354            short_filename: None,
355            line: 0,
356            locals: None,
357            is_entry: true,
358            is_shim_entry: true,
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::python_bindings::v3_7_0::PyCodeObject;
367    use crate::python_data_access::tests::to_byteobject;
368    use remoteprocess::LocalProcess;
369
370    #[test]
371    fn test_get_line_number() {
372        let mut lnotab = to_byteobject(&[0u8, 1, 10, 1, 8, 1, 4, 1]);
373        let code = PyCodeObject {
374            co_firstlineno: 3,
375            co_lnotab: &mut lnotab.base.ob_base.ob_base,
376            ..Default::default()
377        };
378        let lineno = get_line_number(&code, 30, &LocalProcess).unwrap();
379        assert_eq!(lineno, 7);
380    }
381}