Explorar o código

fix(code-runtime-python): meter the exception diagnostic by serialized cost

Raising maxValueBytes' load bound to ceiling-envelope assumed both budgets are
metered in serialized (JSON-escaped) bytes, which held for completion values and
logs but not the diagnostic: _cap_message capped by raw UTF-8, so a control-heavy
message near maxValueBytes could serialize sixfold and breach the fd-3 frame
ceiling — the silent worker-exit inversion the load check prevents. _cap_message
now accumulates per-byte serialized cost (new _JSON_BYTE_COST table) and cuts the
prefix that fits. Also reword the host SIGXCPU timeout message to name cpuSeconds
as the configured ceiling rather than a budget a stricter inherited RLIMIT_CPU
soft may undercut. Adds a control-heavy-diagnostic regression test.
Chinesezjc hai 1 mes
pai
achega
63c49c8a90

+ 48 - 19
packages/code-runtime/code-runtime-python/py/bootstrap.py

@@ -1143,6 +1143,15 @@ _JSON_ESCAPE_SURCHARGES = [
     for byte in [*range(0x20), ord('"'), ord("\\")]
 ]
 
+# Per-byte JSON-string serialized cost (the byte itself plus its escape
+# surcharge), indexed by byte value. Lets :func:`_cap_message` accumulate the
+# serialized cost of a growing prefix in one O(1) step per byte without building
+# the escaped form. A non-ASCII byte stays raw (cost 1); a C0 control or ``"``/
+# ``\\`` carries its surcharge from :data:`_JSON_ESCAPE_SURCHARGES`.
+_JSON_BYTE_COST = [1] * 256
+for _escaped_byte, _surcharge in _JSON_ESCAPE_SURCHARGES:
+    _JSON_BYTE_COST[_escaped_byte[0]] = 1 + _surcharge
+
 
 def _json_string_cost(raw: bytes) -> int:
     """UTF-8 byte length of one string's JSON form, WITHOUT building that form.
@@ -1558,31 +1567,51 @@ _TRUNCATION_MARKER_BYTES = len(_TRUNCATION_MARKER.encode("utf-8"))
 
 
 def _cap_message(message: str, max_bytes: int) -> str:
-    """Byte-cap a diagnostic, appending the same marker the host uses.
+    """Cap a diagnostic by its SERIALIZED cost, appending the host's marker.
+
+    Metered by the JSON-string cost the ``done`` frame will actually carry, not
+    by raw UTF-8 length: the message crosses fd 3 inside a JSON frame where
+    control characters escape up to sixfold (a NUL is one raw byte but six as
+    ``\\u0000``), so a raw-length cap of ``maxValueBytes`` could serialize to
+    roughly six times that and breach the 256 MiB frame ceiling — the silent
+    ``worker-exit`` inversion the load-time cap check exists to prevent, and a
+    several-hundred-MiB escape allocation besides. The seam's load bound admits
+    ``maxValueBytes`` up to ``ceiling - envelope`` on the premise that both the
+    completion value and the diagnostic are metered in serialized bytes, so this
+    honors that premise for the diagnostic.
 
     Encoded with ``errors="replace"`` first: a model exception message can
-    contain an unpaired surrogate (``raise Exception("\\ud800")``), and a
-    strict encode would throw while BUILDING the failure frame — the run
-    would then strand until the wall clock instead of reporting the
-    exception. Then a UTF-8 slice with a trailing partial sequence dropped
-    by ``errors="ignore"``; the marker text matches the host-side
-    ``capMessage`` so a truncated diagnostic reads identically wherever the
-    cap was applied.
-
-    The marker's bytes come OUT of ``max_bytes``, so the returned string as a
-    whole honors the cap; retaining a full cap of text and then appending the
-    marker would exceed the bound this function enforces, and the host meters
-    the same field again on arrival. A ``max_bytes`` below the marker's own
-    size leaves no room for message text and yields the marker alone, so the
-    true bound is ``max(max_bytes, 15)`` — reporting that truncation happened
-    is worth those 15 bytes.
+    contain an unpaired surrogate (``raise Exception("\\ud800")``), and a strict
+    encode would throw while BUILDING the failure frame — the run would then
+    strand until the wall clock instead of reporting the exception. The marker's
+    serialized cost comes OUT of ``max_bytes``, so the returned string's own
+    frame form honors the cap; the host meters the same field again on arrival.
+    A ``max_bytes`` below the marker's cost yields the marker alone.
     """
 
     raw = message.encode("utf-8", errors="replace")
