Skip to content

Commit bfd0f08

Browse files
committed
fix: match correct try block markers in CFG
Fix an ``AssertionError`` in stack-size computation when a single exception region is split into multiple ``TryBegin`` instances sharing one handler (as produced by bytecode-rewriting tools that wrap a whole function body in a single handler). ``TryBegin``/``TryEnd`` are now matched on the handler block rather than on ``TryBegin`` identity.
1 parent e8cfa41 commit bfd0f08

3 files changed

Lines changed: 96 additions & 2 deletions

File tree

doc/changelog.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
ChangeLog
22
=========
33

4+
Unreleased
5+
----------
6+
7+
Bugfixes:
8+
9+
- Fix an ``AssertionError`` in stack-size computation when a single exception
10+
region is split into multiple ``TryBegin`` instances sharing one handler (as
11+
produced by bytecode-rewriting tools that wrap a whole function body in a
12+
single handler). ``TryBegin``/``TryEnd`` are now matched on the handler block
13+
rather than on ``TryBegin`` identity.
14+
415
03-06-2026: Version 0.18.0
516
--------------------------
617

src/bytecode/cfg.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,17 @@ def run(self) -> Generator[Union[_StackSizeComputer, int], int, None]:
343343
# current try begin. However inside the CFG some blocks may
344344
# start with a TryEnd relevant only when reaching this block
345345
# through a particular jump. So we are lenient here.
346-
if instr.entry is not self._current_try_begin:
346+
#
347+
# We match on the exception handler (the TryBegin target block)
348+
# rather than on the TryBegin instance: a single exception region
349+
# can be split into several TryBegin copies that share the same
350+
# handler (see ``from_bytecode``), and the copy carried over as
351+
# ``pending_try_begin`` through a jump is not necessarily the same
352+
# instance as the one referenced by this block's leading TryEnd.
353+
if (
354+
self._current_try_begin is None
355+
or instr.entry.target is not self._current_try_begin.target
356+
):
347357
continue
348358

349359
# Compute the stack usage of the exception handler
@@ -403,7 +413,7 @@ def run(self) -> Generator[Union[_StackSizeComputer, int], int, None]:
403413
if (
404414
(te := self.block.get_trailing_try_end(i))
405415
and self._current_try_begin is not None
406-
and te.entry is self._current_try_begin
416+
and te.entry.target is self._current_try_begin.target
407417
):
408418
assert isinstance(te.entry.target, BasicBlock)
409419
yield from self._compute_exception_handler_stack_usage(

tests/test_cfg.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
Label,
1919
SetLineno,
2020
TryBegin,
21+
TryEnd,
2122
dump_bytecode,
2223
)
2324
from bytecode.utils import PY312, PY313, PY314
@@ -903,6 +904,78 @@ def test(*args, **kwargs):
903904
self.assertEqual(test([], name=None), -1)
904905
self.assertEqual(stdout.getvalue(), "second finally\nfirst finally\n")
905906

907+
def test_stack_size_computation_shared_handler_split_regions(self):
908+
# Regression test for an assertion failure in the stack-size
909+
# computation. A bytecode-rewriting transform (e.g. an instrumentation
910+
# tool) may wrap a whole function body in a single exception handler
911+
# whose coverage is split around the body's own try blocks, so that the
912+
# regions never nest but all share the *same* handler block. A single
913+
# logical region then maps to several TryBegin instances.
914+
#
915+
# When such a region is exited through a forward jump (here the branch
916+
# inside the first handler body), the jump target block is reached both
917+
# by fall-through and by the jump, and ends up carrying one TryBegin
918+
# copy as ``pending_try_begin`` while its own leading TryEnd references
919+
# a *different* copy of the same region. Matching TryEnd against the
920+
# current TryBegin by identity then failed to close the region, leaving
921+
# it spuriously open and tripping ``assert self._current_try_begin is
922+
# None`` at the following TryBegin. Matching must be done on the handler
923+
# (the TryBegin target block) instead.
924+
if sys.version_info < (3, 11):
925+
self.skipTest("exception tables (TryBegin/TryEnd) require 3.11+")
926+
927+
def trigger(d, x): # pragma: no cover
928+
try:
929+
d["a"]
930+
except KeyError:
931+
if x: # a branch *inside* the handler body
932+
pass
933+
else:
934+
d["b"] = 1
935+
try: # ... followed by another try block
936+
del d["c"]
937+
except KeyError:
938+
pass
939+
return 99
940+
941+
bc = Bytecode.from_code(trigger.__code__)
942+
943+
# Wrap the whole body in one shared handler, splitting the covering
944+
# region around the body's existing try blocks.
945+
except_label = Label()
946+
first_tb = last_tb = TryBegin(except_label, push_lasti=True)
947+
i = 0
948+
while i < len(bc):
949+
instr = bc[i]
950+
if isinstance(instr, TryBegin) and last_tb is not None:
951+
bc.insert(i, TryEnd(last_tb))
952+
last_tb = None
953+
i += 1
954+
elif isinstance(instr, TryEnd):
955+
j = i + 1
956+
while j < len(bc) and not isinstance(bc[j], TryBegin):
957+
if isinstance(bc[j], Instr):
958+
last_tb = TryBegin(except_label, push_lasti=True)
959+
bc.insert(i + 1, last_tb)
960+
break
961+
j += 1
962+
i += 1
963+
i += 1
964+
bc.insert(0, first_tb)
965+
bc.append(TryEnd(last_tb))
966+
bc.append(except_label)
967+
bc.append(Instr("PUSH_EXC_INFO"))
968+
bc.append(Instr("RERAISE", 2))
969+
970+
cfg = ControlFlowGraph.from_bytecode(bc)
971+
# Used to raise AssertionError on 3.12.
972+
cfg.compute_stacksize()
973+
974+
# The rewritten code object is valid and still runs correctly.
975+
trigger.__code__ = cfg.to_code()
976+
self.assertEqual(trigger({}, True), 99)
977+
self.assertEqual(trigger({"c": 1}, False), 99)
978+
906979
def test_stack_size_with_dead_code(self):
907980
# Simply demonstrate more directly the previously mentioned issue.
908981
def test(*args): # pragma: no cover

0 commit comments

Comments
 (0)