Skip to content

Commit 5a410ef

Browse files
committed
vm: keep the exception stack clean; fix bare raise; implicit __context__
The frame exception stack is shared across a call chain, and the eval loop pushed every raised exception onto it (so handlers can read it) but only popped on handler completion. Exceptions that propagated out of a frame uncaught — including a generator/genexpr's completion StopIteration consumed by the FOR_ITER that resumed it — were pushed and never popped, so they accumulated. A later bare `raise` re-raised that stale StopIteration instead of erroring, and implicit __context__ would pick it up. Balance the lifecycle: when an exception propagates out of a frame that has no handler, the eval loop now pops the entry it just pushed (the caller's eval loop re-pushes it). With the stack kept clean: - ReRaise returns RuntimeError("No active exception to reraise") on an empty stack instead of asserting (bare `raise` outside a handler). - The top-level driver uses the propagated result value rather than popping the exception off the (now-empty) stack. - from_iterable's manual StopIteration cleanup is removed — it is handled by the eval loop now, and keeping it would wrongly pop a pre-existing exception when iterating during exception handling. - RaiseVarargs re-enables implicit __context__ chaining (set the new exception's context to the one being handled); now reliable since the stack no longer holds stale state. exception_chaining.py now covers implicit __context__, bare `raise` -> RuntimeError, and iterating generators/comprehensions while handling an exception.
1 parent 766b369 commit 5a410ef

6 files changed

Lines changed: 78 additions & 50 deletions

File tree

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
# Regression: explicit exception chaining via `raise X from Y` exposes
2-
# __cause__/__context__/__suppress_context__.
3-
#
4-
# NB: implicit __context__ chaining (auto-set to the exception being handled) is
5-
# deliberately NOT done yet — the frame exception stack isn't reliably popped
6-
# (internally-consumed StopIterations linger; the same bug breaks bare `raise`
7-
# outside a handler), so the attribute is exposed and settable but defaults to
8-
# None rather than being populated from that unreliable state.
1+
# Regression: exception chaining — __cause__ (explicit, via `raise X from Y`),
2+
# implicit __context__ (the exception being handled when a new one is raised),
3+
# and __suppress_context__. Also covers the exception-stack hygiene that makes
4+
# these reliable: internally-consumed StopIterations no longer linger, so a bare
5+
# `raise` outside a handler is a RuntimeError and __context__ isn't spuriously
6+
# populated.
97

108

119
# `raise X from Y` sets __cause__ to the instance and suppresses context.
@@ -18,6 +16,21 @@
1816
assert isinstance(k.__cause__, ValueError), k.__cause__
1917
assert str(k.__cause__) == "inner", str(k.__cause__)
2018
assert k.__suppress_context__ is True, k.__suppress_context__
19+
# __context__ is still set implicitly (suppress only affects display).
20+
assert isinstance(k.__context__, ValueError), k.__context__
21+
22+
23+
# Implicit chaining without `from`: __context__ is the handled exception.
24+
try:
25+
try:
26+
raise ValueError("v1")
27+
except ValueError:
28+
raise KeyError("k1")
29+
except KeyError as k:
30+
assert k.__cause__ is None, k.__cause__
31+
assert isinstance(k.__context__, ValueError), k.__context__
32+
assert str(k.__context__) == "v1", str(k.__context__)
33+
assert k.__suppress_context__ is False, k.__suppress_context__
2134

2235

2336
# `raise X from None` -> cause None, still suppressed.
@@ -28,7 +41,7 @@
2841
assert k.__suppress_context__ is True, k.__suppress_context__
2942

3043

31-
# Plain exception: all three default cleanly.
44+
# Plain exception raised outside any handler: no cause, no context.
3245
try:
3346
raise ValueError("plain")
3447
except ValueError as e:
@@ -37,7 +50,32 @@
3750
assert e.__suppress_context__ is False, e.__suppress_context__
3851

3952

40-
# The chaining attributes are writable; setting __cause__ also suppresses context.
53+
# A bare `raise` with no active exception is a RuntimeError (not an abort, and
54+
# not a stale leftover exception).
55+
try:
56+
raise
57+
except RuntimeError as e:
58+
assert str(e) == "No active exception to reraise", str(e)
59+
60+
61+
# Iterating a generator / comprehensions while handling an exception must not
62+
# disturb the active exception (exception-stack hygiene).
63+
def gen():
64+
yield 1
65+
yield 2
66+
yield 3
67+
68+
69+
try:
70+
raise ValueError("active")
71+
except ValueError as e:
72+
assert set(gen()) == {1, 2, 3}
73+
assert [x for x in range(4)] == [0, 1, 2, 3]
74+
assert {k: k * k for k in range(3)} == {0: 0, 1: 1, 2: 4}
75+
assert isinstance(e, ValueError) and str(e) == "active"
76+
77+
78+
# Chaining attributes are writable; setting __cause__ also suppresses context.
4179
try:
4280
raise ValueError("x")
4381
except ValueError as e:
@@ -51,12 +89,4 @@
5189
assert e.__suppress_context__ is False
5290

5391

54-
# `from` works with a freshly-constructed cause too.
55-
try:
56-
raise ValueError("v") from TypeError("t")
57-
except ValueError as e:
58-
assert isinstance(e.__cause__, TypeError), e.__cause__
59-
assert str(e.__cause__) == "t", str(e.__cause__)
60-
61-
6292
print("EXCEPTION_CHAINING_OK")

