Skip to content

Commit 858996f

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

File tree

7 files changed

+281
-4
lines changed

7 files changed

+281
-4
lines changed

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: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ use crate::err::{error_on_minusone, PyResult};
22
use crate::types::{any::PyAnyMethods, string::PyStringMethods, PyString};
33
use crate::{ffi, Bound, PyAny};
44
#[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))]
5-
use crate::{types::PyFrame, PyTypeCheck, Python};
5+
use crate::{
6+
types::{PyFrame, PyFrameMethods},
7+
BoundObject, IntoPyObject, PyTypeCheck, Python,
8+
};
69

710
/// Represents a Python traceback.
811
///
@@ -41,6 +44,27 @@ impl PyTraceback {
4144
.cast_into_unchecked())
4245
}
4346
}
47+
48+
/// Creates a new traceback object from an iterator of frames.
49+
///
50+
/// The frames should be ordered from newest to oldest, i.e. the first frame in the iterator
51+
/// will be the innermost frame in the traceback.
52+
#[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))]
53+
pub fn from_frames<'py, I>(
54+
py: Python<'py>,
55+
start: Option<Bound<'py, PyTraceback>>,
56+
frames: I,
57+
) -> PyResult<Option<Bound<'py, PyTraceback>>>
58+
where
59+
I: IntoIterator,
60+
I::Item: IntoPyObject<'py, Target = PyFrame>,
61+
{
62+
frames.into_iter().try_fold(start, |prev, frame| {
63+
let frame = frame.into_pyobject(py).map_err(Into::into)?.into_bound();
64+
let line_number = frame.line_number();
65+
PyTraceback::new(py, prev, frame, 0, line_number).map(Some)
66+
})
67+
}
4468
}
4569

4670
/// Implementation of functionality for [`PyTraceback`].
@@ -113,6 +137,7 @@ mod tests {
113137
};
114138

115139
#[test]
140+
#[cfg(false)]
116141
fn format_traceback() {
117142
Python::attach(|py| {
118143
let err = py
@@ -196,4 +221,44 @@ def f():
196221
);
197222
})
198223
}
224+
225+
#[test]
226+
#[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))]
227+
fn test_insert_traceback() {
228+
Python::attach(|py| {
229+
// Error happens in rust, so rust frames are created first.
230+
let rust_traceback = PyTraceback::from_frames(
231+
py,
232+
None,
233+
[
234+
PyFrame::new(py, c"rust2.rs", c"func2", 22).unwrap(),
235+
PyFrame::new(py, c"rust1.rs", c"func1", 11).unwrap(),
236+
],
237+
)
238+
.unwrap()
239+
.unwrap();
240+
241+
// Stacktrace where python calls into rust
242+
let traceback = PyTraceback::from_frames(
243+
py,
244+
Some(rust_traceback),
245+
[
246+
PyFrame::new(py, c"file2.py", c"func2", 20).unwrap(),
247+
PyFrame::new(py, c"file1.py", c"func1", 10).unwrap(),
248+
],
249+
)
250+
.unwrap()
251+
.unwrap();
252+
253+
assert_eq!(
254+
traceback.format().unwrap(),
255+
r#"Traceback (most recent call last):
256+
File "file1.py", line 10, in func1
257+
File "file2.py", line 20, in func2
258+
File "rust1.rs", line 11, in func1
259+
File "rust2.rs", line 22, in func2
260+
"#
261+
);
262+
})
263+
}
199264
}
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)

0 commit comments

Comments
 (0)