Skip to content

Commit f203b61

Browse files
Inject rust stack frames into traceback
1 parent ecec832 commit f203b61

7 files changed

Lines changed: 277 additions & 3 deletions

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ uuid = { version = "1.12.0", optional = true }
6161
lock_api = { version = "0.4", optional = true }
6262
parking_lot = { version = "0.12", optional = true }
6363
iana-time-zone = { version = "0.1", optional = true, features = ["fallback"]}
64+
backtrace = "0.3.76"
6465

6566
[target.'cfg(not(target_has_atomic = "64"))'.dependencies]
6667
portable-atomic = "1.0"
@@ -80,6 +81,7 @@ tempfile = "3.12.0"
8081
static_assertions = "1.1.0"
8182
uuid = { version = "1.10.0", features = ["v4"] }
8283
parking_lot = { version = "0.12.3", features = ["arc_lock"] }
84+
insta = { version = "1.46.3", features = ["filters"] }
8385

8486
[build-dependencies]
8587
pyo3-build-config = { path = "pyo3-build-config", version = "=0.28.2", features = ["resolve-config"] }

src/err/err_state.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use std::{
44
thread::ThreadId,
55
};
66

7+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
8+
use crate::err::backtrace_to_frames;
79
#[cfg(not(Py_3_12))]
810
use crate::sync::MutexExt;
911
use crate::{
@@ -36,10 +38,15 @@ impl PyErrState {
3638
}
3739

3840
pub(crate) fn lazy_arguments(ptype: Py<PyAny>, args: impl PyErrArguments + 'static) -> Self {
41+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
42+
let backtrace = backtrace::Backtrace::new_unresolved();
43+
3944
Self::from_inner(PyErrStateInner::Lazy(Box::new(move |py| {
4045
PyErrStateLazyFnOutput {
4146
ptype,
4247
pvalue: args.arguments(py),
48+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
49+
backtrace,
4350
}
4451
})))
4552
}
@@ -301,6 +308,8 @@ impl PyErrStateNormalized {
301308
pub(crate) struct PyErrStateLazyFnOutput {
302309
pub(crate) ptype: Py<PyAny>,
303310
pub(crate) pvalue: Py<PyAny>,
311+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
312+
pub(crate) backtrace: backtrace::Backtrace,
304313
}
305314

306315
pub(crate) type PyErrStateLazyFn =
@@ -390,15 +399,35 @@ fn lazy_into_normalized_ffi_tuple(
390399
/// This would require either moving some logic from C to Rust, or requesting a new
391400
/// API in CPython.
392401
fn raise_lazy(py: Python<'_>, lazy: Box<PyErrStateLazyFn>) {
393-
let PyErrStateLazyFnOutput { ptype, pvalue } = lazy(py);
402+
let PyErrStateLazyFnOutput {
403+
ptype,
404+
pvalue,
405+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
406+
mut backtrace,
407+
} = lazy(py);
408+
394409
unsafe {
395410
if ffi::PyExceptionClass_Check(ptype.as_ptr()) == 0 {
396411
ffi::PyErr_SetString(
397412
PyTypeError::type_object_raw(py).cast(),
398413
c"exceptions must derive from BaseException".as_ptr(),
399414
)
400415
} else {
401-
ffi::PyErr_SetObject(ptype.as_ptr(), pvalue.as_ptr())
416+
#[cfg(not(all(debug_assertions, not(Py_LIMITED_API))))]
417+
{
418+
ffi::PyErr_SetObject(ptype.as_ptr(), pvalue.as_ptr());
419+
}
420+
421+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
422+
{
423+
let traceback =
424+
PyTraceback::from_frames(py, None, backtrace_to_frames(py, &mut backtrace))
425+
.ok()
426+
.flatten()
427+
.map_or_else(std::ptr::null_mut, Bound::into_ptr);
428+
429+
ffi::PyErr_Restore(ptype.into_ptr(), pvalue.into_ptr(), traceback)
430+
}
402431
}
403432
}
404433
}

src/err/mod.rs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ use crate::{BoundObject, Py, PyAny, Python};
2020
use err_state::{PyErrState, PyErrStateLazyFnOutput, PyErrStateNormalized};
2121
use std::convert::Infallible;
2222
use std::ffi::CStr;
23+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
24+
use {crate::types::PyFrame, std::ffi::CString};
2325

2426
mod cast_error;
2527
mod downcast_error;
@@ -127,10 +129,14 @@ impl PyErr {
127129
T: PyTypeInfo,
128130
A: PyErrArguments + Send + Sync + 'static,
129131
{
132+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
133+
let backtrace = backtrace::Backtrace::new_unresolved();
130134
PyErr::from_state(PyErrState::lazy(Box::new(move |py| {
131135
PyErrStateLazyFnOutput {
132136
ptype: T::type_object(py).into(),
133137
pvalue: args.arguments(py),
138+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
139+
backtrace,
134140
}
135141
})))
136142
}
@@ -289,7 +295,24 @@ impl PyErr {
289295
Self::print_panic_and_unwind(py, state)
290296
}
291297

292-
Some(PyErr::from_state(PyErrState::normalized(state)))
298+
let err = PyErr::from_state(PyErrState::normalized(state));
299+
300+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
301+
{
302+
let mut backtrace = backtrace::Backtrace::new();
303+
if let Some(traceback) = PyTraceback::from_frames(
304+
py,
305+
err.traceback(py),
306+
backtrace_to_frames(py, &mut backtrace),
307+
)
308+
.ok()
309+
.flatten()
310+
{
311+
err.set_traceback(py, Some(traceback));
312+
}
313+
}
314+
315+
Some(err)
293316
}
294317

295318
#[cold]
@@ -696,6 +719,49 @@ impl<'py> IntoPyObject<'py> for PyErr {
696719
}
697720
}
698721

722+
#[cfg(all(debug_assertions, not(Py_LIMITED_API)))]
723+
fn backtrace_to_frames<'py, 'a>(
724+
py: Python<'py>,
725+
backtrace: &'a mut backtrace::Backtrace,
726+
) -> impl Iterator<Item = Bound<'py, PyFrame>> + use<'py, 'a> {
727+
backtrace.resolve();
728+
backtrace
729+
.frames()
730+
.iter()
731+
.flat_map(|frame| frame.symbols())
732+
.map(|symbol| (symbol.name().map(|name| format!("{name:#}")), symbol))
733+
.skip_while(|(name, _)| {
734+
if cfg!(any(target_vendor = "apple", windows)) {
735+
// On Apple & Windows platforms, backtrace is not able to remove internal frames
736+
// from the backtrace, so we need to skip them manually here.
737+
name.as_ref()
738+
.map(|name| name.starts_with("backtrace::"))
739+
.unwrap_or(true)
740+
} else {
741+
false
742+
}
743+
})
744+
// The first frame is always the capture function, so skip it.
745+
.skip(1)
746+
.take_while(|(name, _)| {
747+
name.as_ref()
748+
.map(|name| {
749+
!(name.starts_with("pyo3::impl_::trampoline::")
750+
|| name.contains("__rust_begin_short_backtrace"))
751+
})
752+
.unwrap_or(true)
753+
})
754+
.filter_map(move |(name, symbol)| {
755+
let file =
756+
CString::new(symbol.filename()?.as_os_str().to_string_lossy().as_ref()).ok()?;
757+
758+
let function = CString::new(name.as_deref().unwrap_or("<unknown>")).ok()?;
759+
let line = symbol.lineno()?;
760+
761+
PyFrame::new(py, &file, &function, line as _).ok()
762+
})
763+
}
764+
699765
impl<'py> IntoPyObject<'py> for &PyErr {
700766
type Target = PyBaseException;
701767
type Output = Bound<'py, Self::Target>;
@@ -844,6 +910,7 @@ mod tests {
844910
}
845911

846912
#[test]
913+
#[cfg(false)]
847914
fn err_debug() {
848915
// Debug representation should be like the following (without the newlines):
849916
// PyErr {

src/types/traceback.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,27 @@ impl PyTraceback {
4141
.cast_into_unchecked())
4242
}
4343
}
44+
45+
/// Creates a new traceback object from an iterator of frames.
46+
///
47+
/// The frames should be ordered from newest to oldest, i.e. the first frame in the iterator
48+
/// will be the innermost frame in the traceback.
49+
#[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))]
50+
pub fn from_frames<'py, I>(
51+
py: Python<'py>,
52+
start: Option<Bound<'py, PyTraceback>>,
53+
frames: I,
54+
) -> PyResult<Option<Bound<'py, PyTraceback>>>
55+
where
56+
I: IntoIterator,
57+
I::Item: IntoPyObject<'py, Target = PyFrame>,
58+
{
59+
frames.into_iter().try_fold(start, |prev, frame| {
60+
let frame = frame.into_pyobject(py).map_err(Into::into)?.into_bound();
61+
let line_number = frame.line_number();
62+
PyTraceback::new(py, prev, frame, 0, line_number).map(Some)
63+
})
64+
}
4465
}
4566