src/executable/bytecode/Bytecode.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,14 @@ py::PyResult<py::Value> Bytecode::eval_loop(VirtualMachine &vm, Interpreter &int
180180
ASSERT(vm.state().cleanup.size() > 0);
181181
if (!vm.state().cleanup.top()) {
182182
ASSERT(vm.state().cleanup.size() == 1);
183+
// No handler in this frame: the exception propagates to the caller,
184+
// whose eval loop re-pushes it. Pop the entry we just pushed so it
185+
// does not linger on the frame-shared exception stack. Otherwise
186+
// internally-consumed exceptions (e.g. a generator's completion
187+
// StopIteration, swallowed by the FOR_ITER that resumed it) accumulate,
188+
// and a later bare `raise` or implicit __context__ lookup observes that
189+
// stale exception instead of seeing an empty stack.
190+
interpreter.execution_frame()->pop_exception();
183191
// when a function returns without handling the exception do not copy the value
184192
// to the callers the return register
185193
vm.pop_frame(false);

src/executable/bytecode/BytecodeProgram.cpp

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -137,19 +137,11 @@ int BytecodeProgram::execute(VirtualMachine *vm)
137137
auto result = m_main_function->function()->call(*vm, interpreter);
138138

139139
if (result.is_err()) {
140-
auto *exception = interpreter.execution_frame()->pop_exception();
141-
ASSERT(exception == result.unwrap_err());
140+
// The exception propagated all the way out; the eval loop already popped it
141+
// off the (now-clean) exception stack as it unwound, so use the result value
142+
// directly rather than popping again.
143+
auto *exception = result.unwrap_err();
142144
std::cout << exception->format_traceback() << std::endl;
143-
144-
// if (interpreter.execution_frame()->exception_info().has_value()) {
145-
// std::cout << "During handling of the above exception, another exception occurred:\n\n";
146-
// exception = interpreter.execution_frame()->pop_exception();
147-
// std::cout << exception->format_traceback() << std::endl;
148-
// if (interpreter.execution_frame()->exception_info().has_value()) {
149-
// // how many exceptions is one meant to expect? :(
150-
// TODO();
151-
// }
152-
// }
153145
}
154146

155147
return result.is_ok() ? EXIT_SUCCESS : EXIT_FAILURE;

src/executable/bytecode/instructions/RaiseVarargs.cpp

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,14 @@ PyResult<Value> RaiseVarargs::execute(VirtualMachine &vm, Interpreter &) const
3939
raised->set_cause(cause.unwrap());
4040
raised->set_suppress_context(true);
4141
}
42-
// NB: implicit __context__ chaining (set the new exception's context to
43-
// the one currently being handled) is intentionally NOT done here: the
44-
// frame exception stack is not reliably popped (internally-consumed
45-
// StopIterations linger — the same bug that makes a bare `raise` outside
46-
// a handler re-raise a stale exception), so reading it would attach a
47-
// spurious context. __context__ remains exposed and settable.
42+
// Implicit __context__ chaining: if an exception is currently being
43+
// handled, it becomes the context of the newly-raised one (unless it is
44+
// the same object). The exception stack is now kept clean, so this only
45+
// fires inside a genuine handler, not from stale internal state.
46+
if (auto info = vm.interpreter().execution_frame()->exception_info();
47+
info.has_value() && info->exception != raised) {
48+
raised->set_context(info->exception);
49+
}
4850
return Err(raised);
4951
} else {
5052
// reraise

src/executable/bytecode/instructions/ReRaise.cpp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22
#include "interpreter/Interpreter.hpp"
33
#include "runtime/PyFrame.hpp"
44
#include "runtime/PyNone.hpp"
5+
#include "runtime/RuntimeError.hpp"
56

67
using namespace py;
78

89

910
PyResult<Value> ReRaise::execute(VirtualMachine &, Interpreter &interpreter) const
1011
{
11-
ASSERT(interpreter.execution_frame()->exception_info().has_value());
12+
// A bare `raise` with no exception currently being handled is a RuntimeError,
13+
// not an abort. (Now that the exception stack is kept clean, the stack is
14+
// genuinely empty here rather than holding a stale internal exception.)
15+
if (!interpreter.execution_frame()->exception_info().has_value()) {
16+
return Err(runtime_error("No active exception to reraise"));
17+
}
1218
return Err(interpreter.execution_frame()->pop_exception());
1319
}
1420

src/runtime/utilities.hpp

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@ PyResult<std::monostate> from_iterable(PyObject *iterable, OutputIterator result
2121
static constexpr bool OutputIteratorCanError =
2222
detail::has_output_iterator_error<OutputIterator>;
2323

24-
auto &vm = VirtualMachine::the();
25-
auto exc = vm.interpreter().execution_frame()->exception_info();
26-
2724
auto iterator = iterable->iter();
2825
if (iterator.is_err()) return Err(iterator.unwrap_err());
2926

@@ -39,16 +36,9 @@ PyResult<std::monostate> from_iterable(PyObject *iterable, OutputIterator result
3936

4037
if (value.unwrap_err()->type() != stop_iteration()->type()) { return Err(value.unwrap_err()); }
4138

42-
auto current_exc = vm.interpreter().execution_frame()->exception_info();
43-
if (current_exc.has_value()) {
44-
auto *popped_exc = vm.interpreter().execution_frame()->pop_exception();
45-
if (exc.has_value()) {
46-
ASSERT(popped_exc == exc->exception);
47-
} else {
48-
ASSERT(!vm.interpreter().execution_frame()->exception_info().has_value());
49-
}
50-
}
51-
39+
// The iteration-terminating StopIteration does not need manual cleanup: a
40+
// Python iterator's StopIteration is popped by the eval loop as it unwinds the
41+
// iterator frame, and a built-in iterator never pushes one.
5242
return Ok(std::monostate{});
5343
}
5444

0 commit comments

Comments
 (0)