@@ -9,15 +9,92 @@ use crate::policy::RecorderPolicy;
99use crate :: runtime:: io_capture:: { IoCaptureSettings , ScopedMuteIoCapture } ;
1010use crate :: runtime:: line_snapshots:: LineSnapshotStore ;
1111use crate :: runtime:: output_paths:: TraceOutputPaths ;
12+ use crate :: runtime:: value_encoder:: encode_value;
1213use crate :: trace_filter:: engine:: TraceFilterEngine ;
1314use pyo3:: prelude:: * ;
15+ use pyo3:: types:: { PyAny , PyInt , PyString } ;
1416use runtime_tracing:: NonStreamingTraceWriter ;
1517use runtime_tracing:: { Line , TraceEventsFileFormat , TraceWriter } ;
18+ use std:: borrow:: Cow ;
1619use std:: collections:: HashMap ;
1720use std:: path:: Path ;
1821use std:: sync:: Arc ;
1922use 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.
23100pub 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
33111impl 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| {
0 commit comments