Skip to content

Commit 8570ee0

Browse files
authored
Extend handled events (#56)
Before we used to only handle `PY_START`, `PY_RETURN` and `LINE` events. This lead to an unbalanced call stack in the trace, because we didn't handle exceptions, generators and coroutines properly. We do now. Here's the CHANGELOG entry: - Balanced call-stack handling for generators, coroutines, and unwinding frames by subscribing to `PY_YIELD`, `PY_UNWIND`, `PY_RESUME`, and `PY_THROW`, mapping resume/throw events to `TraceWriter::register_call`, yield/unwind to `register_return`, and capturing `PY_THROW` arguments as `exception` using the existing value encoder. Added Python + Rust integration tests that drive `.send()`/`.throw()` on coroutines and generators to guarantee the trace stays balanced and that exception payloads are recorded.
2 parents 97b900e + caacd0b commit 8570ee0

File tree

12 files changed

+925
-78
lines changed

12 files changed

+925
-78
lines changed

codetracer-python-recorder/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ All notable changes to `codetracer-python-recorder` will be documented in this f
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
- Balanced call-stack handling for generators, coroutines, and unwinding frames by subscribing to `PY_YIELD`, `PY_UNWIND`, `PY_RESUME`, and `PY_THROW`, mapping resume/throw events to `TraceWriter::register_call`, yield/unwind to `register_return`, and capturing `PY_THROW` arguments as `exception` using the existing value encoder. Added Python + Rust integration tests that drive `.send()`/`.throw()` on coroutines and generators to guarantee the trace stays balanced and that exception payloads are recorded.
810

911
## [0.2.0] - 2025-10-17
1012
### Added

codetracer-python-recorder/src/runtime/activation.rs

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ pub struct ActivationController {
1616
activation_code_id: Option<usize>,
1717
activation_done: bool,
1818
started: bool,
19+
suspended: bool,
20+
}
21+
22+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23+
pub enum ActivationExitKind {
24+
Suspended,
25+
Completed,
1926
}
2027

2128
impl ActivationController {
@@ -28,6 +35,7 @@ impl ActivationController {
2835
activation_code_id: None,
2936
activation_done: false,
3037
started,
38+
suspended: false,
3139
}
3240
}
3341

@@ -38,6 +46,7 @@ impl ActivationController {
3846
/// Ensure activation state reflects the current event and report whether
3947
/// tracing should continue processing it.
4048
pub fn should_process_event(&mut self, py: Python<'_>, code: &CodeObjectWrapper) -> bool {
49+
self.resume_if_needed(code);
4150
self.ensure_started(py, code);
4251
self.is_active()
4352
}
@@ -73,15 +82,33 @@ impl ActivationController {
7382
}
7483
}
7584

76-
/// Handle return events and turn off tracing when the activation function
77-
/// exits. Returns `true` when tracing was deactivated by this call.
78-
pub fn handle_return_event(&mut self, code_id: usize) -> bool {
79-
if self.activation_code_id == Some(code_id) {
80-
self.started = false;
81-
self.activation_done = true;
82-
return true;
85+
/// Handle activation exits, marking suspension or completion as appropriate.
86+
/// Returns `true` when tracing was deactivated by this call.
87+
pub fn handle_exit(&mut self, code_id: usize, exit: ActivationExitKind) -> bool {
88+
if self.activation_code_id != Some(code_id) {
89+
return false;
90+
}
91+
match exit {
92+
ActivationExitKind::Suspended => {
93+
self.suspended = true;
94+
false
95+
}
96+
ActivationExitKind::Completed => {
97+
self.started = false;
98+
self.activation_done = true;
99+
self.suspended = false;
100+
true
101+
}
102+
}
103+
}
104+
105+
fn resume_if_needed(&mut self, code: &CodeObjectWrapper) {
106+
if self.started
107+
&& self.suspended
108+
&& self.activation_code_id == Some(code.id())
109+
{
110+
self.suspended = false;
83111
}
84-
false
85112
}
86113
}
87114

@@ -147,12 +174,28 @@ mod tests {
147174
let mut controller = ActivationController::new(Some(Path::new("/tmp/target.py")));
148175
assert!(controller.should_process_event(py, &code));
149176
assert!(controller.is_active());
150-
assert!(controller.handle_return_event(code.id()));
177+
assert!(controller.handle_exit(code.id(), ActivationExitKind::Completed));
151178
assert!(!controller.is_active());
152179
assert!(!controller.should_process_event(py, &code));
153180
});
154181
}
155182

183+
#[test]
184+
fn suspension_keeps_tracing_active() {
185+
Python::with_gil(|py| {
186+
let code = build_code(py, "target", "/tmp/target.py");
187+
let mut controller = ActivationController::new(Some(Path::new("/tmp/target.py")));
188+
assert!(controller.should_process_event(py, &code));
189+
assert!(controller.is_active());
190+
assert!(!controller.handle_exit(code.id(), ActivationExitKind::Suspended));
191+
// While suspended, subsequent events should keep tracing alive and clear suspension
192+
assert!(controller.should_process_event(py, &code));
193+
assert!(controller.is_active());
194+
assert!(controller.handle_exit(code.id(), ActivationExitKind::Completed));
195+
assert!(!controller.is_active());
196+
});
197+
}
198+
156199
#[test]
157200
fn start_path_prefers_activation_path() {
158201
let controller = ActivationController::new(Some(Path::new("/tmp/target.py")));
@@ -164,6 +207,6 @@ mod tests {
164207
impl ActivationController {
165208
#[allow(dead_code)]
166209
pub fn handle_return(&mut self, code_id: usize) -> bool {
167-
self.handle_return_event(code_id)
210+
self.handle_exit(code_id, ActivationExitKind::Completed)
168211
}
169212
}

0 commit comments

Comments
 (0)