-    if len(raw) <= max_bytes:
+    if _json_string_cost(raw) <= max_bytes:
         return raw.decode("utf-8")
-    budget = max(0, max_bytes - _TRUNCATION_MARKER_BYTES)
-    return raw[:budget].decode("utf-8", errors="ignore") + _TRUNCATION_MARKER
+    # Truncating: the result is `prefix + marker`, whose serialized cost is
+    # `2 (quotes) + sum(prefix byte costs) + marker cost`. The marker is
+    # escape-free, so its cost is its UTF-8 length. Reserve that and the quotes,
+    # then take the longest raw prefix whose accumulated per-byte cost fits.
+    # `_JSON_BYTE_COST` is per-byte and additive, so the scan is exact and walks
+    # at most a budget's worth of bytes, allocating nothing (unlike building the
+    # escaped form). `max(0, ...)` handles a `max_bytes` below the marker's own
+    # cost, yielding the marker alone.
+    content_budget = max(0, max_bytes - 2 - len(_TRUNCATION_MARKER.encode("utf-8")))
+    cost = 0
+    end = 0
+    for end in range(len(raw)):
+        cost += _JSON_BYTE_COST[raw[end]]
+        if cost > content_budget:
+            break
+    else:
+        end = len(raw)
+    # Drop a trailing partial UTF-8 sequence the slice may have cut (continuation
+    # bytes are 0b10xxxxxx); `errors="ignore"` renders the clean prefix.
+    return raw[:end].decode("utf-8", errors="ignore") + _TRUNCATION_MARKER
 
 
 # Fixed safety/liveness bound, not a tunable: a model can raise an exception

+ 8 - 1
packages/code-runtime/code-runtime-python/src/index.ts

@@ -1162,8 +1162,15 @@ export class PythonCodeRuntime extends CodeRuntime {
         // OOM killer, an operator, or itself consumed none), so every other
         // signal or code — including an unsolicited SIGKILL, even the
         // hard-limit one — reports as an opaque worker exit.
+        //
+        // The message names `cpuSeconds` as the CONFIGURED ceiling, not "the
+        // budget that fired": the child clamps RLIMIT_CPU to the stricter of
+        // `cpuSeconds` and any inherited soft limit, so under a tighter inherited
+        // cap SIGXCPU arrives before `cpuSeconds` — the host cannot see the
+        // effective value, so it states the ceiling it set rather than a second
+        // count it cannot guarantee.
         finish(signal === 'SIGXCPU'
-          ? { error: { kind: 'timeout', message: `CPU budget (${this.config.cpuSeconds}s) exhausted` } }
+          ? { error: { kind: 'timeout', message: `CPU time exhausted (limit at most the configured ${this.config.cpuSeconds}s; a stricter inherited RLIMIT_CPU can fire sooner)` } }
           : { error: { kind: 'worker-exit', message: `python exited (code=${String(code)}, signal=${String(signal)}) before completing` } })
         settle(decided)
       })

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

@@ -2900,6 +2900,29 @@ describe('PythonCodeRuntime — hostile peer', () => {
     expect(Buffer.byteLength(result.error?.message ?? '', 'utf8')).toBeLessThan(2048)
   })
 
+  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 ``). 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
+    // by its serialized cost, so a NUL flood is truncated to fit the frame and
+    // the run still reports the exception rather than a worker-exit.
+    const { runtime } = await setup({ maxValueBytes: 4096 })
+    const result = await runtime.run({
+      // 512 KiB of NUL: ~3 MiB once escaped, far past the 4 KiB cap.
+      program: 'raise ValueError("\\x00" * (512 * 1024))',
+      bindings: [],
+    })
+    expect(result.error?.kind).toBe('exception')
+    expect(result.error?.message.endsWith('… [truncated]')).toBe(true)
+    // The SERIALIZED form (what the frame carried) fits the budget, so its raw
+    // length is well under it too — a raw-byte cap would have admitted ~4 KiB of
+    // NULs that serialize to ~24 KiB.
+    const serialized = JSON.stringify(result.error?.message ?? '')
+    expect(Buffer.byteLength(serialized, 'utf8')).toBeLessThanOrEqual(4096 + 8)
+  })
+
   it('bounds a newline-free partial-line flood while the program is still running', async () => {
     // print("x", end="") never completes a line, so nothing reaches the
     // Python LogBuffer until settlement — the buffered tail must still hit