diff --git a/packages/code-runtime/code-runtime-python/py/bootstrap.py b/packages/code-runtime/code-runtime-python/py/bootstrap.py index 464ecc421e..a4c88d2d4a 100644 --- a/packages/code-runtime/code-runtime-python/py/bootstrap.py +++ b/packages/code-runtime/code-runtime-python/py/bootstrap.py @@ -986,6 +986,15 @@ async def _run(channel: ProtocolChannel) -> None: # bound above. encode_plain_bound = _encode_json_plain write_encoded_bound = channel.write_encoded + # The fallback primitives are bound into LOCALS here, before the program + # runs, so `send_done`'s except arm does not read module globals at call + # time. This bootstrap is `__main__`, so `__main__._os_write = boom` (or + # `__main__._FALLBACK_DONE_FRAME`, `__main__._memoryview`) would otherwise + # rebind exactly the names the fallback reads, reopening the single-line- + # rebind hole the fallback exists to close. + _os_write_local = _os_write + _memoryview_local = _memoryview + _fallback_frame_local = _FALLBACK_DONE_FRAME def send_done(payload: dict[str, Any] | str) -> None: try: @@ -993,7 +1002,7 @@ async def _run(channel: ProtocolChannel) -> None: write_encoded_bound(payload) else: write_encoded_bound(encode_plain_bound(payload)) - except BaseException: # noqa: BLE001 -- a rebind must not cost the done frame + except: # noqa: BLE001, E722 -- a rebind must not cost the done frame; bare except avoids naming BaseException # `encode_plain_bound`/`write_encoded_bound` are bound callables, but # their BODIES still resolve transitive module globals at call time — # `_encode_json_plain` reaches `_dump_scalar`/`_dump_string`/`json.dumps`, @@ -1002,16 +1011,16 @@ async def _run(channel: ProtocolChannel) -> None: # `__main__.os = ...`) makes the error-frame encode/write throw AFTER # the `except` block, which would drop the `done` frame and downgrade a # settled `exception` verdict to a host-side `worker-exit`. Write a fixed - # literal done frame with the import-time captured `_os_write` and - # `_memoryview` (module-level names captured before model code runs, so - # a one-line rebind cannot change them) so the host still gets a - # verdict. The literal is JSON-valid and newline-terminated; the lock + # literal done frame with the LOCALLY-BOUND `_os_write_local` and + # `_memoryview_local` (captured before the program runs, so a one-line + # rebind of the module global cannot change them) so the host still gets + # a verdict. The literal is JSON-valid and newline-terminated; the lock # is the channel's, so the write is serialized against any concurrent # writer. with channel._write_lock: - view = _memoryview(_FALLBACK_DONE_FRAME) + view = _memoryview_local(_fallback_frame_local) while view: - view = view[_os_write(channel._fd, view):] + view = view[_os_write_local(channel._fd, view):] max_value_bytes = int(boot["maxValueBytes"]) done: dict[str, Any] | str diff --git a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts index 317364512a..d01f891424 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -1488,7 +1488,8 @@ describe('PythonCodeRuntime — programs and bindings', () => { // `__main__`, so rebinding `__main__._dump_scalar` to a raising function makes // the error-frame encode throw AFTER the `except` block. `send_done` catches // that and writes a fixed literal done frame (kind `exception`) with the - // captured `os.write`, so the host still gets a verdict — the run must be an + // LOCALLY-BOUND `_os_write`/`_memoryview`/`_FALLBACK_DONE_FRAME` captured + // before the program runs, so the host still gets a verdict — the run must be an // `exception`, never a `worker-exit`. The real message is lost (the literal // carries a fixed `` text), which is acceptable: the verdict // outranks the diagnostic detail. @@ -1500,6 +1501,10 @@ describe('PythonCodeRuntime — programs and bindings', () => { ' raise RuntimeError("hijacked")', '__main__._dump_scalar = boom', '__main__.os = boom', + // The fallback must also survive a rebind of its own primitives. + '__main__._os_write = boom', + '__main__._memoryview = boom', + '__main__._FALLBACK_DONE_FRAME = boom', 'raise ValueError("real failure")', ].join('\n'), bindings: [],