Skip to content

Update test_sys from v3.14.3 and impl more sys module#7075

Open
youknowone wants to merge 9 commits intoRustPython:mainfrom
youknowone:sys
Open

Update test_sys from v3.14.3 and impl more sys module#7075
youknowone wants to merge 9 commits intoRustPython:mainfrom
youknowone:sys

Conversation

@youknowone
Copy link
Member

@youknowone youknowone commented Feb 10, 2026

Summary by CodeRabbit

  • New Features

    • Added sys.monitoring module for runtime execution monitoring with tool-based event system
    • Added fallback support for incremental codec encoding/decoding when primary codecs are unavailable
  • Improvements

    • Enhanced line attribution accuracy for tracebacks and error reporting
    • Improved exception tracking and handling across threaded environments
    • Refined NOP instruction handling for better line event accuracy

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces a comprehensive interpreter-level event monitoring subsystem with per-code-object event masks and callback dispatch, refactors frame ownership from reference-counted to non-owning pointers with per-thread tracking, and modifies exception handling to use per-frame stacks alongside extensive instrumentation throughout frame execution.

Changes

Cohort / File(s) Summary
Spell Check Dictionary
.cspell.dict/cpython.txt
Added "bltin" token to CPython dictionary.
Codegen Line Markers
crates/codegen/src/compile.rs, crates/codegen/src/ir.rs
Modified NOP emission and removal strategy to emit NOPs at entry points for LINE events and preserve line-marker NOPs while removing redundant ones; changes finalize_code logic to compute per-block non-NOP lines.
Pymodule Nested Discovery
crates/derive-impl/src/pymodule.rs
Added detection and initialization of nested #[pymodule] items, constructing submodule init blocks and injecting them into __init_attributes flow.
Frame Tracking & Fault Handling
crates/stdlib/src/faulthandler.rs, crates/vm/src/builtins/frame.rs, crates/vm/src/stdlib/thread.rs
Changed per-thread frame slot type to ThreadSlot; updated dump_frame_from_ref signature from PyRef to Py; modified f_back implementation to search in-memory and per-thread frame registries; updated all frame access patterns.
Frame Execution Instrumentation
crates/vm/src/frame.rs
Added extensive runtime monitoring with event firing gates (LINE, INSTRUCTION, JUMP, BRANCH, CALL, C_RETURN, C_RAISE, YIELD, exception events) at numerous execution points; tracks line changes and branches with per-frame/global monitoring masks; stores prev_line as u32 to support extended tracking.
Monitoring System
crates/vm/src/stdlib/sys/monitoring.rs
New comprehensive subsystem with MonitoringState, per-tool callbacks, per-code-object event masks, and 19 event types; includes thread-local FIRING guards, composite event logic, and Python exposure via sys.monitoring module with tool/event/callback management functions.
VM Frame & State Refactor
crates/vm/src/vm/mod.rs, crates/vm/src/vm/interpreter.rs, crates/vm/src/vm/thread.rs
Introduced FramePtr (non-owning pointer) replacing FrameRef ownership in VM.frames; added ThreadSlot struct with per-thread frames and atomic exception; changed exception stack model from linked structure to Vec<Option>; added monitoring fields and methods (with_frame, resume_gen_frame); early Latin-1 codec bootstrap for stdio.
Sys Module Monitoring & Functions
crates/vm/src/stdlib/sys/mod.rs
Added monitoring submodule wiring, call_tracing function, _current_exceptions (with threading variants), and getters for context_aware_warnings/thread_inherit_context; removed FlagsData fields and exposed them as pygetset accessors; updated exit() to handle tuple arguments; improved _getframe bounds checking.
Coroutine Exception State
crates/vm/src/coroutine.rs
Changed exception field from PyMutex<Option> to PyAtomicRef<Option>; updated run_with_context signature to accept &FrameRef; switched to resume_gen_frame path with atomic exception swapping.
IO Stateless Encoders/Decoders
crates/vm/src/stdlib/io.rs
Added StatelessIncrementalEncoder and StatelessIncrementalDecoder pyclass fallbacks with encode/decode methods for when incremental objects are unavailable; preserves error handling and name property for encoder.
Execution & Traceback Support
crates/vm/src/vm/python_run.rs
Added linecache-based source caching for "" sources via cache_source_for_traceback; enables traceback support mirroring CPython behavior.
Protocol & Callable Refactoring
crates/vm/src/protocol/callable.rs, crates/vm/src/stdlib/builtins.rs, crates/vm/src/warn.rs
Simplified frame-extraction patterns using current_frame() directly; removed AsObject from public imports; eliminated unnecessary clones of current_globals(); streamlined frame presence checks across call tracing and event dispatch.

Sequence Diagram(s)

Skipped—changes span heterogeneous subsystems (codegen, frame management, monitoring, stdlib) without a single coherent control flow suitable for visualization.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~105 minutes

Possibly related PRs

  • RustPython/RustPython#6949: Overlapping changes to faulthandler, frame tracking, and per-thread frame slots with frame ownership refactoring.
  • RustPython/RustPython#7015: Direct code-level overlap in codegen source-range/line-marker handling and frame/coroutine exception state changes.
  • RustPython/RustPython#6869: Overlapping codegen pipeline modifications related to instruction emission and NOP/line-marker handling.

Suggested reviewers

  • ShaharNaveh
  • fanninpm

Poem

🐰 Hops through frames with eager sight,
Events fire left and right,
Monitoring threads both near and far,
Line markers guide our bytecode star,
With atomic swaps, the state runs true!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Update test_sys from v3.14.3 and impl more sys module' directly reflects the main changes: updating test_sys and implementing additional sys module functionality. This is clearly supported by the PR which updates sys module infrastructure (monitoring, current exceptions, thread slots) and test-related modifications.
Docstring Coverage ✅ Passed Docstring coverage is 96.50% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Contributor

Code has been automatically formatted

The code in this PR has been formatted using:

  • cargo fmt --all
    Please pull the latest changes before pushing again:
git pull origin sys

@youknowone youknowone force-pushed the sys branch 4 times, most recently from 71af7b0 to 33b66f9 Compare February 12, 2026 07:58
@youknowone youknowone marked this pull request as ready for review February 14, 2026 00:16
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/vm/src/frame.rs (1)

788-829: ⚠️ Potential issue | 🟠 Major

Handle monitoring callback errors in gen_throw consistently with other code paths.

In the run function, monitoring callback errors from fire_raise and fire_py_unwind replace the original exception. However, in gen_throw, errors from fire_py_throw, fire_raise, and fire_py_unwind are silently ignored with let _, creating an inconsistency. Apply the same error-handling pattern to all monitoring callbacks here.

🤖 Fix all issues with AI agents
In `@crates/vm/src/builtins/frame.rs`:
- Around line 197-235: Extract the duplicated pointer-comparison logic in f_back
into a small helper (e.g., find_frame_by_ptr or frame_matches_ptr) that takes an
iterator/collection of &PyRef<Frame> (or a closure to produce such an iterator)
and the previous pointer from previous_frame(), performs the pointer equality
using the correct deref chain (&***f) and returns Option<PyRef<Frame>>; then
call this helper for vm.frames.borrow().iter() and for each
slot.frames.lock().iter() inside the threading block, replacing the duplicated
closures (do not use &****f).

