diff --git a/crates/monty/src/bytecode/code.rs b/crates/monty/src/bytecode/code.rs index 97bab5721..23b817a1f 100644 --- a/crates/monty/src/bytecode/code.rs +++ b/crates/monty/src/bytecode/code.rs @@ -276,17 +276,30 @@ pub struct ExceptionEntry { /// The VM pops values until the stack reaches this depth, then /// pushes the exception value. stack_depth: u16, + + /// Number of THIS frame's exceptions that should be on `exception_stack` + /// when execution is inside this try region — i.e., the + /// `except_handler_depth` recorded by the compiler at the try-region + /// entry. Used by the VM during exception unwind to pop entries left + /// behind by handlers that the new exception is propagating past + /// (e.g. `try: raise; except: raise NewError` — the inner except's + /// entry needs to be dropped because the inner handler is abandoned + /// even though its trailer is dead code). Without this, a later bare + /// `raise` could resurrect an exception whose handler had been + /// abandoned via `raise`/`return`/`break`/`continue`. + exception_stack_count: u16, } impl ExceptionEntry { /// Creates a new exception table entry. #[must_use] - pub fn new(start: u32, end: u32, handler: u32, stack_depth: u16) -> Self { + pub fn new(start: u32, end: u32, handler: u32, stack_depth: u16, exception_stack_count: u16) -> Self { Self { start, end, handler, stack_depth, + exception_stack_count, } } @@ -302,6 +315,13 @@ impl ExceptionEntry { self.stack_depth } + /// Returns the number of this-frame `exception_stack` entries expected + /// at the try region — see the field docs. + #[must_use] + pub fn exception_stack_count(&self) -> u16 { + self.exception_stack_count + } + /// Returns true if the given bytecode offset is within this entry's protected range. #[must_use] pub fn contains(&self, offset: u32) -> bool { diff --git a/crates/monty/src/bytecode/compiler.rs b/crates/monty/src/bytecode/compiler.rs index 0f3450fb0..691779578 100644 --- a/crates/monty/src/bytecode/compiler.rs +++ b/crates/monty/src/bytecode/compiler.rs @@ -243,12 +243,7 @@ impl<'a> Compiler<'a> { self.code.emit(Opcode::Pop); // Discard result } Node::Return(expr) => { - self.compile_expr(expr)?; - self.compile_return(); - } - Node::ReturnNone => { - self.code.emit(Opcode::LoadNone); - self.compile_return(); + self.compile_return(expr.as_ref())?; } Node::Assign { target, object } => { self.compile_expr(object)?; @@ -380,6 +375,9 @@ impl<'a> Compiler<'a> { self.compile_expr(exc)?; self.code.emit(Opcode::Raise); } else { + // Bare `raise`: re-raise the current exception (top + // of the VM's exception_stack), or RuntimeError if + // no active exception. self.code.emit(Opcode::Reraise); } } @@ -2223,12 +2221,12 @@ impl<'a> Compiler<'a> { let dead_code_depth = self.code.stack_depth(); let target_loop_depth = self.loop_stack.len() - 1; - // If inside except handlers, clean up ALL exception states - // Each nested except handler has pushed an exception onto the stack, - // so we need to clear/pop each one when breaking out + // If inside except handlers, clean up ALL exception states. + // Each enclosing except handler has pushed an exception onto + // exception_stack and the operand stack — pair them up and pop. for _ in 0..self.except_handler_depth { self.code.emit(Opcode::ClearException); - self.code.emit(Opcode::Pop); // Pop the exception value + self.code.emit(Opcode::Pop); } // Pop the iterator only for `for` loops (has iterator on stack) @@ -2278,12 +2276,10 @@ impl<'a> Compiler<'a> { let dead_code_depth = self.code.stack_depth(); let target_loop_depth = self.loop_stack.len() - 1; - // If inside except handlers, clean up ALL exception states - // Each nested except handler has pushed an exception onto the stack, - // so we need to clear/pop each one when continuing + // If inside except handlers, clean up ALL exception states. for _ in 0..self.except_handler_depth { self.code.emit(Opcode::ClearException); - self.code.emit(Opcode::Pop); // Pop the exception value + self.code.emit(Opcode::Pop); } // Check if we need to go through any finally blocks @@ -2778,19 +2774,59 @@ impl<'a> Compiler<'a> { // Exception Handling Compilation // ======================================================================== - /// Compiles a return statement, handling finally blocks properly. + /// Compiles a return statement, handling exception cleanup and finally blocks. + /// + /// `expr` is the expression after `return` (`None` for a bare `return`). + /// + /// CPython clears the active-exception state when control transfers out + /// of an except clause via `return` (so a surrounding finally — or the + /// caller after this function returns — sees no active exception). We + /// match that by emitting `ClearException + Pop` for each enclosing + /// except handler before evaluating the return value: + /// + /// 1. For each enclosing handler: pop its `exception_stack` entry + /// (`ClearException`) and its operand-stack exception value (`Pop`). + /// Doing this BEFORE evaluating the return expression means the + /// final operand-stack state is just `[return_value]`, no leaked + /// exception values underneath. + /// 2. Push the return value (or `LoadNone` for bare `return`). + /// 3. Route: jump to enclosing finally-with-return path, or + /// `ReturnValue` directly. + fn compile_return(&mut self, expr: Option<&ExprLoc>) -> Result<(), CompileError> { + // `return` never falls through; preserve the statement-entry depth + // for any unreachable code that follows in the same block. + let dead_code_depth = self.code.stack_depth(); + + for _ in 0..self.except_handler_depth { + self.code.emit(Opcode::ClearException); + self.code.emit(Opcode::Pop); + } + + if let Some(expr) = expr { + self.compile_expr(expr)?; + } else { + self.code.emit(Opcode::LoadNone); + } + + self.emit_return_routing(); + self.code.set_stack_depth(dead_code_depth); + Ok(()) + } + + /// Emits the routing portion of a return — jump into the enclosing + /// finally-with-return path if any, otherwise emit `ReturnValue`. /// - /// If we're inside a try-finally block, the return value is kept on the stack - /// and we jump to a "finally with return" section that runs finally then returns. - /// Otherwise, we emit a direct `ReturnValue`. - fn compile_return(&mut self) { + /// Assumes the return value is already on top of the stack and that any + /// active exception cleanup (for enclosing except handlers) has already + /// happened. Used by `compile_return` and by the tail of a + /// finally-with-return path, which arrives with the value on the stack + /// from the upstream `return` and needs to pass it to either an outer + /// finally or back to the caller. + fn emit_return_routing(&mut self) { if let Some(finally_target) = self.finally_targets.last_mut() { - // Inside a try-finally: jump to finally, then return - // Return value is already on stack let jump = self.code.emit_jump(Opcode::Jump); finally_target.return_jumps.push(jump); } else { - // Normal return self.code.emit(Opcode::ReturnValue); } } @@ -2835,6 +2871,11 @@ impl<'a> Compiler<'a> { // Record stack depth at try entry (for unwinding on exception) let stack_depth = self.code.stack_depth(); + // Record `except_handler_depth` at try entry — the count of this + // frame's exception_stack entries that should be active inside the + // try body. The VM uses this on unwind to drain entries left + // behind by abandoned-but-trailer-skipped handlers. + let try_exc_stack_count = u16::try_from(self.except_handler_depth).expect("except_handler_depth exceeds u16"); // If there's a finally block, track returns/break/continue inside try/handlers/else if has_finally { @@ -2884,19 +2925,22 @@ impl<'a> Compiler<'a> { let handler_dispatch_end = self.code.current_offset(); // === Finally cleanup handler (for exceptions during handler dispatch) === - // This catches exceptions from RERAISE (and any other exceptions in handlers) - // and ensures finally runs before the exception propagates. + // This catches exceptions from `Raise` (when the last handler didn't + // match) and any other exceptions raised in handler bodies, runs the + // finally block, then re-raises. The exception lives in an anonymous + // local slot for the duration of the finally body — same slot + // mechanism as a regular except handler — so a bare `raise` inside + // the finally body resolves to the in-flight exception. let finally_cleanup_start = if has_finally { let cleanup_start = self.code.current_offset(); // Exception value is on stack (pushed by VM), so stack = stack_depth + 1 self.code.set_stack_depth(stack_depth + 1); - // We need to pop it, run finally, then reraise - // But we can't easily save the exception, so we use a different approach: + // We need to pop it, run finally, then reraise. // The exception is already on the exception_stack from handle_exception, - // so we can just pop from operand stack, run finally, then reraise. - self.code.emit(Opcode::Pop); // Pop exception from operand stack + // so we just pop from operand stack, run finally, then Reraise. + self.code.emit(Opcode::Pop); self.compile_block(&try_block.finally)?; - self.code.emit(Opcode::Reraise); // Re-raise from exception_stack + self.code.emit(Opcode::Reraise); Some(cleanup_start) } else { None @@ -2918,7 +2962,9 @@ impl<'a> Compiler<'a> { // Return value is on stack, stack = stack_depth + 1 self.code.set_stack_depth(stack_depth + 1); self.compile_block(&try_block.finally)?; - self.compile_return(); + // Return value is already on stack from the upstream Jump. + // Route through any outer finally, or emit ReturnValue. + self.emit_return_routing(); Some(start) }; @@ -2980,46 +3026,57 @@ impl<'a> Compiler<'a> { // === Add exception table entries === // Order matters: entries are searched in order, so inner entries must come first. - // Entry 1: Try body -> handler dispatch + // Entry 1: Try body -> handler dispatch. + // exception_stack_count = try_exc_stack_count: entering the try body + // adds no handler entries. if has_handlers || has_finally { self.code.add_exception_entry(ExceptionEntry::new( u32::try_from(try_start).expect("bytecode offset exceeds u32"), u32::try_from(try_end).expect("bytecode offset exceeds u32") + 3, // +3 to include the JUMP instruction u32::try_from(handler_start).expect("bytecode offset exceeds u32"), stack_depth, + try_exc_stack_count, )); } - // Entry 2: Handler dispatch -> finally cleanup (only if has_finally) - // This ensures finally runs when RERAISE is executed or any exception occurs in handlers + // Entry 2: Handler dispatch -> finally cleanup (only if has_finally). + // exception_stack_count = try_exc_stack_count + 1: the original + // exception was pushed onto exception_stack by entry 1's catch and + // is still active throughout handler dispatch. if let Some(cleanup_start) = finally_cleanup_start { self.code.add_exception_entry(ExceptionEntry::new( u32::try_from(handler_start).expect("bytecode offset exceeds u32"), u32::try_from(handler_dispatch_end).expect("bytecode offset exceeds u32"), u32::try_from(cleanup_start).expect("bytecode offset exceeds u32"), stack_depth, + try_exc_stack_count + 1, )); } - // Entry 3: Finally with return -> finally cleanup - // If an exception occurs while running finally (in the return path), catch it + // Entry 3: Finally with return -> finally cleanup. + // Reached via Jump from compile_return, which already emitted + // ClearException for each enclosing handler — so on entry the + // exception_stack count is back to try_exc_stack_count. if let (Some(return_start), Some(cleanup_start)) = (finally_with_return_start, finally_cleanup_start) { self.code.add_exception_entry(ExceptionEntry::new( u32::try_from(return_start).expect("bytecode offset exceeds u32"), - u32::try_from(else_start).expect("bytecode offset exceeds u32"), // End at else_start (before else block) + u32::try_from(else_start).expect("bytecode offset exceeds u32"), u32::try_from(cleanup_start).expect("bytecode offset exceeds u32"), stack_depth, + try_exc_stack_count, )); } - // Entry 4: Else block -> finally cleanup (only if has_finally and has_else) - // Exceptions in else block should go through finally + // Entry 4: Else block -> finally cleanup (only if has_finally and + // has_else). Else runs when no exception was raised, so no handler + // pushed an entry: exception_stack_count = try_exc_stack_count. if has_else && let Some(cleanup_start) = finally_cleanup_start { self.code.add_exception_entry(ExceptionEntry::new( u32::try_from(else_start).expect("bytecode offset exceeds u32"), u32::try_from(else_end).expect("bytecode offset exceeds u32"), u32::try_from(cleanup_start).expect("bytecode offset exceeds u32"), stack_depth, + try_exc_stack_count, )); } @@ -3071,7 +3128,6 @@ impl<'a> Compiler<'a> { // Stack: [exception, exception, exc_type] // Check if exception matches the type - // This validates exc_type is a valid exception type and performs the match // CheckExcMatch pops exc_type, peeks exception, pushes bool self.code.emit(Opcode::CheckExcMatch); // Stack: [exception, exception, bool] @@ -3080,10 +3136,7 @@ impl<'a> Compiler<'a> { // JumpIfFalse pops the bool, leaving [exception, exception] let no_match_jump = self.code.emit_jump(Opcode::JumpIfFalse); - if is_last { - // Last handler - if no match, reraise - // But first we need to handle the exception var cleanup - } else { + if !is_last { next_handler_jumps.push(no_match_jump); } @@ -3127,7 +3180,6 @@ impl<'a> Compiler<'a> { if is_last { self.code.patch_jump(no_match_jump); // Coming from JumpIfFalse no-match path, stack has [exception, exception] - // Reset stack depth for jump target self.code.set_stack_depth(handler_entry_depth + 1); // We need to pop the duplicate before reraising self.code.emit(Opcode::Pop); diff --git a/crates/monty/src/bytecode/op.rs b/crates/monty/src/bytecode/op.rs index b68a623eb..eb72f46bc 100644 --- a/crates/monty/src/bytecode/op.rs +++ b/crates/monty/src/bytecode/op.rs @@ -538,8 +538,8 @@ impl Opcode { // Exception handling Raise => -1, // pop exception - Reraise => 0, // no stack change (reads from exception_stack) - ClearException => 0, // clears exception_stack, no operand stack change + Reraise => 0, // raises RuntimeError("No active exception to reraise") + ClearException => 0, // vestigial, no-op (kept for back-compat with serialized bytecode) CheckExcMatch => 0, // pop exc_type, push bool (net 0, but exc stays) // Return diff --git a/crates/monty/src/bytecode/vm/async_exec.rs b/crates/monty/src/bytecode/vm/async_exec.rs index 7b6878535..a631d3e8e 100644 --- a/crates/monty/src/bytecode/vm/async_exec.rs +++ b/crates/monty/src/bytecode/vm/async_exec.rs @@ -283,10 +283,12 @@ impl<'h, T: ResourceTracker> VM<'h, T> { self.stack.extend(namespace_values); // Push frame to execute the coroutine + let exc_stack_base = self.exception_stack.len(); self.push_frame(CallFrame::new_function( &func.code, stack_base, locals_count, + exc_stack_base, func_id, call_position, ))?; @@ -541,8 +543,8 @@ impl<'h, T: ResourceTracker> VM<'h, T> { /// Saves the current VM context into the given task in the scheduler. /// - /// Serializes frames, moves stack/exception_stack, stores instruction_ip, - /// and adjusts the global recursion depth counter. + /// Serializes frames, moves the operand stack and exception stack, + /// stores instruction_ip, and adjusts the global recursion depth counter. fn save_task_context(&mut self, task_id: TaskId) { let frames: Vec = self .frames @@ -552,6 +554,7 @@ impl<'h, T: ResourceTracker> VM<'h, T> { ip: f.ip, stack_base: f.stack_base, locals_count: f.locals_count, + exception_stack_base: f.exception_stack_base, call_position: f.call_position, }) .collect(); @@ -614,6 +617,7 @@ impl<'h, T: ResourceTracker> VM<'h, T> { ip: sf.ip, stack_base: sf.stack_base, locals_count: sf.locals_count, + exception_stack_base: sf.exception_stack_base, function_id: sf.function_id, call_position: sf.call_position, should_return: false, @@ -694,10 +698,12 @@ impl<'h, T: ResourceTracker> VM<'h, T> { let stack_base = self.stack.len(); self.stack.extend(namespace_values); + let exc_stack_base = self.exception_stack.len(); self.push_frame(CallFrame::new_function( &func.code, stack_base, locals_count, + exc_stack_base, func_id, None, // No call position — this is the root frame for a spawned task ))?; diff --git a/crates/monty/src/bytecode/vm/call.rs b/crates/monty/src/bytecode/vm/call.rs index 9d49dbaf9..c2ed8ff22 100644 --- a/crates/monty/src/bytecode/vm/call.rs +++ b/crates/monty/src/bytecode/vm/call.rs @@ -758,10 +758,12 @@ impl VM<'_, T> { let (namespace, this) = namespace_guard.into_parts(); this.stack.extend(namespace); + let exc_stack_base = this.exception_stack.len(); this.push_frame(CallFrame::new_function( code, stack_base, locals_count, + exc_stack_base, func_id, call_position, ))?; diff --git a/crates/monty/src/bytecode/vm/exceptions.rs b/crates/monty/src/bytecode/vm/exceptions.rs index 19093e3a7..8b1436790 100644 --- a/crates/monty/src/bytecode/vm/exceptions.rs +++ b/crates/monty/src/bytecode/vm/exceptions.rs @@ -6,6 +6,7 @@ use crate::{ defer_drop, exception_private::{ExcType, ExceptionRaise, RawStackFrame, RunError, SimpleException}, heap::{HeapData, HeapGuard}, + heap_traits::CloneWithHeap, intern::{StaticStrings, StringId}, resource::ResourceTracker, types::{PyTrait, Type}, @@ -80,14 +81,8 @@ impl VM<'_, T> { let simple_exc = match exc_value { // Exception instance on heap - Value::Ref(heap_id) => { - if let HeapData::Exception(exc) = this.heap.get(*heap_id) { - // Clone the exception (guard handles cleanup at scope exit) - exc.clone() - } else { - // Not an exception type - SimpleException::new_msg(ExcType::TypeError, "exceptions must derive from BaseException") - } + Value::Ref(heap_id) if let HeapData::Exception(exc) = this.heap.get(*heap_id) => { + exc.clone_with_heap(this.heap) } // Exception type (e.g., `raise ValueError` instead of `raise ValueError()`) // Instantiate with no message @@ -124,29 +119,28 @@ impl VM<'_, T> { pub(super) fn handle_exception(&mut self, mut error: RunError) -> Option { // Ensure exception has initial frame info error = self.attach_frame_to_error(error); + let mut error_guard = HeapGuard::new(error, self); + let (error, this) = error_guard.as_parts_mut(); // For uncatchable exceptions (ResourceError like RecursionError), // we still need to unwind the stack to collect all frames for the traceback - if matches!(error, RunError::UncatchableExc(_) | RunError::Internal(_)) { - return Some(self.unwind_for_traceback(error)); - } - - // Only catchable exceptions can be handled - let exc_info = match &error { - RunError::Exc(exc) => exc.clone(), - RunError::UncatchableExc(_) | RunError::Internal(_) => unreachable!(), + let exc_info = match error { + RunError::Exc(exc) => exc, + RunError::UncatchableExc(_) | RunError::Internal(_) => { + let error = error_guard.into_inner(); + return Some(self.unwind_for_traceback(error)); + } }; // Create exception value to push on stack - let exc_value = self.create_exception_value(&exc_info); - let exc_value = match exc_value { + let exc_value = match this.create_exception_value(exc_info) { Ok(v) => v, Err(e) => return Some(e), }; // Use HeapGuard because exc_value is conditionally consumed (pushed onto // exception_stack when handler found) or dropped (when no handler found) - let mut exc_guard = HeapGuard::new(exc_value, self); + let mut exc_guard = HeapGuard::new(exc_value, this); // Search for handler in current and outer frames loop { @@ -159,6 +153,7 @@ impl VM<'_, T> { // Found a handler! Unwind stack and jump to it. let handler_offset = usize::try_from(entry.handler()).expect("handler offset exceeds usize"); let target_stack_depth = frame.stack_base + frame.locals_count as usize + entry.stack_depth() as usize; + let target_exc_stack_depth = frame.exception_stack_base + entry.exception_stack_count() as usize; // Unwind stack to target depth (drop excess values) while this.stack.len() > target_stack_depth { @@ -166,6 +161,17 @@ impl VM<'_, T> { value.drop_with_heap(this); } + // Drop any `exception_stack` entries left behind by handlers + // the propagating exception is bypassing — without this, a + // handler whose body terminated via `raise`/`return`/`break`/ + // `continue` (so its trailer's `ClearException` is dead code) + // would leak its exception onto `exception_stack`, where a + // later bare `raise` could resurrect it. + while this.exception_stack.len() > target_exc_stack_depth { + let value = this.exception_stack.pop().unwrap(); + value.drop_with_heap(this); + } + // Push exception value onto stack (handler expects it) let exc_for_stack = exc_value.clone_with_heap(this); this.push(exc_for_stack); @@ -174,7 +180,7 @@ impl VM<'_, T> { let (exc_value, this) = exc_guard.into_parts(); // Push exception onto the exception_stack for bare raise - // This allows nested except handlers to restore outer exception context + // and for `__context__` lookup on subsequent exceptions. this.exception_stack.push(exc_value); // Jump to handler @@ -193,6 +199,7 @@ impl VM<'_, T> { // For spawned tasks, fail the task instead of propagating if is_spawned { + let error = error_guard.into_inner(); match self.handle_task_failure(error) { Ok(()) => { // Switched to next task - continue execution @@ -205,7 +212,7 @@ impl VM<'_, T> { } } - return Some(error); + return Some(error_guard.into_inner()); } // Get the call site position before popping frame @@ -216,17 +223,14 @@ impl VM<'_, T> { if this.pop_frame() { // The frame indicated evaluation should stop - e.g. inside `evaluate_function` - return the error // now to stop unwinding. - return Some(error); + drop(exc_guard); // To release borrow on `this` + return Some(error_guard.into_inner()); } // Add caller frame info to traceback (if we have call position) if let Some(pos) = call_position { let frame_name = this.current_frame_name(); - match &mut error { - RunError::Exc(exc) => exc.add_caller_frame(pos, frame_name), - RunError::UncatchableExc(exc) => exc.add_caller_frame(pos, frame_name), - RunError::Internal(_) => {} - } + exc_info.add_caller_frame(pos, frame_name); } } } @@ -260,8 +264,27 @@ impl VM<'_, T> { /// Creates an exception Value from exception info. /// /// Allocates an Exception on the heap and returns a Value::Ref to it. + /// Also sets the new exception's implicit `__context__` chain to the + /// previously-being-handled exception (top of `exception_stack`), + /// matching CPython's behavior — when an exception is raised inside + /// an `except` (or `finally`) handler, its `__context__` points at + /// the exception the handler was processing. fn create_exception_value(&mut self, exc: &ExceptionRaise) -> Result { - let exception = exc.exc.clone(); + // `clone_with_heap` inc_refs the context HeapId, transferring an + // owning ref into the local `exception`. Moving `exception` into + // `HeapData::Exception` below transfers that ref to the heap + // entry (balanced by `py_dec_ref_ids_for_data` when freed). + let mut exception = exc.exc.clone_with_heap(self.heap); + let new_context = self.exception_stack.last().and_then(|v| match v { + Value::Ref(heap_id) => Some(*heap_id), + _ => None, + }); + if let Some(ctx_id) = new_context { + // Take a fresh ref on the new context, then `replace_context` + // drops the previously-cloned context ref (if any). + self.heap.inc_ref(ctx_id); + exception.replace_context(self.heap, Some(ctx_id)); + } let heap_id = self.heap.allocate(HeapData::Exception(exception))?; Ok(Value::Ref(heap_id)) } diff --git a/crates/monty/src/bytecode/vm/mod.rs b/crates/monty/src/bytecode/vm/mod.rs index 38c826143..f9d9c1ebf 100644 --- a/crates/monty/src/bytecode/vm/mod.rs +++ b/crates/monty/src/bytecode/vm/mod.rs @@ -352,6 +352,19 @@ pub struct CallFrame<'code> { /// For function frames, this equals `func.namespace_size`. locals_count: u16, + /// Base index into the VM-wide `exception_stack` for this frame. + /// + /// Entries pushed by `except` handlers in this frame live at + /// `exception_stack[exception_stack_base..]`, while + /// `exception_stack[..exception_stack_base]` belongs to caller frames. + /// `ExceptionEntry.exception_stack_count` is relative to this base — + /// on exception unwind, the VM drains entries down to + /// `exception_stack_base + entry.exception_stack_count()` so that + /// handlers abandoned by the propagating exception (whose + /// fall-through trailers are dead code) don't leave residue that a + /// later bare `raise` would resurrect. + exception_stack_base: usize, + /// Function ID (for tracebacks). None for module-level code. function_id: Option, @@ -368,12 +381,13 @@ impl<'code> CallFrame<'code> { /// /// Module frames have `locals_count = 0` because module-level variables /// are stored in the VM's `globals` vec, not in the stack. - pub fn new_module(code: &'code Code) -> Self { + pub fn new_module(code: &'code Code, exception_stack_base: usize) -> Self { Self { code, ip: 0, stack_base: 0, locals_count: 0, + exception_stack_base, function_id: None, call_position: None, should_return: false, @@ -388,6 +402,7 @@ impl<'code> CallFrame<'code> { code: &'code Code, stack_base: usize, locals_count: u16, + exception_stack_base: usize, function_id: FunctionId, call_position: Option, ) -> Self { @@ -396,6 +411,7 @@ impl<'code> CallFrame<'code> { ip: 0, stack_base, locals_count, + exception_stack_base, function_id: Some(function_id), call_position, should_return: false, @@ -447,6 +463,10 @@ pub struct SerializedFrame { /// Number of local variable slots (0 for module-level frames). locals_count: u16, + /// Base index into the VM-wide `exception_stack` for this frame. + /// See `CallFrame.exception_stack_base`. + exception_stack_base: usize, + /// Call site position (for tracebacks). call_position: Option, } @@ -463,6 +483,7 @@ impl CallFrame<'_> { ip: self.ip, stack_base: self.stack_base, locals_count: self.locals_count, + exception_stack_base: self.exception_stack_base, call_position: self.call_position, } } @@ -493,11 +514,7 @@ pub struct VMSnapshot { /// Call frames (serializable form — stores FunctionId, not &Code). frames: Vec, - /// Stack of exceptions being handled for nested except blocks. - /// - /// When entering an except handler, the exception is pushed onto this stack. - /// When exiting via `ClearException`, the top is popped. This allows nested - /// except handlers to restore the outer exception context. + /// Stack of currently-being-handled exceptions (see VM field for full docs). exception_stack: Vec, /// IP of the instruction that caused the pause (for exception handling). @@ -545,12 +562,20 @@ pub struct VM<'h, T: ResourceTracker> { /// Print output writer, borrowed so callers retain access to collected output. pub(crate) print_writer: PrintWriter<'h>, - /// Stack of exceptions being handled for nested except blocks. + /// Stack of currently-being-handled exceptions for the running task. + /// + /// Each `except` handler entry pushes the caught exception onto this + /// stack; each handler exit (`ClearException` from the handler trailer + /// or break/continue/return cleanup) pops it. Bare `raise` re-raises + /// the top. The VM also reads the top when *creating* a new exception + /// to set its `__context__` attribute, matching CPython's exception + /// chaining semantics. /// - /// Used by bare `raise` to re-raise the current exception. - /// When entering an except handler, the exception is pushed onto this stack. - /// When exiting via `ClearException`, the top is popped. This allows nested - /// except handlers to restore the outer exception context. + /// Every entry is owned (refcount already incremented at handler entry). + /// Drained by frame teardown / task cleanup. On exception unwind, the + /// VM drains entries down to the new handler's recorded depth so that + /// handlers abandoned by the propagating exception (whose trailer + /// would have popped them but is dead code) don't leave residue. exception_stack: Vec, /// IP of the instruction being executed (for exception table lookup). @@ -645,6 +670,7 @@ impl<'h, T: ResourceTracker> VM<'h, T> { ip: sf.ip, stack_base: sf.stack_base, locals_count: sf.locals_count, + exception_stack_base: sf.exception_stack_base, function_id: sf.function_id, call_position: sf.call_position, should_return: false, @@ -703,7 +729,8 @@ impl<'h, T: ResourceTracker> VM<'h, T> { pub fn run_module(&mut self, code: &'h Code) -> Result { // Store module code for restoring main task frames during task switching self.module_code = Some(code); - self.push_frame(CallFrame::new_module(code))?; + let exc_stack_base = self.exception_stack.len(); + self.push_frame(CallFrame::new_module(code, exc_stack_base))?; self.run() } @@ -1417,8 +1444,8 @@ impl<'h, T: ResourceTracker> VM<'h, T> { catch_sync!(self, cached_frame, error); } Opcode::Reraise => { - // Pop the current exception from the stack to re-raise it - // If caught, handle_exception will push it back + // Pop the current exception from the chain and raise it. + // If caught, handle_exception will push it back. let error = if let Some(exc) = self.exception_stack.pop() { self.make_exception(exc, true) // is_raise=true for reraise } else { @@ -1428,8 +1455,7 @@ impl<'h, T: ResourceTracker> VM<'h, T> { catch_sync!(self, cached_frame, error); } Opcode::ClearException => { - // Pop the current exception from the stack - // This restores the previous exception context (if any) + // Pop the current exception from the chain. if let Some(exc) = self.exception_stack.pop() { exc.drop_with_heap(self); } diff --git a/crates/monty/src/bytecode/vm/scheduler.rs b/crates/monty/src/bytecode/vm/scheduler.rs index e888f1dc2..ac0cea9d5 100644 --- a/crates/monty/src/bytecode/vm/scheduler.rs +++ b/crates/monty/src/bytecode/vm/scheduler.rs @@ -69,7 +69,10 @@ pub(crate) struct Task { /// Operand stack for this task. /// Empty for the main task (which uses VM's stack directly). pub stack: Vec, - /// Exception stack for nested except blocks. + /// Stack of currently-being-handled exceptions for this task. + /// Pushed when an except handler is entered, popped when it exits; + /// bare `raise` re-raises the top, and the VM consults the top to + /// set `__context__` on newly-created exceptions. pub exception_stack: Vec, /// VM-level instruction_ip (for exception table lookup). pub instruction_ip: usize, @@ -124,6 +127,9 @@ pub(crate) struct SerializedTaskFrame { pub stack_base: usize, /// Number of local variable slots (0 for module-level frames). pub locals_count: u16, + /// Base index into the VM-wide `exception_stack` for this frame. + /// See `CallFrame.exception_stack_base`. + pub exception_stack_base: usize, /// Call site position (for tracebacks). pub call_position: Option, } diff --git a/crates/monty/src/exception_private.rs b/crates/monty/src/exception_private.rs index 6b5bb4aa8..302f8df0a 100644 --- a/crates/monty/src/exception_private.rs +++ b/crates/monty/src/exception_private.rs @@ -13,7 +13,8 @@ use crate::{ defer_drop, exception_public::{MontyException, SourceMap, StackFrame}, fstring::FormatError, - heap::{HeapData, HeapRead}, + heap::{ContainsHeap, DropWithHeap, HeapData, HeapId, HeapRead}, + heap_traits::CloneWithHeap, intern::{Interns, StaticStrings, StringId}, parse::CodeRange, resource::ResourceTracker, @@ -1376,10 +1377,32 @@ impl ExcType { /// /// This is used for performance reasons for common exception patterns. /// Exception messages use `String` for owned storage. -#[derive(Debug, Clone, PartialEq, Hash, serde::Serialize, serde::Deserialize)] +/// +/// `context` is the implicit exception chain set by Python — when a new +/// exception is raised inside an `except` (or `finally`) handler, the +/// VM sets `context` to the HeapId of the previously-being-handled +/// exception. The chain is exposed as the `__context__` attribute and +/// shows up in tracebacks as "During handling of the above exception, +/// another exception occurred." +/// +/// **Refcount invariant:** every live `SimpleException` whose `context` +/// is `Some(_)` owns one refcount on that HeapId. The owner is whatever +/// holds the `SimpleException`: +/// +/// - A `HeapData::Exception` heap entry releases its ref via +/// `py_dec_ref_ids_for_data` when the entry is freed. +/// - A `SimpleException` in transit (e.g. inside a `RunError::Exc`) +/// must release its ref via [`SimpleException::drop_with_heap`] when +/// discarded, or transfer ownership by being moved into a heap entry. +/// +/// `Clone` is intentionally NOT derived — copying the `HeapId` bits without +/// `inc_ref` would silently break the invariant. Use +/// [`SimpleException::clone_with_heap`] instead. +#[derive(Debug, PartialEq, Hash, serde::Serialize, serde::Deserialize)] pub(crate) struct SimpleException { exc_type: ExcType, arg: Option, + context: Option, } impl fmt::Display for SimpleException { @@ -1392,6 +1415,7 @@ impl From for SimpleException { Self { exc_type: exc.exc_type(), arg: exc.into_message(), + context: None, } } } @@ -1400,7 +1424,11 @@ impl SimpleException { /// Creates a new exception with the given type and optional argument message. #[must_use] pub fn new(exc_type: ExcType, arg: Option) -> Self { - Self { exc_type, arg } + Self { + exc_type, + arg, + context: None, + } } /// Creates a new exception with the given type and argument message. @@ -1409,13 +1437,18 @@ impl SimpleException { Self { exc_type, arg: Some(arg.to_string()), + context: None, } } /// Creates a new exception with the given type and no argument message. #[must_use] pub fn new_none(exc_type: ExcType) -> Self { - Self { exc_type, arg: None } + Self { + exc_type, + arg: None, + context: None, + } } #[must_use] @@ -1428,6 +1461,36 @@ impl SimpleException { self.arg.as_ref() } + /// Returns the implicit `__context__` chain HeapId, if any. + #[must_use] + pub fn context(&self) -> Option { + self.context + } + + /// Replaces the implicit `__context__` chain HeapId. + /// + /// Manages refcounts according to the invariant described on + /// [`SimpleException`]: drops the previous context (if any) and takes + /// ownership of one refcount on `new_context` (the caller must have + /// already `inc_ref`d it, or be transferring an existing owned ref). + pub fn replace_context(&mut self, heap: &mut impl ContainsHeap, new_context: Option) { + if let Some(old_id) = self.context { + heap.heap_mut().dec_ref(old_id); + } + self.context = new_context; + } + + /// Releases the owning refcount on `context` (if any). + /// + /// Must be called for any `SimpleException` that is discarded without + /// being moved into a `HeapData::Exception` heap entry; otherwise the + /// chained exception's refcount leaks. + pub fn drop_with_heap(self, heap: &mut impl ContainsHeap) { + if let Some(ctx_id) = self.context { + heap.heap_mut().dec_ref(ctx_id); + } + } + /// str() for an exception #[must_use] pub fn py_str(&self) -> String { @@ -1440,6 +1503,16 @@ impl SimpleException { } } +impl CloneWithHeap for SimpleException { + fn clone_with_heap(&self, heap: &H) -> Self { + Self { + exc_type: self.exc_type, + arg: self.arg.clone(), + context: self.context.clone_with_heap(heap), + } + } +} + impl<'h> HeapRead<'h, SimpleException> { pub(crate) fn py_type(&self, vm: &VM<'h, impl ResourceTracker>) -> Type { Type::Exception(self.get(vm.heap).exc_type) @@ -1479,13 +1552,16 @@ impl SimpleException { impl<'h> HeapRead<'h, SimpleException> { /// Gets an attribute from this exception. /// - /// Handles the `.args` attribute by allocating a tuple containing the message. - /// Returns `Err(AttributeError)` for all other attributes. + /// Handles `.args` (a tuple containing the message) and `.__context__` + /// (the implicitly-chained previously-being-handled exception, or + /// `None`). Returns `Ok(None)` for unrecognized attributes so the + /// caller can fall through to the generic AttributeError path. pub fn py_getattr(&self, attr: &EitherStr, vm: &mut VM<'h, impl ResourceTracker>) -> RunResult> { // Fast path: interned strings can be matched by ID - let is_args = attr - .static_string() - .map_or_else(|| attr.as_str(vm.interns) == "args", |ss| ss == StaticStrings::Args); + let attr_static = attr.static_string(); + let attr_str = attr.as_str(vm.interns); + let is_args = attr_static.map_or_else(|| attr_str == "args", |ss| ss == StaticStrings::Args); + let is_context = attr_str == "__context__"; if is_args { // Construct tuple with 0 or 1 elements based on whether arg exists @@ -1496,6 +1572,20 @@ impl<'h> HeapRead<'h, SimpleException> { smallvec![] }; Ok(Some(CallResult::Value(allocate_tuple(elements, vm.heap)?))) + } else if is_context { + // `__context__` is None if there was no exception being handled + // when this one was raised, otherwise a Ref to the chained + // exception. Inc_ref on read so the caller's value owns its + // own refcount (matches the convention of attribute-getter + // results). + let value = match self.get(vm.heap).context { + Some(ctx_id) => { + vm.heap.inc_ref(ctx_id); + Value::Ref(ctx_id) + } + None => Value::None, + }; + Ok(Some(CallResult::Value(value))) } else { Ok(None) } @@ -1503,7 +1593,12 @@ impl<'h> HeapRead<'h, SimpleException> { } /// A raised exception with optional stack frame for traceback. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +/// +/// Inherits the refcount invariant from [`SimpleException`]: the contained +/// `exc` owns one refcount on its `context` HeapId (if any). Use +/// [`ExceptionRaise::clone_with_heap`] to clone (no `Clone` impl) and +/// [`ExceptionRaise::drop_with_heap`] to release on discard. +#[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct ExceptionRaise { pub exc: SimpleException, /// The stack frame where the exception was raised (first in vec is closest "bottom" frame). @@ -1538,6 +1633,13 @@ impl From for ExceptionRaise { } impl ExceptionRaise { + /// Releases the inner exception's owning refcount on its context (if any). + /// Must be called for any `ExceptionRaise` that is discarded without + /// being consumed via a path that takes responsibility for the refcount. + pub(crate) fn drop_with_heap(self, heap: &mut impl ContainsHeap) { + self.exc.drop_with_heap(heap); + } + /// Adds a caller's frame as the outermost frame in the traceback chain. /// /// This is used when an exception propagates up through call frames. @@ -1766,6 +1868,14 @@ impl RunError { } } +impl DropWithHeap for RunError { + fn drop_with_heap(self, heap: &mut H) { + if let Self::Exc(exc) | Self::UncatchableExc(exc) = self { + exc.drop_with_heap(heap); + } + } +} + /// Formats a list of parameter names for error messages. /// /// Examples: diff --git a/crates/monty/src/expressions.rs b/crates/monty/src/expressions.rs index 3107f309d..9ef2e309c 100644 --- a/crates/monty/src/expressions.rs +++ b/crates/monty/src/expressions.rs @@ -501,8 +501,8 @@ pub enum Node { /// No-op statement. Only present in parsed form, filtered out during prepare. Pass, Expr(ExprLoc), - Return(ExprLoc), - ReturnNone, + /// `return [expr]`. `None` for a bare `return` (yields `None`). + Return(Option), Raise(Option), Assert { test: ExprLoc, diff --git a/crates/monty/src/heap.rs b/crates/monty/src/heap.rs index b3e68ddd7..ffc039dd8 100644 --- a/crates/monty/src/heap.rs +++ b/crates/monty/src/heap.rs @@ -1453,6 +1453,14 @@ fn collect_child_ids(data: &HeapData, work_list: &mut Vec) { work_list.push(tz_id); } } + HeapData::Exception(exc) => { + // Follow the implicit `__context__` chain so chained exceptions + // aren't swept while their causally-prior exception is still + // reachable through the chain. + if let Some(ctx_id) = exc.context() { + work_list.push(ctx_id); + } + } // Leaf types with no heap references _ => {} } @@ -1514,6 +1522,15 @@ fn py_dec_ref_ids_for_data(data: &mut HeapData, stack: &mut Vec) { stack.push(tz_id); } } + HeapData::Exception(exc) => { + // Drop the implicit `__context__` chain reference so the chained + // exception's refcount is balanced. The heap entry owned one + // ref on its context (set when the exception was caught — see + // `create_exception_value`). + if let Some(ctx_id) = exc.context() { + stack.push(ctx_id); + } + } // other types have no nested heap references _ => {} } diff --git a/crates/monty/src/heap_traits.rs b/crates/monty/src/heap_traits.rs index 6bdaf8dcb..793647be1 100644 --- a/crates/monty/src/heap_traits.rs +++ b/crates/monty/src/heap_traits.rs @@ -286,3 +286,26 @@ macro_rules! defer_drop_mut { let ($value, $heap) = _guard.as_parts_mut(); }; } + +/// Reverse of `DropWithHeap` for cloning data which contains heap references. +/// +/// Not all types implement both these traits, e.g. to discourage cloning complex structures like `Set` +/// where it's probably better to inc-ref the existing heap allocation rather than clone it. +pub(crate) trait CloneWithHeap: Sized { + fn clone_with_heap(&self, heap: &H) -> Self; +} + +impl CloneWithHeap for HeapId { + #[inline] + fn clone_with_heap(&self, heap: &H) -> Self { + heap.heap().inc_ref(*self); + *self + } +} + +impl CloneWithHeap for Option { + #[inline] + fn clone_with_heap(&self, heap: &H) -> Self { + self.as_ref().map(|value| value.clone_with_heap(heap)) + } +} diff --git a/crates/monty/src/parse.rs b/crates/monty/src/parse.rs index 116d70b61..b74379b2d 100644 --- a/crates/monty/src/parse.rs +++ b/crates/monty/src/parse.rs @@ -283,10 +283,10 @@ impl<'a> Parser<'a> { "class definitions", self.convert_range(c.range), )), - Stmt::Return(ast::StmtReturn { value, .. }) => match value { - Some(value) => Ok(Node::Return(self.parse_expression(*value)?)), - None => Ok(Node::ReturnNone), - }, + Stmt::Return(ast::StmtReturn { value, .. }) => Ok(Node::Return(match value { + Some(value) => Some(self.parse_expression(*value)?), + None => None, + })), Stmt::Delete(d) => Err(ParseError::not_implemented( "the 'del' statement", self.convert_range(d.range), diff --git a/crates/monty/src/prepare.rs b/crates/monty/src/prepare.rs index 33fbbdb4c..695f7ea32 100644 --- a/crates/monty/src/prepare.rs +++ b/crates/monty/src/prepare.rs @@ -56,7 +56,7 @@ pub(crate) fn prepare(parse_result: ParseResult, input_names: Vec) -> Re { let new_expr_loc = expr_loc.clone(); prepared_nodes.pop(); - prepared_nodes.push(Node::Return(new_expr_loc)); + prepared_nodes.push(Node::Return(Some(new_expr_loc))); } Ok(PrepareResult { @@ -85,7 +85,7 @@ pub(crate) fn prepare_with_existing_names( { let new_expr_loc = expr_loc.clone(); prepared_nodes.pop(); - prepared_nodes.push(Node::Return(new_expr_loc)); + prepared_nodes.push(Node::Return(Some(new_expr_loc))); } Ok(PrepareResult { @@ -314,8 +314,10 @@ impl<'i> Prepare<'i> { match node { Node::Pass => (), Node::Expr(expr) => new_nodes.push(Node::Expr(self.prepare_expression(expr)?)), - Node::Return(expr) => new_nodes.push(Node::Return(self.prepare_expression(expr)?)), - Node::ReturnNone => new_nodes.push(Node::ReturnNone), + Node::Return(expr) => new_nodes.push(Node::Return(match expr { + Some(expr) => Some(self.prepare_expression(expr)?), + None => None, + })), Node::Raise(exc) => { let expr = match exc { Some(expr) => { @@ -1480,7 +1482,7 @@ impl<'i> Prepare<'i> { ); // Wrap the body expression as a return statement for scope analysis - let body_as_node: ParseNode = Node::Return(body.clone()); + let body_as_node: ParseNode = Node::Return(Some(body.clone())); let body_nodes = vec![body_as_node]; // Extract param names from the parsed signature for scope analysis @@ -2159,10 +2161,7 @@ fn collect_scope_info_from_node( } } // Statements with expressions that may contain walrus operators - Node::Expr(expr) | Node::Return(expr) => { - collect_assigned_names_from_expr(expr, assigned_names, interner); - } - Node::Raise(Some(expr)) => { + Node::Expr(expr) | Node::Return(Some(expr)) | Node::Raise(Some(expr)) => { collect_assigned_names_from_expr(expr, assigned_names, interner); } Node::Assert { test, msg } => { @@ -2172,7 +2171,7 @@ fn collect_scope_info_from_node( } } // These don't create new names - Node::Pass | Node::ReturnNone | Node::Raise(None) | Node::Break { .. } | Node::Continue { .. } => {} + Node::Pass | Node::Return(None) | Node::Raise(None) | Node::Break { .. } | Node::Continue { .. } => {} } } @@ -2457,9 +2456,10 @@ fn collect_cell_vars_from_node( } } // Handle expressions that may contain lambdas - Node::Expr(expr) | Node::Return(expr) => { + Node::Expr(expr) | Node::Return(Some(expr)) => { collect_cell_vars_from_expr(expr, our_locals, cell_vars, interner); } + Node::Return(None) => {} Node::Assign { object, .. } | Node::UnpackAssign { object, .. } => { collect_cell_vars_from_expr(object, our_locals, cell_vars, interner); } @@ -2724,10 +2724,10 @@ fn collect_cell_vars_from_args( /// This is used to find what names a nested function references from enclosing scopes. fn collect_referenced_names_from_node(node: &ParseNode, referenced: &mut AHashSet, interner: &InternerBuilder) { match node { - Node::Expr(expr) => collect_referenced_names_from_expr(expr, referenced, interner), - Node::Return(expr) => collect_referenced_names_from_expr(expr, referenced, interner), - Node::Raise(Some(expr)) => collect_referenced_names_from_expr(expr, referenced, interner), - Node::Raise(None) => {} + Node::Expr(expr) | Node::Return(Some(expr)) | Node::Raise(Some(expr)) => { + collect_referenced_names_from_expr(expr, referenced, interner); + } + Node::Return(None) | Node::Raise(None) => {} Node::Assert { test, msg } => { collect_referenced_names_from_expr(test, referenced, interner); if let Some(m) = msg { @@ -2832,12 +2832,7 @@ fn collect_referenced_names_from_node(node: &ParseNode, referenced: &mut AHashSe } // Imports create bindings but don't reference names Node::Import { .. } | Node::ImportFrom { .. } => {} - Node::Pass - | Node::ReturnNone - | Node::Global { .. } - | Node::Nonlocal { .. } - | Node::Break { .. } - | Node::Continue { .. } => {} + Node::Pass | Node::Global { .. } | Node::Nonlocal { .. } | Node::Break { .. } | Node::Continue { .. } => {} } } diff --git a/crates/monty/src/value.rs b/crates/monty/src/value.rs index c596ef297..09647a9da 100644 --- a/crates/monty/src/value.rs +++ b/crates/monty/src/value.rs @@ -21,6 +21,7 @@ use crate::{ exception_private::{ExcType, RunError, RunResult, SimpleException}, hash::HashValue, heap::{ContainsHeap, DropWithHeap, Heap, HeapData, HeapGuard, HeapId, HeapReadOutput}, + heap_traits::CloneWithHeap, intern::{BytesId, FunctionId, Interns, LongIntId, StaticStrings, StringId}, modules::ModuleFunctions, resource::{ @@ -2032,6 +2033,12 @@ impl Value { } } +impl CloneWithHeap for Value { + fn clone_with_heap(&self, heap: &H) -> Self { + self.clone_with_heap(heap.heap()) + } +} + /// Interned or heap-owned string identifier. /// /// Used when a string value can come from either the intern table (for known diff --git a/crates/monty/test_cases/try_except__all.py b/crates/monty/test_cases/try_except__all.py index befdc2fdc..cb303ad94 100644 --- a/crates/monty/test_cases/try_except__all.py +++ b/crates/monty/test_cases/try_except__all.py @@ -470,3 +470,183 @@ def finally_return_overrides_else_exc(): except (ValueError, BaseException): caught_by_tuple_with_base = True assert caught_by_tuple_with_base, 'tuple with BaseException should catch KeyboardInterrupt' + + +# === Exception state cleared on `return` from inside an except handler === +# When `return` exits an except clause, the exception is cleared from the +# active-exception state before any surrounding finally runs and before +# control returns to the caller. A bare `raise` inside that finally (or +# in subsequent code in the caller) must therefore see `RuntimeError( +# "No active exception to reraise")` rather than re-raising the exception +# the except clause had just been handling. + + +# Bare raise inside a try/except inside a finally that runs after +# return-from-except: should be caught as RuntimeError, not ValueError. +def _return_from_except_then_bare_raise_in_finally() -> None: + try: + try: + raise ValueError('original') + except ValueError: + return + finally: + try: + raise # bare reraise — exception should already be cleared + except ValueError: + assert False, '`return` from except must clear the exception before finally runs' + except RuntimeError as exc: + assert str(exc) == 'No active exception to reraise' + + +_return_from_except_then_bare_raise_in_finally() + + +# Return from a doubly-nested except handler should clear EVERY enclosing +# handler's exception state, not just the innermost. +def _return_from_doubly_nested_except() -> None: + try: + try: + try: + raise ValueError('inner') + except ValueError: + raise TypeError('middle') + except TypeError: + return + finally: + try: + raise + except (ValueError, TypeError): + assert False, "`return` from doubly-nested except must clear every handler's exception state" + except RuntimeError as exc: + assert str(exc) == 'No active exception to reraise' + + +_return_from_doubly_nested_except() + + +# After a function returns from inside an except clause, the caller's +# active-exception state should NOT contain that function's exception. +def _returns_from_except_no_finally() -> str: + try: + raise ValueError('original') + except ValueError: + return 'returned' + + +assert _returns_from_except_no_finally() == 'returned' +try: + raise # bare raise in caller — no exception should be active here +except ValueError: + assert False, "caller should not see inner function's exception as current" +except RuntimeError as exc: + assert str(exc) == 'No active exception to reraise' + + +# === Exception state cleared when an exception propagates past handlers === +# When an exception is raised from inside an except clause and is caught +# by a sibling/outer handler, the inner (abandoned) handler's exception +# must be cleared from the active-exception state — its trailer that +# would normally pop it is dead code (the handler body terminated via +# raise rather than falling through). Without this, a bare `raise` later +# resurrects the abandoned exception instead of producing +# `RuntimeError("No active exception to reraise")`. + +# Triple-nested: `raise X` → `raise Y` → `raise Z`, then bare raise outside. +# Each abandoned handler should be cleared. +try: + try: + try: + raise ValueError('first') + except ValueError: + raise TypeError('second') + except TypeError: + raise KeyError('third') +except KeyError as third: + assert str(third) == "'third'" + second = third.__context__ + assert second is not None and repr(second) == "TypeError('second')" + first = second.__context__ + assert repr(first) == "ValueError('first')" + +try: + raise +except RuntimeError as exc: + assert str(exc) == 'No active exception to reraise' + + +# Raising from a NESTED try body inside an except clause must NOT clear +# the surrounding handler's exception — the inner raise is caught locally +# and the outer handler is still active. After the inner try-except +# completes, a bare `raise` in the outer handler should re-raise the +# outer's original exception, not produce RuntimeError. +try: + raise ValueError('outer') +except ValueError as caught: + try: + raise KeyError('inner') + except KeyError: + pass # inner caught locally; outer's ValueError should remain active + + # Bare raise here should re-raise the outer's ValueError. + try: + raise + except ValueError as bare: + assert str(bare) == 'outer', 'bare raise should re-raise outer exception, not be cleared by inner raise' + + +# Function-call boundary: an exception raised and caught inside a callee +# must not leak active-exception state back to the caller. Probe via bare +# `raise` in the caller after the callee returns. +def _callee_raises_and_handles(): + try: + raise ValueError('callee internal') + except ValueError: + pass + + +_callee_raises_and_handles() +try: + raise +except RuntimeError as exc: + assert str(exc) == 'No active exception to reraise' + + +# === Implicit __context__ chaining === + + +# Chain via implicit raise (1/0) inside a handler — the ZeroDivisionError +# should chain to the outer exception just like an explicit raise does. +try: + try: + raise ValueError('outer') + except ValueError: + _ = 1 / 0 # implicit ZeroDivisionError +except ZeroDivisionError as e: + outer = e.__context__ + assert outer is not None and repr(outer) == "ValueError('outer')" + + +# Chain across a function-call boundary: callee raises while caller's +# except handler is active. The new exception's __context__ is still the +# caller's outer exception, even though the raise happened in the callee. +def _callee_raises(): + raise TypeError('from callee') + + +try: + try: + raise ValueError('caller-side') + except ValueError: + _callee_raises() +except TypeError as e: + context = e.__context__ + assert context is not None and repr(context) == "ValueError('caller-side')" + + +# Chain ONLY when an exception is being handled — a fresh raise inside a +# `try:` body (with no enclosing handler running) has __context__ = None. +# +try: + raise ValueError() +except ValueError as e: + assert e.__context__ is None