Forráskód Böngészése

fix(code-runtime-python): bind RuntimeError and _BindingRejection for dispatch's rejection path

dispatch's call_failure and its except clause resolved the module globals at
call time, so a program rebinding __main__._BindingRejection = ValueError let
the internal marker type leak into model code. Bind _RuntimeError_cls and
_BindingRejection_cls into _run locals before the program runs (names distinct
from the module globals so the assignment RHS resolves the global, not an
unbound local); dispatch now uses the locals. A regression test rebinds
_BindingRejection and asserts a host rejection still surfaces as RuntimeError.

The sys.__stdout__ flush test now reconfigures the streams back to block
buffering (write_through=False) so the settlement drain path is what the case
pins — verified fail-before: binding the stream objects instead of their flush
methods turns the test red.
Chinesezjc 1 hónapja
szülő
commit
937ada4837

+ 11 - 2
packages/code-runtime/code-runtime-python/py/bootstrap.py

@@ -744,6 +744,15 @@ async def _run(channel: ProtocolChannel) -> None:
     # with no done frame, misreporting the run as a `worker-exit`. A frame local
     # is not reachable by `__main__._X = ...`, so the catch is immune.
     _BaseException = BaseException
+    # `RuntimeError` and the `_BindingRejection` marker class are likewise bound
+    # into locals: `dispatch`'s `call_failure` and its `except` clause resolve
+    # them at call time, and the program (running as `__main__`) can rebind the
+    # module globals — `__main__._BindingRejection = ValueError` would leak the
+    # marker type into model code, violating the class's conversion contract.
+    # The names differ from the module globals (`_RuntimeError_cls`) so the
+    # assignment RHS resolves the module global, not an unbound local.
+    _RuntimeError_cls = RuntimeError
+    _BindingRejection_cls = _BindingRejection
     # 1. Boot handshake.
     boot = channel.read_frame()
     if boot is None or boot.get("type") != "boot":
@@ -861,7 +870,7 @@ async def _run(channel: ProtocolChannel) -> None:
             # the pre-errorClass behavior for namespaces that declared none.
             if error_class is not None:
                 return error_class(name, message)
-            return RuntimeError(message)
+            return _RuntimeError_cls(message)
 
         # Validate the argument shape before claiming an id, so a rejected call
         # leaves no gap in the sequence the host checks. json.dumps would coerce
@@ -907,7 +916,7 @@ async def _run(channel: ProtocolChannel) -> None:
             next_id += 1
         try:
             return await fut
-        except _BindingRejection as exc:
+        except _BindingRejection_cls as exc:
             raise call_failure(str(exc)) from None
 
     namespaces: dict[str, Any] = {}

+ 35 - 0
packages/code-runtime/code-runtime-python/tests/runtime.spec.ts

@@ -674,6 +674,35 @@ describe('PythonCodeRuntime — programs and bindings', () => {
     expect(calls).toEqual([{ n: 1 }])
   })
 
+  it('keeps the rejection contract when _BindingRejection is rebound', async () => {
+    // `dispatch`'s except clause resolves `_BindingRejection` at call time; a
+    // program that rebinds `__main__._BindingRejection = ValueError` would
+    // otherwise let the internal marker type leak into model code (the program
+    // would catch a `ValueError` for a host rejection). The class is now bound
+    // into `_run` locals before the program runs, so a host rejection still
+    // surfaces as the declared `RuntimeError`.
+    const { runtime } = await setup()
+    const result = await runtime.run({
+      program: [
+        'import __main__',
+        '__main__._BindingRejection = ValueError',
+        'caught = ""',
+        'try:',
+        '    await tools.fail({})',
+        'except RuntimeError as e:',
+        '    caught = str(e)',
+        'except Exception as e:',
+        '    caught = "WRONG TYPE: " + type(e).__name__',
+        'return caught',
+      ].join('\n'),
+      bindings: tools({
+        fail: async () => { throw new Error('nope') },
+      }),
+    })
+    expect(result.error).toBeUndefined()
+    expect(result.value).toBe('nope')
+  }, 15_000)
+
   it('still answers the call when the rejection value cannot be converted to a string', async () => {
     // `messageOf` calls `String(error)`, which runs the value's own conversion,
     // and this call site is a DETACHED async reply callback. A rejection whose
@@ -4317,6 +4346,12 @@ describe('PythonCodeRuntime — hostile peer', () => {
     const result = await runtime.run({
       program: [
         'import sys',
+        // `-u` makes the streams write-through; re-enable block buffering so
+        // the bytes sit in the wrapper until the SETTLEMENT drain flushes them
+        // — the drain path, not the -u immediate write, is what this case pins.
+        'if hasattr(sys.__stdout__, "reconfigure"):',
+        '    sys.__stdout__.reconfigure(write_through=False)',
+        '    sys.__stderr__.reconfigure(write_through=False)',
         'sys.__stdout__.write("orig stdout\\n")',
         'sys.__stderr__.write("orig stderr\\n")',
         'return "done"',