fix(code-runtime-python): catch the model exception with a pre-program local exception class

The _run outer try/except used the module-global BaseException, which the
program (running as __main__) can rebind: __main__.BaseException = RuntimeError
made the except resolve to RuntimeError, so a subsequent ValueError escaped _run
with no done frame and misreported the run as worker-exit. Bind BaseException
into a _run local before the program runs so the catch is immune; a regression
test rebinds BaseException and raises, asserting an exception, not a worker-exit.

Also correct the NUL-escape comment text: the JSON escape-result side is \^@ (6
bytes, the valid JSON NUL escape), not \x00, so the 6x-budget arithmetic in the
comments is self-consistent. Register the BaseException-rebind case in the
settlement note Testing (en + zh) and re-record the pairing.
This commit is contained in:
Chinesezjc
2026-08-31 14:34:09 +08:00
committed by Tianyi Cui
parent 202c428137
commit 0102cd95bf
5 changed files with 41 additions and 9 deletions
@@ -998,6 +998,15 @@ async def _run(channel: ProtocolChannel) -> None:
_os_write_local = _os_write
_memoryview_local = _memoryview
_fallback_frame_local = _FALLBACK_DONE_FRAME
# The exception class the outer try/except catches is bound into a LOCAL
# here, before the program runs. This bootstrap is `__main__`, so
# `__main__.BaseException = RuntimeError` would otherwise rebind the module
# global `BaseException` the `except BaseException` clause resolves at
# runtime, and a subsequent `ValueError` would then not match the clause —
# escaping `_run` with no `done` frame and misreporting the run as a
# `worker-exit`. Binding the class into a local makes the catch immune to a
# one-line rebind.
_BaseException = BaseException
def send_done(payload: dict[str, Any] | str) -> None:
try:
@@ -1061,7 +1070,7 @@ async def _run(channel: ProtocolChannel) -> None:
flush_out()
flush_err()
done = _done_with_value(value, max_value_bytes)
except BaseException as exc: # noqa: BLE001 -- report every failure to host
except _BaseException as exc: # noqa: BLE001 -- report every failure to host; `_BaseException` is a pre-program local, not a rebindable module global
done = {
"type": "done",
"error": {
@@ -848,7 +848,7 @@ describe('PythonCodeRuntime — programs and bindings', () => {
it('bounds a control-char-dense native residual by serialized cost, not raw length', async () => {
// A newline-free NUL flood passes the cheap `length + 3` lower bound at a
// raw length well under the budget, but each NUL serializes to `\x00` (6
// raw length well under the budget, but each NUL serializes to `\^@` (6
// bytes), so the true JSON cost is ~6x. The ledger must charge that
// serialized cost — and `jsonStringCostUpTo` must measure it WITHOUT
// allocating the escaped copy, so a near-budget line under a large
@@ -1513,6 +1513,29 @@ describe('PythonCodeRuntime — programs and bindings', () => {
expect(result.error?.kind).not.toBe('worker-exit')
}, 15_000)
it('still reports a model exception when the program rebinds BaseException', async () => {
// `_run`'s outer try/except catches the program's failure and builds a
// `done` frame. The clause previously used the module-global `BaseException`,
// which the program (running as `__main__`) can rebind: `__main__.BaseException
// = RuntimeError` makes the `except BaseException` resolve to `RuntimeError`,
// so a subsequent `ValueError` does not match and escapes `_run` with no
// `done` frame — misreporting the run as a `worker-exit`. The exception class
// is now bound into a `_run` LOCAL before the program runs, so the rebind
// cannot change which class the clause catches; the run must still report an
// `exception`, not a `worker-exit`.
const { runtime } = await setup({ maxWallMs: 10_000 })
const result = await runtime.run({
program: [
'import __main__',
'__main__.BaseException = RuntimeError',
'raise ValueError("real failure")',
].join('\n'),
bindings: [],
})
expect(result.error?.kind).toBe('exception')
expect(result.error?.kind).not.toBe('worker-exit')
}, 15_000)
it('bounds an over-cap exception-group nesting on the copy', async () => {
// Exception groups link through `exceptions`, not the cause/context
// dunders, so the cap has to count that edge too — otherwise a deeply
@@ -3269,7 +3292,7 @@ describe('PythonCodeRuntime — hostile peer', () => {
}, 20_000)
it('rejects a control-heavy oversized completion on its length, not its escaped copy', async () => {
// Every "\x00" escapes to the six bytes "\x00", so the escaped form of a
// Every "\x00" escapes to the six bytes "\^@", so the escaped form of a
// 40 MB string is ~240 MB. The walk must refuse on the cheap
// `len(current) + 2` lower bound; the 384 MiB address space holds the raw
// string but not its escaped expansion, so a pre-escape check dies on
@@ -3523,7 +3546,7 @@ describe('PythonCodeRuntime — hostile peer', () => {
it('caps a control-heavy exception diagnostic by its serialized cost, not raw bytes', async () => {
// The diagnostic crosses fd 3 inside a JSON frame where a control character
// escapes sixfold (a NUL is one raw byte, six as `\x00`). Capping by raw
// escapes sixfold (a NUL is one raw byte, six as `\^@`). Capping by raw
// UTF-8 length would let a NUL-heavy message near maxValueBytes serialize to
// ~6x that and breach the frame ceiling — the silent worker-exit inversion
// the load-time cap check exists to prevent. The child meters the diagnostic
@@ -4157,7 +4180,7 @@ describe('PythonCodeRuntime — hostile peer', () => {
it('drops a forged oversized log frame on its code-unit lower bound, before escaping it', async () => {
// A forged `log` frame carrying a control-heavy string sits below the
// 256 MiB fd-3 frame ceiling but escapes several-fold: 24 MiB of NULs
// becomes ~144 MiB of `\x00`. Charging it required building that escaped
// becomes ~144 MiB of `\^@`. Charging it required building that escaped
// copy first, so a 32-byte maxLogBytes could still force a
// hundreds-of-megabytes host allocation. The cheap `length + 3` lower bound
// truncates it instead. The host's own heap is what is under test, so keep