In `@crates/vm/src/stdlib/io.rs`:
- Around line 2619-2679: The tuple validation in
StatelessIncrementalEncoder::encode and StatelessIncrementalDecoder::decode
relies on try_into_value(), which produces different TypeError messages than the
codec helpers; update both methods to perform the same downcast + length check
used in crate::codecs::{encode, decode} (or call those helpers) so non-tuple
inputs and wrong-length tuples produce the identical CPython-compatible error
text. Concretely, replace res.try_into_value(vm)? with
res.downcast::<PyTupleRef>(vm).map_err(|_| vm.new_type_error("encoder must
return a tuple (object, integer)")) (and the corresponding decoder message for
decode) and then check tuple.len() exactly as in the codec helpers to keep
messages and behavior consistent.

In `@crates/vm/src/stdlib/sys/monitoring.rs`:
- Around line 371-378: The is_disable function currently checks only the class
name and .is_none(), which treats both DISABLE and MISSING (both
MonitoringSentinel/_Sentinel) as disable; change it to perform an identity
comparison against the actual DISABLE singleton instead: obtain the DISABLE
object (either by fetching the module-level DISABLE singleton or by caching it
on MonitoringState like missing) and return obj.is(disable_obj) (or
pointer/identity equality) rather than comparing class name; update is_disable
to use that cached/obtained DISABLE symbol so MISSING is not misclassified.

In `@crates/vm/src/vm/python_run.rs`:
- Around line 47-62: cache_source_for_traceback currently builds the `lines`
list via split_inclusive('\n') which can leave the final line without a trailing
newline; update the logic in cache_source_for_traceback to post-process the
collected `lines` (before converting to Py objects) and ensure the last element
ends with '\n' (append one if missing) so the resulting tuple `entry` matches
CPython's linecache invariant `(size, mtime=None, lines, path)` with every line
ending in newline; keep the existing creation of `entry` and call to
cache.set_item as-is.
🧹 Nitpick comments (8)
crates/derive-impl/src/pymodule.rs (1)

233-243: Generated submodule init code panics on failure with no diagnostic context.

The four .unwrap() calls in the generated block will panic at runtime without any indication of which submodule failed or why. While the rest of the file also uses .unwrap() for __module_set_attr, those are single-attribute operations; here you have an entire module lifecycle (create_module__init_methodsmodule_execset_attr) that could fail at multiple stages.

Consider using .expect(...) with the submodule name to improve debuggability:

Suggested improvement
 submodule_inits.push(quote! {
     #(`#cfgs`)*
     {
         let child_def = `#mod_ident`::module_def(ctx);
-        let child = child_def.create_module(vm).unwrap();
-        child.__init_methods(vm).unwrap();
-        `#mod_ident`::module_exec(vm, &child).unwrap();
+        let child = child_def.create_module(vm)
+            .expect(concat!("failed to create submodule ", `#py_name`));
+        child.__init_methods(vm)
+            .expect(concat!("failed to init methods for submodule ", `#py_name`));
+        `#mod_ident`::module_exec(vm, &child)
+            .expect(concat!("failed to exec submodule ", `#py_name`));
         let child: ::rustpython_vm::PyObjectRef = child.into();
         vm.__module_set_attr(module, ctx.intern_str(`#py_name`), child).unwrap();
     }
 });
crates/codegen/src/ir.rs (1)

247-273: Second NOP-removal pass looks correct; consider inlining a use for HashSet.

The two-pass approach makes sense: remove_nops() (line 195) handles pre-existing NOPs before optimization passes, while this pass handles NOPs freshly created by convert_pseudo_ops and label_exception_targets. The set-based deduplication logic is sound — a NOP is kept only when no real instruction covers its line and no other NOP for that line was already retained.

Minor nit: std::collections::HashSet is spelled out three times. A local use would reduce noise.

♻️ Optional: local import for readability
+        use std::collections::HashSet;
         // Collect lines that have non-NOP instructions in this block
-        let non_nop_lines: std::collections::HashSet<_> = block
+        let non_nop_lines: HashSet<_> = block
             .instructions
             .iter()
             .filter(|ins| !matches!(ins.instr.real(), Some(Instruction::Nop)))
             .map(|ins| ins.location.line)
             .collect();
-        let mut kept_nop_lines: std::collections::HashSet<OneIndexed> =
-            std::collections::HashSet::new();
+        let mut kept_nop_lines: HashSet<OneIndexed> = HashSet::new();
crates/vm/src/stdlib/thread.rs (1)

890-895: Avoid holding the registry lock while locking per-thread frames.

thread_frames.lock() is held while slot.frames.lock() is acquired for each entry. This creates lock contention and prevents thread registration/cleanup during the snapshot. The faulthandler.rs timeout handler demonstrates the correct pattern: clone slot references under the registry lock, drop the registry lock, then lock individual slot frames.

♻️ Suggested refactor (drop registry lock before per-slot locks)
-        let registry = vm.state.thread_frames.lock();
-        registry
-            .iter()
-            .filter_map(|(id, slot)| slot.frames.lock().last().cloned().map(|f| (*id, f)))
-            .collect()
+        let registry = vm.state.thread_frames.lock();
+        let slots: Vec<_> = registry.iter().map(|(id, slot)| (*id, slot.clone())).collect();
+        drop(registry);
+        slots
+            .into_iter()
+            .filter_map(|(id, slot)| slot.frames.lock().last().cloned().map(|f| (id, f)))
+            .collect()
crates/vm/src/frame.rs (1)

466-503: Shared prev_line between tracing and monitoring may suppress first Line event after mid-frame settrace() if still on the same line.

The code unconditionally updates prev_line at line 502 outside the monitoring_mask check. While this is intentional (per the comment on line 484 to prevent spurious LINE events when monitoring is enabled mid-function), it means if sys.settrace() is called mid-frame while still on the same line, the first Line event will not fire until the line changes. This design prioritizes shared state efficiency over system independence. Clarify whether this matches CPython 3.12+ behavior; if stricter separation is needed, track prev_trace_line independently for tracing while retaining prev_line for monitoring's mid-function safety.

crates/vm/src/stdlib/sys/monitoring.rs (4)

430-451: FIRING guard is not panic-safe — use a RAII drop guard instead.

If a Rust panic occurs inside the callback loop (lines 432–448), line 450 is never reached and FIRING stays true for the thread permanently, silently suppressing all future monitoring events on that thread.

Proposed fix using a drop guard
+struct FiringGuard;
+impl FiringGuard {
+    fn new() -> Self {
+        FIRING.with(|f| f.set(true));
+        Self
+    }
+}
+impl Drop for FiringGuard {
+    fn drop(&mut self) {
+        FIRING.with(|f| f.set(false));
+    }
+}
+
 // ... inside fire_event_inner:
-    FIRING.with(|f| f.set(true));
-    let result = (|| {
+    let _guard = FiringGuard::new();
+    let result = (|| {
         for (tool, cb) in callbacks {
             // ...
         }
         Ok(())
     })();
-    FIRING.with(|f| f.set(false));
     result

265-275: Replace magic event-ID literals with derived constants.

18, 8, 9 correspond to the bit positions of BRANCH, BRANCH_LEFT, and BRANCH_RIGHT respectively, but bare literals are fragile and will silently break if bit assignments change.

Proposed fix

You could define constants at the module level:

const BRANCH_EVENT_ID: usize = MonitoringEvents::BRANCH.bits().trailing_zeros() as usize;
const BRANCH_LEFT_EVENT_ID: usize = MonitoringEvents::BRANCH_LEFT.bits().trailing_zeros() as usize;
const BRANCH_RIGHT_EVENT_ID: usize = MonitoringEvents::BRANCH_RIGHT.bits().trailing_zeros() as usize;

Then replace all magic numbers in register_callback and the fire_* functions accordingly.


356-356: Remove decorative section separator.

As per coding guidelines, "Do not add decorative section separators (e.g. // -----------, // ===, /* *** */). Use /// doc-comments or short // comments only when they add value".


457-726: Consider a macro to reduce boilerplate across fire_* functions.

All 17 fire_* functions follow the same pattern: extract code_id, build FuncArgs, call fire_event_inner. The differences are the event ID, event bit constant, and the argument list. A declarative macro could eliminate the repetition while keeping each variant's distinct arguments clear.

This is a readability/maintainability suggestion — no functional concern.

Comment on lines 197 to 235
pub fn f_back(&self, vm: &VirtualMachine) -> Option<PyRef<Frame>> {
// TODO: actually store f_back inside Frame struct
let previous = self.previous_frame();
if previous.is_null() {
return None;
}

// get the frame in the frame stack that appears before this one.
// won't work if this frame isn't in the frame stack, hence the todo above
vm.frames
if let Some(frame) = vm
.frames
.borrow()
.iter()
.rev()
.skip_while(|p| !p.is(self.as_object()))
.nth(1)
.find(|f| {
let ptr: *const Frame = &****f;
core::ptr::eq(ptr, previous)
})
.cloned()
{
return Some(frame);
}

#[cfg(feature = "threading")]
{
let registry = vm.state.thread_frames.lock();
for slot in registry.values() {
if let Some(frame) = slot
.frames
.lock()
.iter()
.find(|f| {
let ptr: *const Frame = &****f;
core::ptr::eq(ptr, previous)
})
.cloned()
{
return Some(frame);
}
}
}

None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check previous_frame() return type
echo "=== previous_frame() signature ==="
rg -n 'fn previous_frame' crates/vm/src/builtins/frame.rs -C 5

# Check Frame struct definition and Deref impls
echo "=== Frame struct definition ==="
rg -n 'pub struct Frame' crates/vm/src/builtins/frame.rs -C 3

# Check for Deref impl on Frame
echo "=== Deref implementations for Frame ==="
rg -n 'impl.*Deref.*Frame' crates/vm/src/builtins/frame.rs -C 3

# Check vm.frames type
echo "=== vm.frames field definition ==="
rg -n 'frames:' crates/vm/src/vm.rs -C 2

# Check the exact types involved - get PyRef definition
echo "=== Check FrameRef type alias ==="
rg -n 'type FrameRef' crates/vm/src/builtins/frame.rs -C 1

Repository: RustPython/RustPython

Length of output: 317


🏁 Script executed:

#!/bin/bash
# List the top-level structure
echo "=== Repository structure ==="
git ls-files | head -30

# Find files named frame.rs or vm.rs
echo "=== Finding frame.rs ==="
find . -name "frame.rs" -type f 2>/dev/null

echo "=== Finding vm.rs ==="
find . -name "vm.rs" -type f 2>/dev/null

# Check crates directory structure
echo "=== Crates directory ==="
ls -la crates/ 2>/dev/null | head -20

Repository: RustPython/RustPython

Length of output: 2216


🏁 Script executed:

#!/bin/bash
# Examine the frame.rs file around the relevant lines
echo "=== previous_frame() and surrounding code in frame.rs ==="
sed -n '1,50p' crates/vm/src/builtins/frame.rs | head -40

echo "=== Frame struct definition ==="
rg -n 'pub struct Frame' crates/vm/src/builtins/frame.rs -A 15

echo "=== FrameRef type alias ==="
rg -n 'FrameRef\|type.*Frame' crates/vm/src/builtins/frame.rs | head -10

echo "=== previous_frame method ==="
rg -n 'fn previous_frame' crates/vm/src/builtins/frame.rs -A 3

Repository: RustPython/RustPython

Length of output: 1197


🏁 Script executed:

#!/bin/bash
# Check size of frame.rs
wc -l crates/vm/src/frame.rs

# Search for previous_frame in frame.rs
echo "=== previous_frame in crates/vm/src/frame.rs ==="
rg -n 'fn previous_frame' crates/vm/src/frame.rs -A 5

# Search for Frame struct definition
echo "=== Frame struct in crates/vm/src/frame.rs ==="
rg -n 'pub struct Frame' crates/vm/src/frame.rs -A 20

Repository: RustPython/RustPython

Length of output: 1200


🏁 Script executed:

#!/bin/bash
# Search for FrameRef type definition
echo "=== FrameRef definition in frame.rs ==="
rg -n 'type FrameRef\|pub type FrameRef' crates/vm/src/frame.rs

# Check if Frame implements Deref
echo "=== Deref impl for Frame ==="
rg -n 'impl.*Deref.*for Frame' crates/vm/src/frame.rs -A 5

# Find vm.frames definition (search in lib.rs or vm.rs equivalent)
echo "=== Looking for VirtualMachine struct and frames field ==="
fd -t f -n 'lib.rs' crates/vm/src/ | head -5

# Check if there's a VirtualMachine definition
rg -n 'pub struct VirtualMachine' crates/vm/src/ -A 30 | head -50

Repository: RustPython/RustPython

Length of output: 2689


🏁 Script executed:

#!/bin/bash
# Search for FrameRef definition
echo "=== FrameRef type definition ==="
rg -n 'FrameRef' crates/vm/src/frame.rs | grep -E 'type|pub' | head -5

# More specific search
echo "=== Searching in frame.rs for FrameRef ==="
grep -n 'FrameRef' crates/vm/src/frame.rs | head -10

# Search across entire vm crate
echo "=== FrameRef definition anywhere in vm ==="
rg -n '^pub type FrameRef' crates/vm/src/

Repository: RustPython/RustPython

Length of output: 487


🏁 Script executed:

#!/bin/bash
# Now that we know FrameRef = PyRef<Frame>, let's verify the deref chain
# Check if Frame implements Deref
echo "=== All impl blocks for Frame ==="
rg -n 'impl.*Frame' crates/vm/src/frame.rs | head -20

# Get the full deref chain visualization - read around the f_back method
echo "=== The f_back method implementation ==="
sed -n '197,240p' crates/vm/src/builtins/frame.rs

Repository: RustPython/RustPython

Length of output: 1574


Extract the frame-matching logic to a helper to avoid duplication, and fix the deref depth.

The pointer-comparison closure is duplicated between the vm.frames search (lines 208–210) and the thread-frames search (lines 225–227). Per coding guidelines, extract the differing iterator source and reuse the comparison logic.

Additionally, the deref chain &****f appears incorrect. Given f: &PyRef<Frame> from the iterator:

  • *f&PyRef<Frame>
  • **f&Py<Frame> (via PyRef::Deref)
  • ***f&Frame (via Py::Deref)

The correct pointer should be &***f, not &****f. Unless Frame implements Deref, the fourth dereference is invalid.

🤖 Prompt for AI Agents
In `@crates/vm/src/builtins/frame.rs` around lines 197 - 235, Extract the
duplicated pointer-comparison logic in f_back into a small helper (e.g.,
find_frame_by_ptr or frame_matches_ptr) that takes an iterator/collection of
&PyRef<Frame> (or a closure to produce such an iterator) and the previous
pointer from previous_frame(), performs the pointer equality using the correct
deref chain (&***f) and returns Option<PyRef<Frame>>; then call this helper for
vm.frames.borrow().iter() and for each slot.frames.lock().iter() inside the
threading block, replacing the duplicated closures (do not use &****f).

Comment on lines +2619 to +2679
fn encode(
&self,
input: PyObjectRef,
_final: OptionalArg<bool>,
vm: &VirtualMachine,
) -> PyResult {
let mut args: Vec<PyObjectRef> = vec![input];
if let Some(errors) = &self.errors {
args.push(errors.to_owned().into());
}
let res = self.encode.call(args, vm)?;
let tuple: PyTupleRef = res.try_into_value(vm)?;
if tuple.len() != 2 {
return Err(vm.new_type_error("encoder must return a tuple (object, integer)"));
}
Ok(tuple[0].clone())
}

#[pymethod]
fn reset(&self) {}

#[pymethod]
fn setstate(&self, _state: PyObjectRef) {}

#[pymethod]
fn getstate(&self, vm: &VirtualMachine) -> PyObjectRef {
vm.ctx.new_int(0).into()
}

#[pygetset]
fn name(&self) -> Option<PyStrRef> {
self.name.clone()
}
}

#[pyclass(module = "_io", name, no_attr)]
#[derive(Debug, PyPayload)]
struct StatelessIncrementalDecoder {
decode: PyObjectRef,
errors: Option<PyStrRef>,
}

#[pyclass]
impl StatelessIncrementalDecoder {
#[pymethod]
fn decode(
&self,
input: PyObjectRef,
_final: OptionalArg<bool>,
vm: &VirtualMachine,
) -> PyResult {
let mut args: Vec<PyObjectRef> = vec![input];
if let Some(errors) = &self.errors {
args.push(errors.to_owned().into());
}
let res = self.decode.call(args, vm)?;
let tuple: PyTupleRef = res.try_into_value(vm)?;
if tuple.len() != 2 {
return Err(vm.new_type_error("decoder must return a tuple (object, integer)"));
}
Ok(tuple[0].clone())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align stateless encode/decode tuple validation with codecs.rs.

try_into_value() yields different TypeError messages for non-tuples, and the decoder message currently differs from the codec helper. To preserve CPython-compatible errors and keep behavior consistent with crate::codecs::{encode, decode}, mirror the same downcast/len check (or reuse those helpers).

🔧 Suggested fix (consistent tuple validation + error messages)
-            let tuple: PyTupleRef = res.try_into_value(vm)?;
-            if tuple.len() != 2 {
-                return Err(vm.new_type_error("encoder must return a tuple (object, integer)"));
-            }
-            Ok(tuple[0].clone())
+            let tuple = res
+                .downcast::<PyTuple>()
+                .ok()
+                .filter(|t| t.len() == 2)
+                .ok_or_else(|| vm.new_type_error("encoder must return a tuple (object, integer)"))?;
+            Ok(tuple[0].clone())
-            let tuple: PyTupleRef = res.try_into_value(vm)?;
-            if tuple.len() != 2 {
-                return Err(vm.new_type_error("decoder must return a tuple (object, integer)"));
-            }
-            Ok(tuple[0].clone())
+            let tuple = res
+                .downcast::<PyTuple>()
+                .ok()
+                .filter(|t| t.len() == 2)
+                .ok_or_else(|| vm.new_type_error("decoder must return a tuple (object,integer)"))?;
+            Ok(tuple[0].clone())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn encode(
&self,
input: PyObjectRef,
_final: OptionalArg<bool>,
vm: &VirtualMachine,
) -> PyResult {
let mut args: Vec<PyObjectRef> = vec![input];
if let Some(errors) = &self.errors {
args.push(errors.to_owned().into());
}
let res = self.encode.call(args, vm)?;
let tuple: PyTupleRef = res.try_into_value(vm)?;
if tuple.len() != 2 {
return Err(vm.new_type_error("encoder must return a tuple (object, integer)"));
}
Ok(tuple[0].clone())
}
#[pymethod]
fn reset(&self) {}
#[pymethod]
fn setstate(&self, _state: PyObjectRef) {}
#[pymethod]
fn getstate(&self, vm: &VirtualMachine) -> PyObjectRef {
vm.ctx.new_int(0).into()
}
#[pygetset]
fn name(&self) -> Option<PyStrRef> {
self.name.clone()
}
}
#[pyclass(module = "_io", name, no_attr)]
#[derive(Debug, PyPayload)]
struct StatelessIncrementalDecoder {
decode: PyObjectRef,
errors: Option<PyStrRef>,
}
#[pyclass]
impl StatelessIncrementalDecoder {
#[pymethod]
fn decode(
&self,
input: PyObjectRef,
_final: OptionalArg<bool>,
vm: &VirtualMachine,
) -> PyResult {
let mut args: Vec<PyObjectRef> = vec![input];
if let Some(errors) = &self.errors {
args.push(errors.to_owned().into());
}
let res = self.decode.call(args, vm)?;
let tuple: PyTupleRef = res.try_into_value(vm)?;
if tuple.len() != 2 {
return Err(vm.new_type_error("decoder must return a tuple (object, integer)"));
}
Ok(tuple[0].clone())
fn encode(
&self,
input: PyObjectRef,
_final: OptionalArg<bool>,
vm: &VirtualMachine,
) -> PyResult {
let mut args: Vec<PyObjectRef> = vec![input];
if let Some(errors) = &self.errors {
args.push(errors.to_owned().into());
}
let res = self.encode.call(args, vm)?;
let tuple = res
.downcast::<PyTuple>()
.ok()
.filter(|t| t.len() == 2)
.ok_or_else(|| vm.new_type_error("encoder must return a tuple (object, integer)"))?;
Ok(tuple[0].clone())
}
#[pymethod]
fn reset(&self) {}
#[pymethod]
fn setstate(&self, _state: PyObjectRef) {}
#[pymethod]
fn getstate(&self, vm: &VirtualMachine) -> PyObjectRef {
vm.ctx.new_int(0).into()
}
#[pygetset]
fn name(&self) -> Option<PyStrRef> {
self.name.clone()
}
}
#[pyclass(module = "_io", name, no_attr)]
#[derive(Debug, PyPayload)]
struct StatelessIncrementalDecoder {
decode: PyObjectRef,
errors: Option<PyStrRef>,
}
#[pyclass]
impl StatelessIncrementalDecoder {
#[pymethod]
fn decode(
&self,
input: PyObjectRef,
_final: OptionalArg<bool>,
vm: &VirtualMachine,
) -> PyResult {
let mut args: Vec<PyObjectRef> = vec![input];
if let Some(errors) = &self.errors {
args.push(errors.to_owned().into());
}
let res = self.decode.call(args, vm)?;
let tuple = res
.downcast::<PyTuple>()
.ok()
.filter(|t| t.len() == 2)
.ok_or_else(|| vm.new_type_error("decoder must return a tuple (object, integer)"))?;
Ok(tuple[0].clone())
🤖 Prompt for AI Agents
In `@crates/vm/src/stdlib/io.rs` around lines 2619 - 2679, The tuple validation in
StatelessIncrementalEncoder::encode and StatelessIncrementalDecoder::decode
relies on try_into_value(), which produces different TypeError messages than the
codec helpers; update both methods to perform the same downcast + length check
used in crate::codecs::{encode, decode} (or call those helpers) so non-tuple
inputs and wrong-length tuples produce the identical CPython-compatible error
text. Concretely, replace res.try_into_value(vm)? with
res.downcast::<PyTupleRef>(vm).map_err(|_| vm.new_type_error("encoder must
return a tuple (object, integer)")) (and the corresponding decoder message for
decode) and then check tuple.len() exactly as in the codec helpers to keep
messages and behavior consistent.

Comment on lines +286 to +294
fn set_events(tool_id: i32, event_set: i32, vm: &VirtualMachine) -> PyResult<()> {
let tool = check_valid_tool(tool_id, vm)?;
check_tool_in_use(tool, vm)?;
let normalized = normalize_event_set(event_set, false, vm)?;
let mut state = vm.state.monitoring.lock();
state.global_events[tool] = normalized;
update_events_mask(vm, &state);
Ok(())
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

TOCTOU race: check_tool_in_use releases the lock before set_events re-acquires it.

check_tool_in_use (line 288) locks, verifies the tool is registered, and unlocks. Then line 290 locks again to mutate. Between the two lock acquisitions another thread can call free_tool_id, removing the tool. The same pattern exists in set_local_events (lines 319–323) and free_tool_id (lines 236–237, where clear_tool_id locks/unlocks before the second lock).

Fold the check into the critical section so validation and mutation happen under a single lock hold.

Sketch for set_events
 fn set_events(tool_id: i32, event_set: i32, vm: &VirtualMachine) -> PyResult<()> {
     let tool = check_valid_tool(tool_id, vm)?;
-    check_tool_in_use(tool, vm)?;
     let normalized = normalize_event_set(event_set, false, vm)?;
     let mut state = vm.state.monitoring.lock();
+    if state.tool_names[tool].is_none() {
+        return Err(vm.new_value_error(format!("tool {tool} is not in use")));
+    }
     state.global_events[tool] = normalized;
     update_events_mask(vm, &state);
     Ok(())
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn set_events(tool_id: i32, event_set: i32, vm: &VirtualMachine) -> PyResult<()> {
let tool = check_valid_tool(tool_id, vm)?;
check_tool_in_use(tool, vm)?;
let normalized = normalize_event_set(event_set, false, vm)?;
let mut state = vm.state.monitoring.lock();
state.global_events[tool] = normalized;
update_events_mask(vm, &state);
Ok(())
}
fn set_events(tool_id: i32, event_set: i32, vm: &VirtualMachine) -> PyResult<()> {
let tool = check_valid_tool(tool_id, vm)?;
let normalized = normalize_event_set(event_set, false, vm)?;
let mut state = vm.state.monitoring.lock();
if state.tool_names[tool].is_none() {
return Err(vm.new_value_error(format!("tool {tool} is not in use")));
}
state.global_events[tool] = normalized;
update_events_mask(vm, &state);
Ok(())
}

Comment on lines +371 to +378
/// Check if DISABLE sentinel was returned by a callback.
fn is_disable(obj: &PyObjectRef, vm: &VirtualMachine) -> bool {
// DISABLE is the _Sentinel singleton stored on the monitoring module.
// We check its type name to avoid needing a reference to the exact object.
let name = obj.class().name();
let name_str: &str = &name;
name_str == "_Sentinel" && !vm.is_none(obj)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

is_disable cannot distinguish DISABLE from MISSING — both are _Sentinel instances.

Both DISABLE (line 749) and MISSING (line 754) are instances of MonitoringSentinel with class name "_Sentinel", and neither is None. This function returns true for both sentinels, so a callback that returns MISSING will be misinterpreted as DISABLE, silently disabling monitoring for that code location.

CPython uses identity comparison against the specific DISABLE singleton. You should do the same here — compare by object identity against the known DISABLE object rather than by type name.

Proposed fix
-/// Check if DISABLE sentinel was returned by a callback.
-fn is_disable(obj: &PyObjectRef, vm: &VirtualMachine) -> bool {
-    // DISABLE is the _Sentinel singleton stored on the monitoring module.
-    // We check its type name to avoid needing a reference to the exact object.
-    let name = obj.class().name();
-    let name_str: &str = &name;
-    name_str == "_Sentinel" && !vm.is_none(obj)
+/// Check if DISABLE sentinel was returned by a callback.
+fn is_disable(obj: &PyObjectRef, vm: &VirtualMachine) -> bool {
+    // Compare by identity against the actual DISABLE singleton.
+    if let Ok(monitoring_mod) = vm.import("sys.monitoring", 0) {
+        if let Ok(disable_obj) = monitoring_mod.get_attr("DISABLE", vm) {
+            return obj.is(&disable_obj);
+        }
+    }
+    false
 }

Alternatively, cache the DISABLE object in MonitoringState (similar to how missing is cached) for a cheaper check:

 pub struct MonitoringState {
     pub tool_names: [Option<String>; TOOL_LIMIT],
     pub global_events: [u32; TOOL_LIMIT],
     pub local_events: HashMap<(usize, usize), u32>,
     pub callbacks: HashMap<(usize, usize), PyObjectRef>,
     pub disabled: HashSet<(usize, usize, usize)>,
     pub missing: Option<PyObjectRef>,
+    pub disable: Option<PyObjectRef>,
 }

Then compare obj.is(disable_obj) in is_disable.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Check if DISABLE sentinel was returned by a callback.
fn is_disable(obj: &PyObjectRef, vm: &VirtualMachine) -> bool {
// DISABLE is the _Sentinel singleton stored on the monitoring module.
// We check its type name to avoid needing a reference to the exact object.
let name = obj.class().name();
let name_str: &str = &name;
name_str == "_Sentinel" && !vm.is_none(obj)
}
/// Check if DISABLE sentinel was returned by a callback.
fn is_disable(obj: &PyObjectRef, vm: &VirtualMachine) -> bool {
// Compare by identity against the actual DISABLE singleton.
if let Ok(monitoring_mod) = vm.import("sys.monitoring", 0) {
if let Ok(disable_obj) = monitoring_mod.get_attr("DISABLE", vm) {
return obj.is(&disable_obj);
}
}
false
}
🤖 Prompt for AI Agents
In `@crates/vm/src/stdlib/sys/monitoring.rs` around lines 371 - 378, The
is_disable function currently checks only the class name and .is_none(), which
treats both DISABLE and MISSING (both MonitoringSentinel/_Sentinel) as disable;
change it to perform an identity comparison against the actual DISABLE singleton
instead: obtain the DISABLE object (either by fetching the module-level DISABLE
singleton or by caching it on MonitoringState like missing) and return
obj.is(disable_obj) (or pointer/identity equality) rather than comparing class
name; update is_disable to use that cached/obtained DISABLE symbol so MISSING is
not misclassified.

Comment on lines 47 to 62
fn cache_source_for_traceback(&self, source_path: &str, source: &str) -> PyResult<()> {
let linecache = self.import("linecache", 0)?;
let cache = linecache.get_attr("cache", self)?;
let lines: Vec<PyObjectRef> = source
.split_inclusive('\n')
.map(|line| self.ctx.new_str(line).into())
.collect();
let entry = self.ctx.new_tuple(vec![
self.ctx.new_int(source.len()).into(),
self.ctx.none(),
self.ctx.new_list(lines).into(),
self.ctx.new_str(source_path).into(),
]);
cache.set_item(source_path, entry.into(), self)?;
Ok(())
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's find linecache-related Python files to understand the format
fd 'linecache' --type f -e py 2>/dev/null | head -20

Repository: RustPython/RustPython

Length of output: 108


🌐 Web query:

CPython linecache entry tuple format mtime None

💡 Result:

In CPython’s linecache, an eager/loaded cache entry is a 4-tuple:

(size, mtime, lines, fullname) — i.e., file size, modification time, list of source lines, and the resolved full path. The module-level comment documents this tuple format. [1]

mtime can be None when the source did not come from a normal filesystem file (e.g., it came via an import __loader__ / get_source). In that case, checkcache() explicitly treats the entry as a no-op (it skips os.stat() staleness checks) because there’s no meaningful on-disk mtime to compare against. [1][2]

Separately, linecache.cache[filename] can also temporarily hold a 1-tuple used for lazy loading (a thunk/function to fetch the lines later), which is why code sometimes checks len(entry) == 1. [2]

Sources:
[1] Debian’s packaged CPython Lib/linecache.py excerpt showing the cache tuple and mtime is None handling. (sources.debian.org)
[2] Stack Overflow explanation of linecache cache entry shapes (1-tuple lazy thunk vs 4-tuple) and why loader-based entries lack mtime. (stackoverflow.com)

Citations:


🏁 Script executed:

#!/bin/bash
# Check the actual implementation in the code file
cat -n crates/vm/src/vm/python_run.rs | head -80

Repository: RustPython/RustPython

Length of output: 3552


🏁 Script executed:

#!/bin/bash
# Search for linecache usage elsewhere in RustPython to understand expectations
rg 'linecache' --type rs -B2 -A2 | head -100

Repository: RustPython/RustPython

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Examine CPython's linecache.py to see the cache entry format
head -200 Lib/linecache.py | grep -A 20 -B 5 "cache\["

Repository: RustPython/RustPython

Length of output: 1187


🏁 Script executed:

#!/bin/bash
# Check specifically how linecache handles the cache tuple
rg 'def.*cache|cache.*=.*\(' Lib/linecache.py -A 3 | head -80

Repository: RustPython/RustPython

Length of output: 847


RustPython's linecache entry tuple format is correct, but trailing newline handling differs from CPython.

The tuple (size, mtime=None, lines, path) matches CPython's expectations. However, CPython's linecache explicitly ensures each line ends with '\n': if not lines[-1].endswith('\n'): lines[-1] += '\n'. RustPython's split_inclusive('\n') approach does not add trailing newlines when the source doesn't end with one, meaning the last line may lack a '\n'. This could affect traceback display for the final line. Add post-processing to match CPython's guarantee that all lines in the list end with a newline character.

🤖 Prompt for AI Agents
In `@crates/vm/src/vm/python_run.rs` around lines 47 - 62,
cache_source_for_traceback currently builds the `lines` list via
split_inclusive('\n') which can leave the final line without a trailing newline;
update the logic in cache_source_for_traceback to post-process the collected
`lines` (before converting to Py objects) and ensure the last element ends with
'\n' (append one if missing) so the resulting tuple `entry` matches CPython's
linecache invariant `(size, mtime=None, lines, path)` with every line ending in
newline; keep the existing creation of `entry` and call to cache.set_item as-is.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@crates/codegen/src/compile.rs`:
- Around line 5186-5201: Capture the for-statement's source range at the start
of compile_for (e.g., store self.current_source_range into a local like
for_range) and replace uses of iter.range() used for setting attribution with
that for_range; specifically update the calls to
self.set_source_range(iter.range()) around the loop epilogue and implicit return
attribution to use for_range so LINE events remain tied to the for-statement
header rather than the iterable expression.

In `@crates/vm/src/frame.rs`:
- Around line 590-619: The current block replaces the original exception with
any error returned from monitoring::fire_reraise/fire_raise and other sites like
fire_py_unwind; change this so that only RAISE/RERAISE events replace the
propagated exception, but PY_UNWIND (and similar unwind events) preserve the
original exception on monitoring callback failure—i.e., call
monitoring::fire_py_unwind (or other unwind callers) and if it returns Err,
ignore or log the monitoring error and return the original exception instead of
monitor_exc. Update the logic around monitoring::EVENT_PY_UNWIND and the
corresponding match/Err handling (referencing fire_reraise, fire_raise,
fire_py_unwind, and monitoring::EVENT_* masks) to implement this differentiated
behavior.
- Around line 788-803: In gen_throw, don't silently discard
monitoring::fire_py_throw and monitoring::fire_raise results; capture their
Result and, on Err, propagate/replace the current exception just like the run()
loop does—i.e., call monitoring::fire_py_throw(vm, self.code, offset, &exc_obj)
and monitoring::fire_raise(vm, self.code, offset, &exc_obj), check the Result,
and if it returns Err(e) return or propagate that error (or replace the
propagating exception) so monitoring errors are not swallowed; update the
gen_throw logic accordingly to mirror run()'s error-handling behavior.

In `@crates/vm/src/stdlib/sys/monitoring.rs`:
- Around line 430-451: The FIRING TLS flag is set before running callbacks but
is reset only after the closure returns, so a panic inside the callback loop
will skip FIRING.with(|f| f.set(false)) and leave monitoring disabled for that
thread; wrap the FIRING set/reset in a panic-safe RAII guard (or use the
scopeguard crate) so that FIRING.with(|f| f.set(false)) runs in Drop even if a
callback panics: set FIRING to true, create a guard type or scopeguard that
calls FIRING.with(|f| f.set(false)) in its Drop, then run the existing callbacks
loop (the code using callbacks, cb.call(...), is unchanged) and let the guard
reset FIRING regardless of success/failure before returning the result and
before modifying vm.state.monitoring.lock().
- Around line 696-726: The doc comments for fire_branch_left and
fire_branch_right are reversed; update the comments so fire_branch_left
documents "fired when a branch is not taken (falls through)" and
fire_branch_right documents "fired when a branch is taken (jumps to
destination)" to match how frame.rs invokes these functions (reference
fire_branch_left and fire_branch_right to locate them and adjust their
docstrings accordingly).
🧹 Nitpick comments (11)
crates/derive-impl/src/pymodule.rs (1)

197-248: Submodule initialization looks correct; consider a minor robustness note on the NameValue case.

The overall logic — detect nested #[pymodule] mods, skip sub modules, generate cfg-guarded init code — is clean and consistent with the rest of the file.

One small observation: at Line 213, syn::Meta::NameValue silently returns Ok(()), meaning a malformed #[pymodule = "..."] annotation would be silently ignored rather than producing a compile-time diagnostic. This is unlikely in practice but could cause confusion if someone misuses the attribute.

Optional: emit an error for the NameValue case
-                    _ => return Ok(()),
+                    syn::Meta::NameValue(nv) => {
+                        bail_span!(nv, "#[pymodule] does not support `=` syntax");
+                    }
crates/vm/src/stdlib/sys/mod.rs (1)

1072-1079: Inconsistency with non-threading _current_frames stub.

The non-threading _current_exceptions returns a dict with key 0 and the actual exception, while the non-threading _current_frames (line 1084) returns an empty dict. Consider aligning them — either both return meaningful data for the main thread or both return an empty dict. Currently CPython returns both with the main thread's data.

crates/vm/src/vm/mod.rs (1)

1109-1155: resume_gen_frame lacks scopeguard for recursion_depth decrement.

If f(frame) panics, recursion_depth at line 1153 won't be decremented, and the exception stack / frame chain won't be cleaned up either (lines 1141–1151). While panics from Python execution are rare, with_frame_exc at least protects the recursion depth via with_recursion's scopeguard::defer!. Consider wrapping the cleanup in a scopeguard or a drop guard for consistency.

Proposed fix — use scopeguard for cleanup
         self.recursion_depth.update(|d| d + 1);
 
         self.frames.borrow_mut().push(frame.clone());
         let frame_ptr: *const Frame = &***frame;
         let old_frame = crate::vm::thread::set_current_frame(frame_ptr);
         frame.previous.store(
             old_frame as *mut Frame,
             core::sync::atomic::Ordering::Relaxed,
         );
-        // Inline exception push without thread exception update
         self.exceptions.borrow_mut().stack.push(exc);
         let old_owner = frame.owner.swap(
             crate::frame::FrameOwner::Thread as i8,
             core::sync::atomic::Ordering::AcqRel,
         );
 
         let result = f(frame);
 
-        // SAFETY: frame_ptr is valid because self.frames holds a clone
-        unsafe { &*frame_ptr }
-            .owner
-            .store(old_owner, core::sync::atomic::Ordering::Release);
-        // Inline exception pop without thread exception update
-        self.exceptions
-            .borrow_mut()
-            .stack
-            .pop()
-            .expect("pop_exception() without nested exc stack");
-        crate::vm::thread::set_current_frame(old_frame);
-        let _popped = self.frames.borrow_mut().pop();
-
-        self.recursion_depth.update(|d| d - 1);
+        scopeguard::defer! {
+            // SAFETY: frame_ptr is valid because self.frames holds a clone
+            unsafe { &*frame_ptr }
+                .owner
+                .store(old_owner, core::sync::atomic::Ordering::Release);
+            self.exceptions
+                .borrow_mut()
+                .stack
+                .pop()
+                .expect("pop_exception() without nested exc stack");
+            crate::vm::thread::set_current_frame(old_frame);
+            let _popped = self.frames.borrow_mut().pop();
+            self.recursion_depth.update(|d| d - 1);
+        }
+
         result
crates/vm/src/frame.rs (3)

2021-2056: Duplicated branch monitoring code — extract into a helper.

The exact same branch-monitoring pattern (compute src_offset/dest_offset, check mask, fire branch_left/branch_right depending on branch_taken) appears in:

  1. PopJumpIfNone (Lines 2029-2056)
  2. PopJumpIfNotNone (Lines 2067-2094)
  3. pop_jump_if (Lines 3109-3136)
  4. execute_for_iter (Lines 3150-3164, 3188-3202)

This violates DRY — extract a small helper like fire_branch_event(&self, vm, src_offset, dest_offset, branch_taken).

♻️ Sketch of helper
/// Fire BRANCH_LEFT or BRANCH_RIGHT monitoring event.
fn fire_branch_monitoring(
    &self,
    vm: &VirtualMachine,
    src_offset: u32,
    dest_offset: u32,
    branch_taken: bool,
) {
    let monitoring_mask = vm.state.monitoring_events.load();
    if monitoring_mask
        & (crate::stdlib::sys::monitoring::EVENT_BRANCH_LEFT
            | crate::stdlib::sys::monitoring::EVENT_BRANCH_RIGHT)
        != 0
    {
        if branch_taken {
            let _ = crate::stdlib::sys::monitoring::fire_branch_right(
                vm, self.code, src_offset, dest_offset,
            );
        } else {
            let _ = crate::stdlib::sys::monitoring::fire_branch_left(
                vm, self.code, src_offset, dest_offset,
            );
        }
    }
}

Then each call site becomes a single line:

-// Fire BRANCH monitoring events
-let monitoring_mask = vm.state.monitoring_events.load();
-if monitoring_mask
-    & (crate::stdlib::sys::monitoring::EVENT_BRANCH_LEFT
-        | crate::stdlib::sys::monitoring::EVENT_BRANCH_RIGHT)
-    != 0
-{
-    let dest_offset = if branch_taken { target.0 * 2 } else { self.lasti() * 2 };
-    if branch_taken {
-        let _ = crate::stdlib::sys::monitoring::fire_branch_right(
-            vm, self.code, src_offset, dest_offset,
-        );
-    } else {
-        let _ = crate::stdlib::sys::monitoring::fire_branch_left(
-            vm, self.code, src_offset, dest_offset,
-        );
-    }
-}
+let dest_offset = if branch_taken { target.0 * 2 } else { self.lasti() * 2 };
+self.fire_branch_monitoring(vm, src_offset, dest_offset, branch_taken);

As per coding guidelines: "When branches differ only in a value but share common logic, extract the differing value first, then call the common logic once to avoid duplicate code."

Also applies to: 2059-2094, 3096-3137


2908-2966: execute_call monitoring: is_python_call check is shallow.

Line 2930 uses callable.downcast_ref::<PyFunction>().is_some() to decide whether to fire C_RETURN/C_RAISE. This misses cases where a PyFunction is wrapped (e.g., bound methods, functools.partial, descriptors). A bound method wrapping a Python function would still be classified as a "C call" and fire C_RETURN/C_RAISE events incorrectly. CPython checks Py_TYPE(callable) == &PyFunction_Type which is similarly shallow, so this may be acceptable for now, but it's worth noting.


2929-2944: call_arg0 computation always allocates the MISSING sentinel when no args present, even when CALL monitoring is disabled.

The MISSING sentinel is only fetched when monitoring_mask & EVENT_CALL != 0 (Line 2933), so the guard is correct. However, get_missing(vm) acquires the monitoring lock every time. For the hot path (CALL with no args but monitoring active), consider caching MISSING at VM init time to avoid repeated lock acquisition.

crates/vm/src/vm/thread.rs (2)

133-142: get_all_current_exceptions holds the registry lock while cloning all exceptions.

Line 137 acquires vm.state.thread_frames.lock() and then iterates all slots calling slot.exception.to_owned() (Line 140). If to_owned() involves cloning PyObjectRef (ref-count bump), this holds the registry lock for O(threads) clone operations. For typical thread counts this is fine, but consider collecting just the Arc<ThreadSlot> references first, then releasing the lock before reading exceptions, to minimize contention.


15-21: ThreadSlot fields are pub but the struct itself should probably restrict visibility.

Both frames and exception are pub, allowing any code to directly lock/swap them. Consider restricting to pub(crate) to match the existing visibility pattern in this module (e.g., pub(crate) static CURRENT_FRAME).

crates/vm/src/stdlib/sys/monitoring.rs (3)

248-278: Magic event IDs: hardcoded 18, 8, 9 for BRANCH/BRANCH_LEFT/BRANCH_RIGHT.

Lines 265, 267, 268, 272-274 use raw numeric event IDs. These should use named constants derived from the bitflags to prevent silent breakage if event positions change.

♻️ Proposed fix
+// Event ID constants (bit position = trailing_zeros of the corresponding bit)
+const EVENT_ID_BRANCH_LEFT: usize = MonitoringEvents::BRANCH_LEFT.bits().trailing_zeros() as usize;
+const EVENT_ID_BRANCH_RIGHT: usize = MonitoringEvents::BRANCH_RIGHT.bits().trailing_zeros() as usize;
+const EVENT_ID_BRANCH: usize = MonitoringEvents::BRANCH.bits().trailing_zeros() as usize;

 // In register_callback:
-        if event_id == 18 {
-            // BRANCH → BRANCH_LEFT (8) + BRANCH_RIGHT (9)
-            state.callbacks.insert((tool, 8), func.clone());
-            state.callbacks.insert((tool, 9), func);
+        if event_id == EVENT_ID_BRANCH {
+            state.callbacks.insert((tool, EVENT_ID_BRANCH_LEFT), func.clone());
+            state.callbacks.insert((tool, EVENT_ID_BRANCH_RIGHT), func);
         }

356-356: Remove decorative section separator.

Line 356 // ── Event dispatch ──... is a decorative separator. As per coding guidelines: "Do not add decorative section separators (e.g. // -----------, // ===, /* *** */). Use /// doc-comments or short // comments only when they add value."


84-107: MonitoringState uses HashMap for local_events, callbacks, and disabled — consider FxHashMap for better performance.

These maps are accessed under the monitoring lock on every event dispatch (fire_event_inner, Lines 407-418). The standard HashMap uses SipHash which is slower than FxHashMap for small integer keys (usize, usize). Since this is a runtime VM, using rustc_hash::FxHashMap (or similar) could reduce overhead when monitoring is active.

Comment on lines +5186 to +5201
// END_FOR + POP_ITER are on the `for` line, not the body's last line
let saved_range = self.current_source_range;
self.set_source_range(iter.range());
emit!(self, Instruction::EndFor);
emit!(self, Instruction::PopIter);
self.set_source_range(saved_range);
}
self.compile_statements(orelse)?;

self.switch_to_block(after_block);

// Restore source range to the `for` line so any implicit return
// (LOAD_CONST None, RETURN_VALUE) is attributed to the `for` line,
// not the loop body's last line.
self.set_source_range(iter.range());

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the for statement range instead of iter.range() for line attribution.

For multi-line headers (for x in (\n y\n):), iter.range() can move LINE events to the iterable’s line. Capturing the statement range once (before compiling the loop) preserves correct attribution.

💡 Suggested fix
-            let saved_range = self.current_source_range;
-            self.set_source_range(iter.range());
+            let saved_range = self.current_source_range;
+            self.set_source_range(for_range);
             emit!(self, Instruction::EndFor);
             emit!(self, Instruction::PopIter);
             self.set_source_range(saved_range);
-        self.set_source_range(iter.range());
+        self.set_source_range(for_range);
// Add near the start of compile_for (before compiling iter/body):
let for_range = self.current_source_range;
🤖 Prompt for AI Agents
In `@crates/codegen/src/compile.rs` around lines 5186 - 5201, Capture the
for-statement's source range at the start of compile_for (e.g., store
self.current_source_range into a local like for_range) and replace uses of
iter.range() used for setting attribution with that for_range; specifically
update the calls to self.set_source_range(iter.range()) around the loop epilogue
and implicit return attribution to use for_range so LINE events remain tied to
the for-statement header rather than the iterable expression.

Comment on lines +590 to +619
// Fire RAISE or RERAISE monitoring event.
// fire_reraise internally deduplicates: only the first
// re-raise after each EXCEPTION_HANDLED fires the event.
// If the callback raises (e.g. ValueError for illegal DISABLE),
// replace the original exception.
let exception = {
use crate::stdlib::sys::monitoring;
let monitoring_mask = vm.state.monitoring_events.load();
if is_reraise {
if monitoring_mask & monitoring::EVENT_RERAISE != 0 {
let offset = idx as u32 * 2;
let exc_obj: PyObjectRef = exception.clone().into();
match monitoring::fire_reraise(vm, self.code, offset, &exc_obj) {
Ok(()) => exception,
Err(monitor_exc) => monitor_exc,
}
} else {
exception
}
} else if monitoring_mask & monitoring::EVENT_RAISE != 0 {
let offset = idx as u32 * 2;
let exc_obj: PyObjectRef = exception.clone().into();
match monitoring::fire_raise(vm, self.code, offset, &exc_obj) {
Ok(()) => exception,
Err(monitor_exc) => monitor_exc,
}
} else {
exception
}
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent error handling: fire_reraise/fire_raise replace the original exception, but fire_py_unwind (and other sites) do the same — verify this is intentional.

When monitoring callbacks raise, you replace the original exception with the monitoring exception (Lines 604, 614, 636). This is reasonable for fire_raise/fire_reraise (the callback error becomes the propagated exception), but doing the same for fire_py_unwind means a buggy monitoring callback can silently swallow the real exception and replace it with its own. CPython's _Py_call_instrumentation_exc typically restores the original exception if the callback fails for unwind events.

Consider whether replacing the exception for PY_UNWIND is the correct behavior, or if the original exception should be preserved.

Also applies to: 624-640

🤖 Prompt for AI Agents
In `@crates/vm/src/frame.rs` around lines 590 - 619, The current block replaces
the original exception with any error returned from
monitoring::fire_reraise/fire_raise and other sites like fire_py_unwind; change
this so that only RAISE/RERAISE events replace the propagated exception, but
PY_UNWIND (and similar unwind events) preserve the original exception on
monitoring callback failure—i.e., call monitoring::fire_py_unwind (or other
unwind callers) and if it returns Err, ignore or log the monitoring error and
return the original exception instead of monitor_exc. Update the logic around
monitoring::EVENT_PY_UNWIND and the corresponding match/Err handling
(referencing fire_reraise, fire_raise, fire_py_unwind, and monitoring::EVENT_*
masks) to implement this differentiated behavior.

Comment on lines +430 to +451
FIRING.with(|f| f.set(true));
let result = (|| {
for (tool, cb) in callbacks {
let result = cb.call(args.clone(), vm)?;
if is_disable(&result, vm) {
// Only local events (event_id < LOCAL_EVENTS_COUNT) can be disabled.
// Non-local events (RAISE, EXCEPTION_HANDLED, PY_UNWIND, etc.)
// cannot be disabled per code object.
if event_id >= LOCAL_EVENTS_COUNT {
return Err(vm.new_value_error(format!(
"cannot disable {} events",
EVENT_NAMES[event_id]
)));
}
let mut state = vm.state.monitoring.lock();
state.disabled.insert((code_id, offset as usize, tool));
}
}
Ok(())
})();
FIRING.with(|f| f.set(false));
result
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

FIRING guard is not panic-safe — a panicking callback permanently disables monitoring for the thread.

If any callback at Line 433 panics (and the panic is caught by catch_unwind or similar), FIRING is never reset to false (Line 450 is skipped). All subsequent monitoring events on this thread will be silently suppressed due to the early return at Line 390-392.

Use a scopeguard or RAII wrapper to ensure cleanup:

🔒 Proposed fix using scopeguard pattern
     FIRING.with(|f| f.set(true));
+    let _guard = scopeguard::guard((), |_| {
+        FIRING.with(|f| f.set(false));
+    });
     let result = (|| {
         for (tool, cb) in callbacks {
             let result = cb.call(args.clone(), vm)?;
             if is_disable(&result, vm) {
                 if event_id >= LOCAL_EVENTS_COUNT {
                     return Err(vm.new_value_error(format!(
                         "cannot disable {} events",
                         EVENT_NAMES[event_id]
                     )));
                 }
                 let mut state = vm.state.monitoring.lock();
                 state.disabled.insert((code_id, offset as usize, tool));
             }
         }
         Ok(())
     })();
-    FIRING.with(|f| f.set(false));
     result
🤖 Prompt for AI Agents
In `@crates/vm/src/stdlib/sys/monitoring.rs` around lines 430 - 451, The FIRING
TLS flag is set before running callbacks but is reset only after the closure
returns, so a panic inside the callback loop will skip FIRING.with(|f|
f.set(false)) and leave monitoring disabled for that thread; wrap the FIRING
set/reset in a panic-safe RAII guard (or use the scopeguard crate) so that
FIRING.with(|f| f.set(false)) runs in Drop even if a callback panics: set FIRING
to true, create a guard type or scopeguard that calls FIRING.with(|f|
f.set(false)) in its Drop, then run the existing callbacks loop (the code using
callbacks, cb.call(...), is unchanged) and let the guard reset FIRING regardless
of success/failure before returning the result and before modifying
vm.state.monitoring.lock().

Comment on lines +696 to +726
/// BRANCH_LEFT: fired when a branch goes left (condition true)
pub fn fire_branch_left(
vm: &VirtualMachine,
code: &PyRef<PyCode>,
offset: u32,
destination: u32,
) -> PyResult<()> {
let code_id = code.get_id();
let args = FuncArgs::from(vec![
code.clone().into(),
vm.ctx.new_int(offset).into(),
vm.ctx.new_int(destination).into(),
]);
fire_event_inner(vm, 8, EVENT_BRANCH_LEFT, code_id, offset, &args)
}

/// BRANCH_RIGHT: fired when a branch goes right (condition false, falls through)
pub fn fire_branch_right(
vm: &VirtualMachine,
code: &PyRef<PyCode>,
offset: u32,
destination: u32,
) -> PyResult<()> {
let code_id = code.get_id();
let args = FuncArgs::from(vec![
code.clone().into(),
vm.ctx.new_int(offset).into(),
vm.ctx.new_int(destination).into(),
]);
fire_event_inner(vm, 9, EVENT_BRANCH_RIGHT, code_id, offset, &args)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, find where fire_branch_left and fire_branch_right are called
fd -e rs -type f | xargs rg -n "fire_branch_(left|right)" --context=3

Repository: RustPython/RustPython

Length of output: 4979


🏁 Script executed:

# Let's also check the frame.rs file specifically
fd frame.rs -type f | head -5

Repository: RustPython/RustPython

Length of output: 235


🌐 Web query:

CPython 3.14 sys.monitoring BRANCH_LEFT BRANCH_RIGHT semantics documentation

💡 Result:

In CPython/Python 3.14, sys.monitoring.events.BRANCH_LEFT and BRANCH_RIGHT are local monitoring events emitted when a conditional branch is taken (i.e., control flow follows one of the two outcomes of a conditional). [1]

What “left” vs “right” means

  • The VM labels the two outcomes as “left” and “right”, but does not guarantee which outcome corresponds to which label (e.g., “true” vs “false” is not specified). [1]
  • The only guarantee is consistency for the duration of the program: once a given branch’s outcomes are assigned “left/right”, that assignment won’t flip mid-run. [1]

Callback signature / how to interpret it

For both BRANCH_LEFT and BRANCH_RIGHT, the callback is invoked as:
func(code: CodeType, instruction_offset: int, destination_offset: int) -> object [1]

  • instruction_offset: where the conditional branch decision happens
  • destination_offset: the next instruction offset that will execute (i.e., the target that was actually taken) [1]

Relationship to the old BRANCH event

  • In Python 3.14, BRANCH is deprecated, and BRANCH_LEFT / BRANCH_RIGHT are the intended replacement (and can be disabled independently for better performance). [1]
  • The 3.14 “What’s New” notes the addition of these two events and the deprecation of BRANCH. [2]

Sources: Python 3.14 sys.monitoring docs [1], Python 3.14 “What’s New” [2].


Doc comments for fire_branch_left and fire_branch_right are inverted.

In frame.rs, fire_branch_right() is called when a branch is taken (jumps to destination), and fire_branch_left() is called when a branch is not taken (falls through). The current doc comments have these backwards:

-/// BRANCH_LEFT: fired when a branch goes left (condition true)
+/// BRANCH_LEFT: fired when a conditional branch is NOT taken (falls through)
 pub fn fire_branch_left(

-/// BRANCH_RIGHT: fired when a branch goes right (condition false, falls through)
+/// BRANCH_RIGHT: fired when a conditional branch IS taken (jumps to target)
 pub fn fire_branch_right(
🤖 Prompt for AI Agents
In `@crates/vm/src/stdlib/sys/monitoring.rs` around lines 696 - 726, The doc
comments for fire_branch_left and fire_branch_right are reversed; update the
comments so fire_branch_left documents "fired when a branch is not taken (falls
through)" and fire_branch_right documents "fired when a branch is taken (jumps
to destination)" to match how frame.rs invokes these functions (reference
fire_branch_left and fire_branch_right to locate them and adjust their
docstrings accordingly).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/vm/src/frame.rs (2)

466-502: ⚠️ Potential issue | 🟠 Major

Separate trace vs monitoring line tracking to avoid suppressing LINE events

prev_line is updated in the tracing block before the monitoring block, so when tracing is enabled the monitoring EVENT_LINE check sees no change and never fires. Track trace and monitoring line numbers independently (or update ordering) so both subsystems get line-change notifications.

💡 Suggested fix
-        let mut prev_line: u32 = 0;
+        let mut prev_trace_line: u32 = 0;
+        let mut prev_monitor_line: u32 = 0;
...
-                && loc.line.get() as u32 != prev_line
+                && loc.line.get() as u32 != prev_trace_line
             {
-                prev_line = loc.line.get() as u32;
+                prev_trace_line = loc.line.get() as u32;
                 vm.trace_event(crate::protocol::TraceEvent::Line, None)?;
             }
...
-                    if monitoring_mask & monitoring::EVENT_LINE != 0 && line != prev_line {
+                    if monitoring_mask & monitoring::EVENT_LINE != 0 && line != prev_monitor_line {
                         monitoring::fire_line(vm, self.code, offset, line)?;
                     }
...
-                prev_line = line;
+                prev_monitor_line = line;
             }

2909-2966: ⚠️ Potential issue | 🟠 Major

C_RETURN/C_RAISE should fire even when EVENT_CALL is off

call_arg0 is computed only when EVENT_CALL is set, so C_RETURN/C_RAISE never fire unless EVENT_CALL is enabled. Also, fire_c_raise errors are discarded. Compute arg0 when any of CALL/C_RETURN/C_RAISE is enabled, and handle fire_c_raise consistently.

💡 Suggested fix
-        let monitoring_mask = vm.state.monitoring_events.load();
-        let is_python_call = callable.downcast_ref::<PyFunction>().is_some();
-
-        // Compute arg0 once for CALL, C_RETURN, and C_RAISE events
-        let call_arg0 = if monitoring_mask & monitoring::EVENT_CALL != 0 {
+        let monitoring_mask = vm.state.monitoring_events.load();
+        let is_python_call = callable.downcast_ref::<PyFunction>().is_some();
+        let wants_call = monitoring_mask & monitoring::EVENT_CALL != 0;
+        let wants_c = monitoring_mask
+            & (monitoring::EVENT_C_RETURN | monitoring::EVENT_C_RAISE)
+            != 0;
+
+        // Compute arg0 once for CALL / C_RETURN / C_RAISE
+        let call_arg0 = if wants_call || wants_c {
             let arg0 = final_args
                 .args
                 .first()
                 .cloned()
                 .unwrap_or_else(|| monitoring::get_missing(vm));
             let offset = (self.lasti() - 1) * 2;
-            monitoring::fire_call(vm, self.code, offset, &callable, arg0.clone())?;
+            if wants_call {
+                monitoring::fire_call(vm, self.code, offset, &callable, arg0.clone())?;
+            }
             Some(arg0)
         } else {
             None
         };
...
-                if let Some(arg0) = call_arg0
-                    && !is_python_call
-                {
+                if let Some(arg0) = call_arg0
+                    && !is_python_call
+                    && wants_c
+                {
                     let offset = (self.lasti() - 1) * 2;
                     monitoring::fire_c_return(vm, self.code, offset, &callable, arg0)?;
                 }
...
-                if let Some(arg0) = call_arg0
-                    && !is_python_call
-                {
+                if let Some(arg0) = call_arg0
+                    && !is_python_call
+                    && wants_c
+                {
                     let offset = (self.lasti() - 1) * 2;
-                    let _ = monitoring::fire_c_raise(vm, self.code, offset, &callable, arg0);
+                    monitoring::fire_c_raise(vm, self.code, offset, &callable, arg0)?;
                 }
🤖 Fix all issues with AI agents
In `@crates/vm/src/frame.rs`:
- Around line 1407-1420: The monitoring call currently swallows errors when
firing jump events; update the block in function(s) using self.lasti(),
target.get(arg), self.jump(dest) so the call to
crate::stdlib::sys::monitoring::fire_jump returns its Result and any Err is
propagated instead of ignored—e.g., check vm.state.monitoring_events for
EVENT_JUMP as now but replace the ignored call with a propagating call (use the
? operator or map_err to convert/return the error) so failures from fire_jump
are not silently dropped; apply the same change to the other occurrences
referenced (around the blocks that call fire_* and inspect monitoring_events).

In `@crates/vm/src/vm/mod.rs`:
- Around line 1129-1176: resume_gen_frame currently performs manual cleanup
(recursion_depth.update decrement, frames.pop(), exceptions.stack.pop(),
owner.store(old_owner), crate::vm::thread::set_current_frame(old_frame)) after
calling f(frame), which will be skipped if f panics; wrap the post-call cleanup
in a panic-safe guard (either use scopeguard::defer! immediately after
successfully performing the setup steps or create a small RAII guard struct) so
that on unwind you always: decrement recursion_depth, pop self.frames, pop the
exceptions stack, restore frame.owner to old_owner, and reset the current thread
frame via crate::vm::thread::set_current_frame(old_frame); place the guard right
after pushing the FramePtr and pushing the exception so it has access to
old_owner and old_frame and remove the manual cleanup at the end or let the
guard perform it.
🧹 Nitpick comments (6)
crates/codegen/src/ir.rs (1)

253-273: Consider using an import for std::collections::HashSet.

std::collections::HashSet is used three times inline (lines 254, 260, 261). A local use or a module-level import would reduce verbosity.

♻️ Suggested refactor
+        use std::collections::HashSet;
+
         // Collect lines that have non-NOP instructions in this block
-        let non_nop_lines: std::collections::HashSet<_> = block
+        let non_nop_lines: HashSet<_> = block
             .instructions
             .iter()
             .filter(|ins| !matches!(ins.instr.real(), Some(Instruction::Nop)))
             .map(|ins| ins.location.line)
             .collect();
-        let mut kept_nop_lines: std::collections::HashSet<OneIndexed> =
-            std::collections::HashSet::new();
+        let mut kept_nop_lines: HashSet<OneIndexed> = HashSet::new();
crates/vm/src/frame.rs (1)

2692-2707: EXCEPTION_HANDLED hook placement looks correct

Please also confirm fmt/clippy and the bytecode clean-build command were run since instruction handling changed.

Commands to run locally:

cargo fmt
cargo clippy --all-targets --all-features
rm -r target/debug/build/rustpython-* && find . | grep -E "\.pyc$" | xargs rm -r

As per coding guidelines: "Follow the default rustfmt code style by running cargo fmt to format Rust code", "Always run clippy to lint Rust code (cargo clippy) before completing tasks and fix any warnings or lints that are introduced by your changes", and "When modifying bytecode instructions, perform a clean build by running: rm -r target/debug/build/rustpython-* && find . | grep -E "\.pyc$" | xargs rm -r".

crates/vm/src/vm/mod.rs (1)

1148-1148: Triple deref (&***frame) is hard to follow — consider a helper or a brief inline comment.

frame is &FrameRef (&PyRef<Frame>), so &***frame dereferences through &PyRef<Frame>Py<Frame>Frame then re-borrows to &Frame. It works, but is a readability hazard. A named binding or a brief // &FrameRef → &Frame comment would help future readers.

crates/vm/src/stdlib/sys/monitoring.rs (3)

456-726: Magic event-id literals scattered across all fire_* functions.

Every fire_* function passes a raw integer (e.g., 0, 1, 11, 16) as the event_id to fire_event_inner. A single typo would silently route callbacks to the wrong event with no compile-time protection. Consider defining named constants (e.g., const EVENT_ID_PY_START: usize = 0;) or deriving the id from the bit position of the corresponding EVENT_* constant (EVENT_PY_START.trailing_zeros() as usize).

Example using trailing_zeros
 pub fn fire_py_start(vm: &VirtualMachine, code: &PyRef<PyCode>, offset: u32) -> PyResult<()> {
     let code_id = code.get_id();
     let args = FuncArgs::from(vec![code.clone().into(), vm.ctx.new_int(offset).into()]);
-    fire_event_inner(vm, 0, EVENT_PY_START, code_id, offset, &args)
+    fire_event_inner(vm, EVENT_PY_START.trailing_zeros() as usize, EVENT_PY_START, code_id, offset, &args)
 }

Or, have fire_event_inner derive event_id from event_bit internally, eliminating the redundant parameter.


234-240: free_tool_id double-locks: clear_tool_id locks+unlocks, then line 237 locks again.

This is both a minor performance issue and the same TOCTOU pattern flagged on set_events. Consider inlining the clear logic or taking the lock once.

Sketch
 fn free_tool_id(tool_id: i32, vm: &VirtualMachine) -> PyResult<()> {
     let tool = check_valid_tool(tool_id, vm)?;
-    clear_tool_id(tool_id, vm)?;
     let mut state = vm.state.monitoring.lock();
+    if state.tool_names[tool].is_some() {
+        state.global_events[tool] = 0;
+        state.local_events.retain(|(local_tool, _), _| *local_tool != tool);
+        state.callbacks.retain(|(cb_tool, _), _| *cb_tool != tool);
+        state.disabled.retain(|&(_, _, t)| t != tool);
+    }
     state.tool_names[tool] = None;
+    update_events_mask(vm, &state);
     Ok(())
 }

84-107: MonitoringState fields are all pub — consider restricting visibility.

All fields of MonitoringState are public, allowing any code to mutate internal invariants (e.g., global_events, disabled) without going through the validation functions. Since this struct is behind a PyMutex, direct field access from outside this module could bypass the event normalization and mask update logic. Making fields pub(crate) or private with accessors would tighten the contract.

Comment on lines +1407 to +1420
let src_offset = (self.lasti() - 1) * 2;
let dest = target.get(arg);
self.jump(dest);
// JUMP events fire only for backward jumps (loop iterations)
if vm.state.monitoring_events.load() & crate::stdlib::sys::monitoring::EVENT_JUMP
!= 0
{
let _ = crate::stdlib::sys::monitoring::fire_jump(
vm,
self.code,
src_offset,
dest.0 * 2,
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid swallowing monitoring errors for branch/jump events

These fire_* calls ignore callback failures, while other monitoring events propagate. Consider using ? (or a consistent replacement policy) so monitoring errors aren’t silently lost.

💡 Example adjustment (apply similarly to other sites)
-                    let _ = crate::stdlib::sys::monitoring::fire_jump(
+                    crate::stdlib::sys::monitoring::fire_jump(
                         vm,
                         self.code,
                         src_offset,
                         dest.0 * 2,
-                    );
+                    )?;

Also applies to: 2023-2055, 2061-2094, 3104-3136, 3144-3202

🤖 Prompt for AI Agents
In `@crates/vm/src/frame.rs` around lines 1407 - 1420, The monitoring call
currently swallows errors when firing jump events; update the block in
function(s) using self.lasti(), target.get(arg), self.jump(dest) so the call to
crate::stdlib::sys::monitoring::fire_jump returns its Result and any Err is
propagated instead of ignored—e.g., check vm.state.monitoring_events for
EVENT_JUMP as now but replace the ignored call with a propagating call (use the
? operator or map_err to convert/return the error) so failures from fire_jump
are not silently dropped; apply the same change to the other occurrences
referenced (around the blocks that call fire_* and inspect monitoring_events).

Comment on lines 1129 to 1176
/// Lightweight frame execution for generator/coroutine resume.
/// Skips the thread frame stack (sys._current_frames()) and trace call/return events,
/// since generator frames manage their own PY_RESUME/PY_YIELD monitoring events.
pub fn resume_gen_frame<R, F: FnOnce(&FrameRef) -> PyResult<R>>(
&self,
frame: &FrameRef,
exc: Option<PyBaseExceptionRef>,
f: F,
) -> PyResult<R> {
self.check_recursive_call("")?;
if self.check_c_stack_overflow() {
return Err(self.new_recursion_error(String::new()));
}
self.recursion_depth.update(|d| d + 1);

// SAFETY: frame (&FrameRef) stays alive for the duration, so NonNull is valid until pop.
self.frames
.borrow_mut()
.push(FramePtr(NonNull::from(&**frame)));
let old_frame = crate::vm::thread::set_current_frame((&***frame) as *const Frame);
frame.previous.store(
old_frame as *mut Frame,
core::sync::atomic::Ordering::Relaxed,
);
// Inline exception push without thread exception update
self.exceptions.borrow_mut().stack.push(exc);
let old_owner = frame.owner.swap(
crate::frame::FrameOwner::Thread as i8,
core::sync::atomic::Ordering::AcqRel,
);

let result = f(frame);

frame
.owner
.store(old_owner, core::sync::atomic::Ordering::Release);
// Inline exception pop without thread exception update
self.exceptions
.borrow_mut()
.stack
.pop()
.expect("pop_exception() without nested exc stack");
crate::vm::thread::set_current_frame(old_frame);
self.frames.borrow_mut().pop();

self.recursion_depth.update(|d| d - 1);
result
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

resume_gen_frame lacks panic-safe cleanup — recursion_depth, frames, and exception stack will leak on unwind.

In with_recursion (line 1047), recursion_depth is decremented via scopeguard::defer!. Here, the manual decrement at line 1174 is skipped if f(frame) panics. The same applies to frames.pop(), exception stack pop, frame-chain restore, and owner restore.

At minimum, wrap the recursion depth in a scopeguard to match with_recursion:

Proposed fix (minimal — recursion_depth guard)
         self.recursion_depth.update(|d| d + 1);
+        scopeguard::defer! { self.recursion_depth.update(|d| d - 1) }
 
         // SAFETY: frame (&FrameRef) stays alive for the duration, so NonNull is valid until pop.
         self.frames
             .borrow_mut()
             .push(FramePtr(NonNull::from(&**frame)));
         let old_frame = crate::vm::thread::set_current_frame((&***frame) as *const Frame);
         frame.previous.store(
             old_frame as *mut Frame,
             core::sync::atomic::Ordering::Relaxed,
         );
         // Inline exception push without thread exception update
         self.exceptions.borrow_mut().stack.push(exc);
         let old_owner = frame.owner.swap(
             crate::frame::FrameOwner::Thread as i8,
             core::sync::atomic::Ordering::AcqRel,
         );
 
         let result = f(frame);
 
         frame
             .owner
             .store(old_owner, core::sync::atomic::Ordering::Release);
         // Inline exception pop without thread exception update
         self.exceptions
             .borrow_mut()
             .stack
             .pop()
             .expect("pop_exception() without nested exc stack");
         crate::vm::thread::set_current_frame(old_frame);
         self.frames.borrow_mut().pop();
 
-        self.recursion_depth.update(|d| d - 1);
         result

Ideally, the frame/exception/owner cleanup would also be wrapped in a scopeguard (or a single RAII guard struct) for full panic safety, as is done for the FIRING flag issue in monitoring.rs.

🤖 Prompt for AI Agents
In `@crates/vm/src/vm/mod.rs` around lines 1129 - 1176, resume_gen_frame currently
performs manual cleanup (recursion_depth.update decrement, frames.pop(),
exceptions.stack.pop(), owner.store(old_owner),
crate::vm::thread::set_current_frame(old_frame)) after calling f(frame), which
will be skipped if f panics; wrap the post-call cleanup in a panic-safe guard
(either use scopeguard::defer! immediately after successfully performing the
setup steps or create a small RAII guard struct) so that on unwind you always:
decrement recursion_depth, pop self.frames, pop the exceptions stack, restore
frame.owner to old_owner, and reset the current thread frame via
crate::vm::thread::set_current_frame(old_frame); place the guard right after
pushing the FramePtr and pushing the exception so it has access to old_owner and
old_frame and remove the manual cleanup at the end or let the guard perform it.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@crates/vm/src/frame.rs`:
- Around line 2957-2963: The monitoring::fire_c_raise call currently ignores its
Result in the Err(exc) branch, causing monitoring errors to be silently dropped;
update the Err(exc) handling so the result of monitoring::fire_c_raise (called
with monitoring::fire_c_raise(vm, self.code, offset, &callable, arg0)) is
checked and its error is either propagated (use the ? operator or map the
monitoring error into the current Err to return) or explicitly handled/logged so
failures aren’t discarded—apply this change in the same block that references
call_arg0, is_python_call, self.lasti(), self.code, offset, callable, and arg0
to match CALL/C_RETURN behavior.
🧹 Nitpick comments (3)
crates/codegen/src/ir.rs (1)

247-273: Consider using an imported HashSet instead of repeated inline paths.

std::collections::HashSet is spelled out in full three times within this block. A use import at the top of the file (or the function) would be slightly cleaner.

Also, a minor observation on correctness: this second NOP-removal pass (post-convert_pseudo_ops) correctly complements the earlier remove_nops pass (pre-pseudo-conversion). New NOPs introduced by convert_pseudo_ops (from SetupFinally, SetupWith, SetupCleanup, PopBlock) are properly filtered here against non_nop_lines, so line-marker uniqueness is preserved.

♻️ Suggested import cleanup

Add to the existing imports at the top of the file:

use std::collections::HashSet;

Then simplify the block:

-            let non_nop_lines: std::collections::HashSet<_> = block
+            let non_nop_lines: HashSet<_> = block
                 .instructions
                 .iter()
                 .filter(|ins| !matches!(ins.instr.real(), Some(Instruction::Nop)))
                 .map(|ins| ins.location.line)
                 .collect();
-            let mut kept_nop_lines: std::collections::HashSet<OneIndexed> =
-                std::collections::HashSet::new();
+            let mut kept_nop_lines: HashSet<OneIndexed> = HashSet::new();
crates/vm/src/coroutine.rs (1)

103-112: Unsafe raw-pointer alias of &self.exception — sound but brittle.

The raw pointer on line 105 is used to sidestep the borrow checker: self is already borrowed, and the closure passed to resume_gen_frame needs mutable-like access to self.exception. The running flag logically guarantees exclusive access. This is sound provided resume_gen_frame invokes the closure synchronously (before the &self borrow expires) and never stores it.

Two observations:

  1. The _old value discarded on line 110 should always be None (swapped in on line 104, with no intervening writes due to the running guard). If that invariant matters, a debug_assert!(_old.is_none()) would make it explicit and catch regressions.

  2. Consider whether resume_gen_frame could be refactored to return the exception state separately, eliminating the need for the raw pointer entirely. That would remove both unsafe blocks here.

💡 Optional: add a debug assertion
             // SAFETY: exclusive access guaranteed by running flag
-            let _old = unsafe { (*exception_ptr).swap(vm.current_exception()) };
+            let old = unsafe { (*exception_ptr).swap(vm.current_exception()) };
+            debug_assert!(old.is_none(), "exception slot should have been None");
             result
crates/vm/src/stdlib/io.rs (1)

2608-2692: Stateless incremental encoder/decoder: significant code duplication.

StatelessIncrementalEncoder::encode and StatelessIncrementalDecoder::decode share nearly identical logic (build args → call function → validate tuple → return first element). Consider extracting a shared helper to reduce duplication.

♻️ Suggested shared helper
+    fn call_stateless_codec(
+        func: &PyObjectRef,
+        input: PyObjectRef,
+        errors: &Option<PyStrRef>,
+        role: &str, // "encoder" or "decoder"
+        vm: &VirtualMachine,
+    ) -> PyResult {
+        let mut args: Vec<PyObjectRef> = vec![input];
+        if let Some(errors) = errors {
+            args.push(errors.to_owned().into());
+        }
+        let res = func.call(args, vm)?;
+        let tuple: PyTupleRef = res.try_into_value(vm)?;
+        if tuple.len() != 2 {
+            return Err(vm.new_type_error(format!(
+                "{role} must return a tuple (object, integer)"
+            )));
+        }
+        Ok(tuple[0].clone())
+    }

Then both encode and decode become one-liners delegating to this helper.

Comment on lines +2957 to +2963
Err(exc) => {
if let Some(arg0) = call_arg0
&& !is_python_call
{
let offset = (self.lasti() - 1) * 2;
let _ = monitoring::fire_c_raise(vm, self.code, offset, &callable, arg0);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

C_RAISE monitoring errors are silently dropped.
fire_c_raise is the only call-related hook that discards callback failures, making monitoring errors invisible and inconsistent with CALL/C_RETURN behavior. Consider propagating (or explicitly handling) the monitoring error to avoid silent loss.

Suggested adjustment
-                if let Some(arg0) = call_arg0
-                    && !is_python_call
-                {
-                    let offset = (self.lasti() - 1) * 2;
-                    let _ = monitoring::fire_c_raise(vm, self.code, offset, &callable, arg0);
-                }
+                if let Some(arg0) = call_arg0
+                    && !is_python_call
+                {
+                    let offset = (self.lasti() - 1) * 2;
+                    if let Err(monitor_exc) =
+                        monitoring::fire_c_raise(vm, self.code, offset, &callable, arg0)
+                    {
+                        return Err(monitor_exc);
+                    }
+                }
🤖 Prompt for AI Agents
In `@crates/vm/src/frame.rs` around lines 2957 - 2963, The
monitoring::fire_c_raise call currently ignores its Result in the Err(exc)
branch, causing monitoring errors to be silently dropped; update the Err(exc)
handling so the result of monitoring::fire_c_raise (called with
monitoring::fire_c_raise(vm, self.code, offset, &callable, arg0)) is checked and
its error is either propagated (use the ? operator or map the monitoring error
into the current Err to return) or explicitly handled/logged so failures aren’t
discarded—apply this change in the same block that references call_arg0,
is_python_call, self.lasti(), self.code, offset, callable, and arg0 to match
CALL/C_RETURN behavior.

@youknowone youknowone force-pushed the sys branch 3 times, most recently from 7990f1c to 8529253 Compare February 15, 2026 15:17
youknowone and others added 9 commits February 16, 2026 03:10
- Add sys.call_tracing as func(*args) dispatch
- Add sys._current_exceptions with per-thread exception tracking
  via thread_exceptions registry synced on push/pop/set_exception
- Fix f_back to use previous_frame pointer and cross-thread lookup
- Add module="sys" to float_info struct sequence
- Fix sys.exit to unpack tuple args
- Set default stdio_errors to surrogateescape
- Remove noisy __del__ warning in object core
- Register latin-1/iso8859-1 codec aliases early for stdio bootstrap
- Fix stdio_errors: use "strict" when encoding is explicitly set
- Use traceback._print_exception_bltin in excepthook
- Cache source in linecache for <string> tracebacks
- Add StatelessIncrementalEncoder/Decoder to _io
- Move sys.flags thread_inherit_context/context_aware_warnings
  to getset properties
- Skip exception slot sync when no exceptions are active to avoid
  O(depth) topmost_exception() walk on every function call
- Remove 5 expectedFailure markers in test_sys
@github-actions
Copy link
Contributor

📦 Library Dependencies

The following Lib/ modules were modified. Here are their dependencies:

[ ] test: cpython/Lib/test/test_c_locale_coercion.py

dependencies:

dependent tests: (no tests depend on c_locale_coercion)

[ ] test: cpython/Lib/test/test_compile.py (TODO: 23)
[x] test: cpython/Lib/test/test_compiler_assemble.py
[x] test: cpython/Lib/test/test_compiler_codegen.py
[x] test: cpython/Lib/test/test_peepholer.py (TODO: 32)

dependencies:

dependent tests: (no tests depend on compile)

[x] test: cpython/Lib/test/test_monitoring.py (TODO: 8)

dependencies:

dependent tests: (no tests depend on monitoring)

[ ] test: cpython/Lib/test/test_sys.py (TODO: 6)
[ ] test: cpython/Lib/test/test_syslog.py (TODO: 2)
[ ] test: cpython/Lib/test/test_sys_setprofile.py (TODO: 14)
[ ] test: cpython/Lib/test/test_sys_settrace.py (TODO: 85)

dependencies:

dependent tests: (213 tests)

  • sys: regrtestdata test___all__ test__colorize test__locale test__osx_support test_android test_annotationlib test_argparse test_array test_ast test_asyncio test_audit test_bdb test_bigaddrspace test_bigmem test_bisect test_buffer test_builtin test_bytes test_bz2 test_c_locale_coercion test_calendar test_call test_cmath test_cmd test_cmd_line test_cmd_line_script test_code test_code_module test_codeccallbacks test_codecs test_collections test_compile test_compileall test_complex test_concurrent_futures test_context test_contextlib test_coroutines test_csv test_ctypes test_datetime test_dbm test_dbm_sqlite3 test_descr test_dict test_difflib test_dis test_doctest test_doctest2 test_docxmlrpc test_dtrace test_dynamic test_dynamicclassattribute test_email test_ensurepip test_enum test_enumerate test_eof test_except_star test_exceptions test_faulthandler test_fcntl test_file test_file_eintr test_fileinput test_fileio test_float test_fork1 test_format test_fractions test_frozen test_functools test_future_stmt test_generators test_genericpath test_getopt test_glob test_grammar test_gzip test_hash test_hashlib test_http_cookiejar test_httpservers test_importlib test_inspect test_int test_io test_iter test_itertools test_json test_largefile test_launcher test_list test_locale test_logging test_long test_lzma test_mailbox test_marshal test_math test_memoryio test_memoryview test_metaclass test_mimetypes test_mmap test_monitoring test_msvcrt test_multiprocessing_fork test_multiprocessing_main_handling test_ntpath test_numeric_tower test_operator test_optparse test_ordered_dict test_os test_osx_env test_pathlib test_patma test_peepholer test_pickle test_pkg test_pkgutil test_platform test_plistlib test_posix test_posixpath test_print test_property test_pty test_pwd test_py_compile test_pyclbr test_pydoc test_pyexpat test_quopri test_raise test_range test_re test_regrtest test_repl test_reprlib test_resource test_runpy test_script_helper test_select test_selectors test_shutil test_signal test_site test_slice test_smtplib test_socket test_sqlite3 test_ssl test_stat test_statistics test_str test_strftime test_string_literals test_strtod test_struct test_subprocess test_support test_symtable test_sys test_sys_setprofile test_sys_settrace test_sysconfig test_syslog test_tarfile test_tempfile test_termios test_threadedtempfile test_threading test_threading_local test_threadsignals test_time test_timeit test_tomllib test_tools test_trace test_traceback test_type_comments test_types test_typing test_unicode_file test_unicode_file_functions test_unicodedata test_unittest test_univnewlines test_urllib test_urllib2 test_urllib2net test_urlparse test_utf8_mode test_uuid test_venv test_wait3 test_wait4 test_wave test_weakref test_webbrowser test_winconsoleio test_with test_wsgiref test_xml_etree test_xmlrpc test_zipapp test_zipfile test_zipfile64 test_zipimport test_zlib

[x] lib: cpython/Lib/warnings.py
[x] lib: cpython/Lib/_py_warnings.py
[ ] test: cpython/Lib/test/test_warnings (TODO: 13)

dependencies:

  • warnings

dependent tests: (49 tests)

  • warnings: test_argparse test_array test_asyncio test_buffer test_builtin test_codecs test_codeop test_coroutines test_ctypes test_decimal test_descr test_fnmatch test_fstring test_funcattrs test_genericpath test_glob test_global test_grammar test_gzip test_hashlib test_hmac test_importlib test_inspect test_io test_logging test_ntpath test_os test_pickle test_posix test_pty test_pyclbr test_random test_re test_runpy test_set test_socket test_sqlite3 test_str test_string_literals test_support test_sys test_tempfile test_threading test_typing test_unicode_file_functions test_unittest test_unparse test_xml_etree test_zipimport

Legend:

  • [+] path exists in CPython
  • [x] up-to-date, [ ] outdated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant