Skip to content

Commit 0f43fdf

Browse files
committed
WS2
codetracer-python-recorder/src/monitoring/api.rs: codetracer-python-recorder/src/monitoring/install.rs: codetracer-python-recorder/src/monitoring/mod.rs: codetracer-python-recorder/src/runtime/tracer/events.rs: codetracer-python-recorder/src/runtime/tracer/runtime_tracer.rs: codetracer-python-recorder/src/session.rs: codetracer-python-recorder/tests/python/test_exit_payloads.py: design-docs/toplevel-exit-and-trace-gating-implementation-plan.status.md: Signed-off-by: Tzanko Matev <[email protected]>
1 parent 6aa061a commit 0f43fdf

File tree

8 files changed

+257
-5
lines changed

8 files changed

+257
-5
lines changed

codetracer-python-recorder/src/monitoring/api.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ pub trait Tracer: Send + Any {
107107
Ok(())
108108
}
109109

110+
/// Provide the process exit status ahead of tracer teardown.
111+
fn set_exit_status(
112+
&mut self,
113+
_py: Python<'_>,
114+
_exit_code: Option<i32>,
115+
) -> PyResult<()> {
116+
Ok(())
117+
}
118+
110119
/// Called on resumption of a generator/coroutine (not via throw()).
111120
fn on_py_resume(
112121
&mut self,

codetracer-python-recorder/src/monitoring/install.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,11 @@ pub fn flush_installed_tracer(py: Python<'_>) -> PyResult<()> {
8282
}
8383
Ok(())
8484
}
85+
86+
/// Provide the session exit status to the active tracer if one is installed.
87+
pub fn update_exit_status(py: Python<'_>, exit_code: Option<i32>) -> PyResult<()> {
88+
if let Some(global) = GLOBAL.lock().unwrap().as_mut() {
89+
global.tracer.set_exit_status(py, exit_code)?;
90+
}
91+
Ok(())
92+
}

codetracer-python-recorder/src/monitoring/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ pub(crate) mod install;
1010
pub mod tracer;
1111

1212
pub use api::Tracer;
13-
pub use install::{flush_installed_tracer, install_tracer, uninstall_tracer};
13+
pub use install::{
14+
flush_installed_tracer,
15+
install_tracer,
16+
uninstall_tracer,
17+
update_exit_status,
18+
};
1419

1520
const MONITORING_TOOL_NAME: &str = "codetracer";
1621

codetracer-python-recorder/src/runtime/tracer/events.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,8 +439,17 @@ impl Tracer for RuntimeTracer {
439439
)
440440
}
441441

442+
fn set_exit_status(
443+
&mut self,
444+
_py: Python<'_>,
445+
exit_code: Option<i32>,
446+
) -> PyResult<()> {
447+
self.record_exit_status(exit_code);
448+
Ok(())
449+
}
450+
442451
fn notify_failure(&mut self, _py: Python<'_>) -> PyResult<()> {
443-
self.mark_failure();
452+
self.mark_disabled();
444453
Ok(())
445454
}
446455

@@ -484,6 +493,8 @@ impl Tracer for RuntimeTracer {
484493
self.mark_event();
485494
}
486495