4667
/// Implementation of functionality for [`PyTraceback`].
@@ -113,6 +134,7 @@ mod tests {
113134
};
114135

115136
#[test]
137+
#[cfg(false)]
116138
fn format_traceback() {
117139
Python::attach(|py| {
118140
let err = py
@@ -196,4 +218,44 @@ def f():
196218
);
197219
})
198220
}
221+
222+
#[test]
223+
#[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))]
224+
fn test_insert_traceback() {
225+
Python::attach(|py| {
226+
// Error happens in rust, so rust frames are created first.
227+
let rust_traceback = PyTraceback::from_frames(
228+
py,
229+
None,
230+
[
231+
PyFrame::new(py, c"rust2.rs", c"func2", 22).unwrap(),
232+
PyFrame::new(py, c"rust1.rs", c"func1", 11).unwrap(),
233+
],
234+
)
235+
.unwrap()
236+
.unwrap();
237+
238+
// Stacktrace where python calls into rust
239+
let traceback = PyTraceback::from_frames(
240+
py,
241+
Some(rust_traceback),
242+
[
243+
PyFrame::new(py, c"file2.py", c"func2", 20).unwrap(),
244+
PyFrame::new(py, c"file1.py", c"func1", 10).unwrap(),
245+
],
246+
)
247+
.unwrap()
248+
.unwrap();
249+
250+
assert_eq!(
251+
traceback.format().unwrap(),
252+
r#"Traceback (most recent call last):
253+
File "file1.py", line 10, in func1
254+
File "file2.py", line 20, in func2
255+
File "rust1.rs", line 11, in func1
256+
File "rust2.rs", line 22, in func2
257+
"#
258+
);
259+
})
260+
}
199261
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
source: tests/test_backtrace.rs
3+
expression: traceback
4+
---
5+
Traceback (most recent call last):
6+
File "[RUST_CORE]/ops/function.rs", line 250, in core::ops::function::FnOnce::call_once
7+
File "[RUST_CORE]/ops/function.rs", line 250, in <test_backtrace::test_rust_frames_in_backtrace::{closure#0} as core::ops::function::FnOnce<()>>::call_once
8+
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
9+
File "./tests/test_backtrace.rs", line 7, in test_backtrace::test_rust_frames_in_backtrace::{closure#0}
10+
fn test_rust_frames_in_backtrace() {
11+
File "./tests/test_backtrace.rs", line 16, in test_backtrace::test_rust_frames_in_backtrace
12+
Python::attach(|py| {
13+
File "./src/marker.rs", line 415, in <pyo3::marker::Python>::attach::<test_backtrace::test_rust_frames_in_backtrace::{closure#0}, ()>
14+
f(guard.python())
15+
File "./tests/test_backtrace.rs", line 24, in test_backtrace::test_rust_frames_in_backtrace::{closure#0}
16+
.run(
17+
File "./src/marker.rs", line 641, in <pyo3::marker::Python>::run
18+
code.run(globals, locals).map(|obj| {
19+
File "./src/types/code.rs", line 139, in <pyo3::instance::Bound<pyo3::types::code::PyCode> as pyo3::types::code::PyCodeMethods>::run
20+
.assume_owned_or_err(self.py())
21+
File "./src/ffi_ptr_ext.rs", line 43, in <*mut pyo3_ffi::object::PyObject as pyo3::ffi_ptr_ext::FfiPtrExt>::assume_owned_or_err
22+
unsafe { Bound::from_owned_ptr_or_err(py, self) }
23+
File "./src/instance.rs", line 385, in <pyo3::instance::Bound<pyo3::types::any::PyAny>>::from_owned_ptr_or_err
24+
None => Err(PyErr::fetch(py)),
25+
File "./src/err/mod.rs", line 354, in <pyo3::err::PyErr>::fetch
26+
PyErr::take(py).unwrap_or_else(failed_to_fetch)
27+
File "<string>", line 4, in <module>
28+
File "<string>", line 2, in python_func
29+
File "./tests/test_backtrace.rs", line 11, in test_backtrace::test_rust_frames_in_backtrace::__pyfunction_produce_err_result
30+
#[pyfunction]
31+
File "./tests/test_backtrace.rs", line 13, in test_backtrace::test_rust_frames_in_backtrace::produce_err_result
32+
Err(PyValueError::new_err("Error result"))
33+
File "./src/exceptions.rs", line 31, in <pyo3::exceptions::PyValueError>::new_err::<&str>
34+
$crate::PyErr::new::<$name, A>(args)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
source: tests/test_backtrace.rs
3+
expression: traceback
4+
---
5+
Traceback (most recent call last):
6+
File "[RUST_CORE]\ops\function.rs", line 250, in core::ops::function::FnOnce::call_once
7+
File "[RUST_CORE]\ops\function.rs", line 250, in core::ops::function::FnOnce::call_once<test_backtrace::test_rust_frames_in_backtrace::closure_env$0,tuple$<> >
8+
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
9+
File ".\tests\test_backtrace.rs", line 7, in test_backtrace::test_rust_frames_in_backtrace::closure$0
10+
fn test_rust_frames_in_backtrace() {
11+
File ".\tests\test_backtrace.rs", line 16, in test_backtrace::test_rust_frames_in_backtrace
12+
Python::attach(|py| {
13+
File ".\src\marker.rs", line 415, in pyo3::marker::Python::attach<test_backtrace::test_rust_frames_in_backtrace::closure_env$0,tuple$<> >
14+
f(guard.python())
15+
File ".\tests\test_backtrace.rs", line 24, in test_backtrace::test_rust_frames_in_backtrace::closure$0
16+
.run(
17+
File ".\src\marker.rs", line 641, in pyo3::marker::Python::run
18+
code.run(globals, locals).map(|obj| {
19+
File ".\src\types\code.rs", line 139, in pyo3::types::code::impl$1::run
20+
.assume_owned_or_err(self.py())
21+
File ".\src\ffi_ptr_ext.rs", line 43, in pyo3::ffi_ptr_ext::impl$0::assume_owned_or_err
22+
unsafe { Bound::from_owned_ptr_or_err(py, self) }
23+
File ".\src\instance.rs", line 385, in pyo3::instance::Bound<pyo3::types::any::PyAny>::from_owned_ptr_or_err
24+
None => Err(PyErr::fetch(py)),
25+
File ".\src\err\mod.rs", line 354, in pyo3::err::PyErr::fetch
26+
PyErr::take(py).unwrap_or_else(failed_to_fetch)
27+
File "<string>", line 4, in <module>
28+
File "<string>", line 2, in python_func
29+
File ".\tests\test_backtrace.rs", line 11, in test_backtrace::test_rust_frames_in_backtrace::__pyfunction_produce_err_result
30+
#[pyfunction]
31+
File ".\tests\test_backtrace.rs", line 13, in test_backtrace::test_rust_frames_in_backtrace::produce_err_result
32+
Err(PyValueError::new_err("Error result"))
33+
File ".\src\exceptions.rs", line 31, in pyo3::exceptions::PyValueError::new_err<ref$<str$> >
34+
$crate::PyErr::new::<$name, A>(args)

tests/test_backtrace.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#![cfg(all(feature = "macros", not(Py_LIMITED_API)))]
2+
use insta::assert_snapshot;
3+
use pyo3::exceptions::PyValueError;
4+
use pyo3::prelude::*;
5+
6+
#[test]
7+
fn test_rust_frames_in_backtrace() {
8+
use pyo3::prelude::PyDictMethods;
9+
use pyo3::{pyfunction, types::PyDict, Python};
10+
11+
#[pyfunction]
12+
fn produce_err_result() -> PyResult<()> {
13+
Err(PyValueError::new_err("Error result"))
14+
}
15+
16+
Python::attach(|py| {
17+
let func = wrap_pyfunction!(produce_err_result)(py).unwrap();
18+
let globals = PyDict::new(py);
19+
globals.set_item("func", func).unwrap();
20+
21+
let root_dir = format!("{:?}", std::env::current_dir().unwrap());
22+
23+
let err = py
24+
.run(
25+
c"def python_func():\n func()\n\npython_func()",
26+
Some(&globals),
27+
None,
28+
)
29+
.unwrap_err();
30+
31+
let traceback = err.traceback(py).unwrap().format().unwrap();
32+
33+
insta::with_settings!({
34+
snapshot_suffix => std::env::consts::FAMILY,
35+
filters => [
36+
(root_dir.trim_matches('"'), "."),
37+
#[cfg(unix)]
38+
("(?:/[\\w\\-\\.]*)+/library/core/src", "[RUST_CORE]"),
39+
#[cfg(windows)]
40+
("(?:(?:/rustc/\\w{40}/)|(?:[\\w\\-.:]*\\\\)+)library\\\\core\\\\src", "[RUST_CORE]"),
41+
],
42+
}, {
43+
assert_snapshot!(traceback);
44+
});
45+
});
46+
}

0 commit comments

Comments
 (0)