496+
self.emit_session_exit(py);
497+
487498
if self.lifecycle.encountered_failure() {
488499
if policy.keep_partial_trace {
489500
if let Err(err) = self.lifecycle.finalise(&mut self.writer, &self.filter) {

codetracer-python-recorder/src/runtime/tracer/runtime_tracer.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,92 @@ use crate::policy::RecorderPolicy;
99
use crate::runtime::io_capture::{IoCaptureSettings, ScopedMuteIoCapture};
1010
use crate::runtime::line_snapshots::LineSnapshotStore;
1111
use crate::runtime::output_paths::TraceOutputPaths;
12+
use crate::runtime::value_encoder::encode_value;
1213
use crate::trace_filter::engine::TraceFilterEngine;
1314
use pyo3::prelude::*;
15+
use pyo3::types::{PyAny, PyInt, PyString};
1416
use runtime_tracing::NonStreamingTraceWriter;
1517
use runtime_tracing::{Line, TraceEventsFileFormat, TraceWriter};
18+
use std::borrow::Cow;
1619
use std::collections::HashMap;
1720
use std::path::Path;
1821
use std::sync::Arc;
1922
use std::thread::ThreadId;
2023

24+
#[derive(Debug)]
25+
enum ExitPayload {
26+
Code(i32),
27+
Text(Cow<'static, str>),
28+
}
29+
30+
impl ExitPayload {
31+
fn is_code(&self) -> bool {
32+
matches!(self, ExitPayload::Code(_))
33+
}
34+
35+
#[cfg(test)]
36+
fn is_text(&self, text: &str) -> bool {
37+
matches!(self, ExitPayload::Text(current) if current.as_ref() == text)
38+
}
39+
}
40+
41+
#[derive(Debug)]
42+
struct SessionExitState {
43+
payload: ExitPayload,
44+
emitted: bool,
45+
}
46+
47+
impl Default for SessionExitState {
48+
fn default() -> Self {
49+
Self {
50+
payload: ExitPayload::Text(Cow::Borrowed("<exit>")),
51+
emitted: false,
52+
}
53+
}
54+
}
55+
56+
impl SessionExitState {
57+
fn set_exit_code(&mut self, exit_code: Option<i32>) {
58+
if self.can_override_with_code() {
59+
self.payload = exit_code
60+
.map(ExitPayload::Code)
61+
.unwrap_or_else(|| ExitPayload::Text(Cow::Borrowed("<exit>")));
62+
}
63+
}
64+
65+
fn mark_disabled(&mut self) {
66+
if !self.payload.is_code() {
67+
self.payload = ExitPayload::Text(Cow::Borrowed("<disabled>"));
68+
}
69+
}
70+
71+
#[cfg(test)]
72+
fn mark_failure(&mut self) {
73+
if !self.payload.is_code() && !self.payload.is_text("<disabled>") {
74+
self.payload = ExitPayload::Text(Cow::Borrowed("<failure>"));
75+
}
76+
}
77+
78+
fn can_override_with_code(&self) -> bool {
79+
matches!(&self.payload, ExitPayload::Text(current) if current.as_ref() == "<exit>")
80+
}
81+
82+
fn as_bound<'py>(&self, py: Python<'py>) -> Bound<'py, PyAny> {
83+
match &self.payload {
84+
ExitPayload::Code(value) => PyInt::new(py, *value).into_any(),
85+
ExitPayload::Text(text) => PyString::new(py, text.as_ref()).into_any(),
86+
}
87+
}
88+
89+
fn mark_emitted(&mut self) {
90+
self.emitted = true;
91+
}
92+
93+
fn is_emitted(&self) -> bool {
94+
self.emitted
95+
}
96+
}
97+
2198
/// Minimal runtime tracer that maps Python sys.monitoring events to
2299
/// runtime_tracing writer operations.
23100
pub struct RuntimeTracer {
@@ -28,6 +105,7 @@ pub struct RuntimeTracer {
28105
pub(super) io: IoCoordinator,
29106
pub(super) filter: FilterCoordinator,
30107
pub(super) module_names: ModuleIdentityCache,
108+
session_exit: SessionExitState,
31109
}
32110

33111
impl RuntimeTracer {
@@ -49,6 +127,7 @@ impl RuntimeTracer {
49127
io: IoCoordinator::new(),
50128
filter: FilterCoordinator::new(trace_filter),
51129
module_names: ModuleIdentityCache::new(),
130+
session_exit: SessionExitState::default(),
52131
}
53132
}
54133

@@ -78,6 +157,18 @@ impl RuntimeTracer {
78157
}
79158
}
80159

160+
pub(super) fn emit_session_exit(&mut self, py: Python<'_>) {
161+
if self.session_exit.is_emitted() {
162+
return;
163+
}
164+
165+
self.flush_pending_io();
166+
let value = self.session_exit.as_bound(py);
167+
let record = encode_value(py, &mut self.writer, &value);
168+
TraceWriter::register_return(&mut self.writer, record);
169+
self.session_exit.mark_emitted();
170+
}
171+
81172
/// Configure output files and write initial metadata records.
82173
pub fn begin(&mut self, outputs: &TraceOutputPaths, start_line: u32) -> PyResult<()> {
83174
self.lifecycle
@@ -95,10 +186,21 @@ impl RuntimeTracer {
95186
self.lifecycle.mark_event();
96187
}
97188

189+
#[cfg(test)]
98190
pub(super) fn mark_failure(&mut self) {
191+
self.session_exit.mark_failure();
99192
self.lifecycle.mark_failure();
100193
}
101194

195+
pub(super) fn mark_disabled(&mut self) {
196+
self.session_exit.mark_disabled();
197+
self.lifecycle.mark_failure();
198+
}
199+
200+
pub(super) fn record_exit_status(&mut self, exit_code: Option<i32>) {
201+
self.session_exit.set_exit_code(exit_code);
202+
}
203+
102204
pub(super) fn ensure_function_id(
103205
&mut self,
104206
py: Python<'_>,
@@ -1326,6 +1428,45 @@ initializer("omega")
13261428
});
13271429
}
13281430

1431+
#[test]
1432+
fn finish_emits_toplevel_return_with_exit_code() {
1433+
Python::with_gil(|py| {
1434+
reset_policy(py);
1435+
1436+
let script_dir = tempfile::tempdir().expect("script dir");
1437+
let program_path = script_dir.path().join("program.py");
1438+
std::fs::write(&program_path, "print('hi')\n").expect("write program");
1439+
1440+
let outputs_dir = tempfile::tempdir().expect("outputs dir");
1441+
let outputs = TraceOutputPaths::new(outputs_dir.path(), TraceEventsFileFormat::Json);
1442+
1443+
let mut tracer = RuntimeTracer::new(
1444+
program_path.to_string_lossy().as_ref(),
1445+
&[],
1446+
TraceEventsFileFormat::Json,
1447+
None,
1448+
None,
1449+
);
1450+
tracer.begin(&outputs, 1).expect("begin tracer");
1451+
tracer.record_exit_status(Some(7));
1452+
1453+
tracer.finish(py).expect("finish tracer");
1454+
1455+
let mut exit_value: Option<ValueRecord> = None;
1456+
for event in &tracer.writer.events {
1457+
if let TraceLowLevelEvent::Return(record) = event {
1458+
exit_value = Some(record.return_value.clone());
1459+
}
1460+
}
1461+
1462+
let exit_value = exit_value.expect("expected toplevel return value");
1463+
match exit_value {
1464+
ValueRecord::Int { i, .. } => assert_eq!(i, 7),
1465+
other => panic!("expected integer exit value, got {other:?}"),
1466+
}
1467+
});
1468+
}
1469+
13291470
#[test]
13301471
fn trace_filter_metadata_includes_summary() {
13311472
Python::with_gil(|py| {

codetracer-python-recorder/src/session.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ use recorder_errors::{usage, ErrorCode};
1010

1111
use crate::ffi;
1212
use crate::logging::init_rust_logging_with_default;
13-
use crate::monitoring::{flush_installed_tracer, install_tracer, uninstall_tracer};
13+
use crate::monitoring::{
14+
flush_installed_tracer,
15+
install_tracer,
16+
uninstall_tracer,
17+
update_exit_status,
18+
};
1419
use crate::policy::policy_snapshot;
1520
use crate::runtime::{RuntimeTracer, TraceOutputPaths};
1621
use bootstrap::TraceSessionBootstrap;
@@ -76,8 +81,8 @@ pub fn start_tracing(
7681
#[pyfunction(signature = (exit_code=None))]
7782
pub fn stop_tracing(exit_code: Option<i32>) -> PyResult<()> {
7883
ffi::wrap_pyfunction("stop_tracing", || {
79-
let _ = exit_code;
8084
Python::with_gil(|py| {
85+
update_exit_status(py, exit_code)?;
8186
// Uninstall triggers finish() on tracer implementation.
8287
uninstall_tracer(py)?;
8388
ACTIVE.store(false, Ordering::SeqCst);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import subprocess
5+
import sys
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
from codetracer_python_recorder.trace_balance import load_trace_events
11+
12+
13+
def _last_return_value(trace_dir: Path) -> dict[str, object]:
14+
events = load_trace_events(trace_dir / "trace.json")
15+
for event in reversed(events):
16+
payload = event.get("Return")
17+
if payload is not None:
18+
return payload["return_value"]
19+
raise AssertionError("trace did not contain any Return events")
20+
21+
22+
def test_cli_records_exit_code_in_toplevel_return(tmp_path: Path) -> None:
23+
script = tmp_path / "exit_script.py"
24+
script.write_text(
25+
"import sys\n"
26+
"sys.exit(3)\n",
27+
encoding="utf-8",
28+
)
29+
30+
trace_dir = tmp_path / "trace"
31+
result = subprocess.run(
32+
[
33+
sys.executable,
34+
"-m",
35+
"codetracer_python_recorder",
36+
"--trace-dir",
37+
str(trace_dir),
38+
"--format",
39+
"json",
40+
str(script),
41+
],
42+
capture_output=True,
43+
text=True,
44+
check=False,
45+
)
46+
47+
assert result.returncode == 3, result.stderr
48+
exit_value = _last_return_value(trace_dir)
49+
assert exit_value["kind"] == "Int"
50+
assert exit_value["i"] == 3
51+
52+
53+
def test_default_exit_payload_uses_placeholder(tmp_path: Path) -> None:
54+
trace_dir = tmp_path / "trace"
55+
trace_dir.mkdir()
56+
57+
# Directly call the start/stop API without providing an exit code.
58+
script = (
59+
"import json\n"
60+
"from pathlib import Path\n"
61+
"import codetracer_python_recorder as codetracer\n"
62+
f"trace_dir = Path({json.dumps(str(trace_dir))!s})\n"
63+
"session = codetracer.start(trace_dir, format='json')\n"
64+
"session.stop()\n"
65+
)
66+
subprocess.run([sys.executable, "-c", script], check=True)
67+
68+
exit_value = _last_return_value(trace_dir)
69+
assert exit_value["kind"] == "String"
70+
assert exit_value["text"] == "<exit>"

design-docs/toplevel-exit-and-trace-gating-implementation-plan.status.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
- Verification: `just dev` (editable build with `integration-test` feature) and `just py-test` (Python suites across both recorders) pass.
2323

2424
### WS2 – Runtime Exit State & `<toplevel>` Return Emission
25-
- **Status:** _Not Started_ (blocked on WS1 API plumbing).
25+
- **Status:** _Completed_
26+
- `RuntimeTracer` now tracks a `SessionExitState`, emits the `<toplevel>` return during `finish`, and differentiates between explicit exit codes, default exits, and policy-driven disables.
27+
- Added trait plumbing (`Tracer::set_exit_status`) plus installer wiring so `stop_tracing` can forward the exit code before teardown.
28+
- Verification: `just cargo-test` (workspace) and `just py-test` exercises the new Rust test (`finish_emits_toplevel_return_with_exit_code`) and Python integration tests (`test_exit_payloads`).
2629

2730
### WS3 – Unified Trace Gate Abstraction
2831
- **Status:** _Not Started_.

0 commit comments

Comments
 (0)