Quellcode durchsuchen

fix(code-runtime-python): reject an oversized unframed frame before the join; cap the rejection diagnostic

The review's remaining critical: the fd-3 data handler checked the unframed
counter against the 256 MiB wire ceiling, so a single 64-256 MiB frame was
fully Buffer.concat-joined (a second copy) and only then dropped in the line
loop — the peak-memory doubling the pre-join check exists to prevent, for a
frame the parser is guaranteed to discard. The counter is now checked against
FRAME_PARSE_CAP_BYTES before the join; the regression case asserts a worker-exit
with 'protocol frame exceeded' (fail-before: reverting to the ceiling turns it
green, proving the join path). FRAME_CEILING_BYTES is removed.

The rejection-cap fix now has its regression: a completion value whose class
name is 70 MiB of Ns asserts invalid-output, not worker-exit (fail-before:
uncapping the diagnostic turns it red).

The settlement note (en + zh) updates the remaining stale bound text, and the
fd-3 protocol note (en + zh) no longer claims protocol-only exports or a
missing Python codec. Pairings re-recorded.
Chinesezjc vor 4 Wochen
Ursprung
Commit
c8139589d2

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md
-2026-07-31-code-runtime-python-fd3-protocol.md: 6e5d96c5cff0ccdb6ecb1779bc5aa003f0b0881f
-2026-07-31-code-runtime-python-fd3-protocol.zh.md: 928be28f15212cb39dff1b215cdfee58da0ac130
+2026-07-31-code-runtime-python-fd3-protocol.md: da9f1fc5010c3975b0a8e20b1dc2dff66f837dcb
+2026-07-31-code-runtime-python-fd3-protocol.zh.md: 3b2954ceb276c286b5cafb40c0d9e2c2c7e64403

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md

@@ -20,7 +20,7 @@ The package ships the protocol AND the runtime implementation: `PythonCodeRuntim
 
 `py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text.
 
-The package remains independently buildable with protocol-only exports. `check-workspace-constraints` reads every `packages/<group>/<pkg>/package.json` unconditionally, while the coverage and invariant-topology checks exercise the package as soon as its directory exists.
+The package ships the runtime alongside the protocol; it remains independently buildable. `check-workspace-constraints` reads every `packages/<group>/<pkg>/package.json` unconditionally, while the coverage and invariant-topology checks exercise the package as soon as its directory exists.
 
 ## Wire contract
 
@@ -32,12 +32,12 @@ Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free f
 
 ## Alternatives considered
 
-**Require a future Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) to live in `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates hostile input and is self-contained. A child-side codec would produce trusted output and belong with bootstrap-owned emission and cost accounting; forcing only its entry points into `protocol.py` would couple the vocabulary mirror to runtime internals or create an import cycle. `protocol.py` remains a pure wire-vocabulary mirror. No Python codec ships in this package.
+**Require a future Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) to live in `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates hostile input and is self-contained. A child-side codec would produce trusted output and belong with bootstrap-owned emission and cost accounting; forcing only its entry points into `protocol.py` would couple the vocabulary mirror to runtime internals or create an import cycle. `protocol.py` remains a pure wire-vocabulary mirror; the codec (`_encode_json_plain` / `_decode_json_plain`) lives in `bootstrap.py` with the runtime it serves.
 
 **Keep the protocol files outside a buildable package until a runtime ships.** Rejected: the workspace-constraint, coverage, and invariant-topology checks require every directory under `packages/<group>/<pkg>` to be a buildable package, and the protocol has independent tests and a public wire vocabulary.
 
 ## Consequences
 
-Bought: the fd-3 protocol and its hostile-input codec form a self-contained, fully unit-covered layer, with an executing guard against TypeScript/Python field-set drift. A future runtime can consume a reviewed wire contract.
+Bought: the fd-3 protocol and its hostile-input codec form a self-contained, fully unit-covered layer, with an executing guard against TypeScript/Python field-set drift. The runtime built on it (`bootstrap.py`) consumes the reviewed wire contract.
 
-Cost: the package name denotes a Python runtime family while `src/index.ts` exports only the protocol vocabulary. The mirror e2e compares field names and required/optional status across the two sides but not field types; comparing type declarations across TypeScript and Python has no mechanical equivalent, so review and the future runtime's real-subprocess suite retain that responsibility.
+Cost: the package name denotes a Python runtime family and `src/index.ts` exports the full `PythonCodeRuntime` implementation, so the protocol vocabulary is only one part of the package surface. The mirror e2e compares field names and required/optional status across the two sides but not field types; comparing type declarations across TypeScript and Python has no mechanical equivalent, so review and the future runtime's real-subprocess suite retain that responsibility.

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md

@@ -20,7 +20,7 @@ Status: implemented
 
 `py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。
 
-该包只导出协议,同时保持独立可构建。`check-workspace-constraints` 会无条件读取每个 `packages/<group>/<pkg>/package.json`,coverage 与 invariant-topology 检查则会在包目录存在时立即覆盖该包。
+该包随协议一起交付 runtime,同时保持独立可构建。`check-workspace-constraints` 会无条件读取每个 `packages/<group>/<pkg>/package.json`,coverage 与 invariant-topology 检查则会在包目录存在时立即覆盖该包。
 
 ## Wire contract
 
@@ -40,4 +40,4 @@ Status: implemented
 
 收获:fd-3 协议及其敌意输入 codec 构成自包含、unit 全覆盖的一层,并由执行中的 guard 防止 TypeScript/Python 字段集漂移。未来 runtime 可以直接消费经过评审的 wire contract。
 
-代价:包名表示 Python runtime 家族,而 `src/index.ts` 导出协议 vocabulary。mirror e2e 会比较两侧字段名与必填/可选状态,但不比较字段类型;跨 TypeScript 与 Python 比较类型声明没有机械等价物,因此评审与未来 runtime 的真实子进程套件继续负责这项检查。
+代价:包名表示 Python runtime 家族,而 `src/index.ts` 导出完整的 `PythonCodeRuntime` 实现,协议 vocabulary 只是包表面的一部分。mirror e2e 会比较两侧字段名与必填/可选状态,但不比较字段类型;跨 TypeScript 与 Python 比较类型声明没有机械等价物,因此评审与未来 runtime 的真实子进程套件继续负责这项检查。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md
-2026-07-31-code-runtime-python-settlement-fixes.md: 869c2736dbd3207b92e7ac7362176d4001629389
-2026-07-31-code-runtime-python-settlement-fixes.zh.md: 30bcdb95b46c7c551027e654a807042c9364b55d
+2026-07-31-code-runtime-python-settlement-fixes.md: f1fa9d39556fd60e0c35b7911bb798ca701133a5
+2026-07-31-code-runtime-python-settlement-fixes.zh.md: 446ec8069301fe51c7aedb8709626a7e9a7c3b8a

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md

@@ -26,7 +26,7 @@ Also in `src/index.ts`, after the newline loop over a `Buffer.concat` of the pen
 
 ### Output-cap load bound is parse-cap minus envelope, not divided by six
 
-The load-time check that rejects a `maxLogBytes`/`maxValueBytes` larger than one fd-3 frame can carry divided the frame ceiling by six for worst-case escape expansion. But both budgets are metered in ALREADY-ESCAPED serialized bytes — the host log ledger charges the serialized cost via `jsonStringCostUpTo` (which walks to the cap without allocating the escaped copy), `checkDoneValue` measures the escaped form, and the producing-side `_cap_message` also caps by serialized cost — so a payload admitted under the cap occupies at most `cap + envelope` on the wire; escaping is inside the charge and must not be multiplied in again. The bound is now `FRAME_CEILING_BYTES - FRAME_ENVELOPE_BYTES`, and the unused `MAX_JSON_ESCAPE_EXPANSION` constant is gone. The old bound was not unsafe — it under-admitted — but it silently forbade legitimate large caps. The same load check also rejects a NON-INTEGER `maxLogBytes`/`maxValueBytes`: the child reads each budget through `int(...)`, which floors a float, so `maxLogBytes: 3.5` would truncate at 3 bytes child-side while the host meters the fraction — the two sides enforcing different public config. Rejecting the float at load keeps them in step, matching the worker backend.
+The load-time check that rejects a `maxLogBytes`/`maxValueBytes` larger than one fd-3 frame can carry divided the frame ceiling by six for worst-case escape expansion. But both budgets are metered in ALREADY-ESCAPED serialized bytes — the host log ledger charges the serialized cost via `jsonStringCostUpTo` (which walks to the cap without allocating the escaped copy), `checkDoneValue` measures the escaped form, and the producing-side `_cap_message` also caps by serialized cost — so a payload admitted under the cap occupies at most `cap + envelope` on the wire; escaping is inside the charge and must not be multiplied in again. The bound is now `FRAME_PARSE_CAP_BYTES - FRAME_ENVELOPE_BYTES` (the receive path drops raw frames past the 64 MiB parse cap before decoding, so a budget must not exceed what an honest child's frame can carry through that parser), and the unused `MAX_JSON_ESCAPE_EXPANSION` constant is gone. The old bound was not unsafe — it under-admitted — but it silently forbade legitimate large caps. The same load check also rejects a NON-INTEGER `maxLogBytes`/`maxValueBytes`: the child reads each budget through `int(...)`, which floors a float, so `maxLogBytes: 3.5` would truncate at 3 bytes child-side while the host meters the fraction — the two sides enforcing different public config. Rejecting the float at load keeps them in step, matching the worker backend.
 
 ### Same-group survivors are reaped before the fiber goes quiescent
 

Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md


+ 23 - 21
packages/code-runtime/code-runtime-python/src/index.ts

@@ -203,25 +203,25 @@ function materializePyScripts(): string {
  * check at the `done` handler, deliberately decoupled from this. Not a config
  * knob because it is an internal framing invariant, not a deployment choice.
  */
-const FRAME_CEILING_BYTES = 256 * 1024 * 1024
-
 /**
- * A frame's RAW length is capped before JSON.parse: the 256 MiB wire ceiling
- * bounds the bytes on fd 3, not the decoded structure, and a compact wide
+ * A frame's RAW length is capped before JSON.parse: the 256 MiB fd-3 wire
+ * ceiling bounds the bytes, not the decoded structure, and a compact wide
  * frame near that ceiling (e.g. a huge array of tiny elements) could decode to
  * far more host memory than the wire admitted — an OOM inside the receive
  * path. 64 MiB raw admits every legal config (the widest in-tree completion
  * and binding frames are ~12 MB) while bounding decode amplification to a
- * roughly constant factor of the wire bytes. A hostile-peer invariant, not a
- * deployment choice.
+ * roughly constant factor of the wire bytes. The unframed-buffer counter is
+ * checked against this same cap BEFORE a `Buffer.concat` join, so an oversized
+ * frame is dropped at one copy of its wire bytes. A hostile-peer invariant,
+ * not a deployment choice.
  */
 const FRAME_PARSE_CAP_BYTES = 64 * 1024 * 1024
 
 /**
  * Fragments the unframed fd-3 buffer may hold before they are coalesced into
- * one Buffer, bounding retained per-chunk overhead that {@link
- * FRAME_CEILING_BYTES} cannot see: that ceiling meters payload bytes, while
- * each chunk is a distinct Buffer with its own object and backing store. A
+ * one Buffer, bounding retained per-chunk overhead that the byte cap cannot
+ * see: the cap meters payload bytes, while each chunk is a distinct Buffer
+ * with its own object and backing store. A
  * program writing single bytes without a newline produced one chunk per write.
  * 1024 keeps the overhead a small constant factor of the payload while leaving
  * normal pipe-sized reads (which arrive in far fewer, much larger chunks)
@@ -1314,21 +1314,23 @@ export class PythonCodeRuntime extends CodeRuntime {
         // current line can be larger than that. That over-count is deliberate and
         // load-bounded on the OTHER side: the config cap is `parse-cap - envelope`,
         // and a legitimate near-cap frame plus a following chunk's leading bytes
-        // could in principle nudge the counter over the ceiling for one read
-        // window — but only when maxLogBytes/maxValueBytes is configured within
-        // one pipe read of the 256 MiB ceiling, orders of magnitude past the
-        // 32/64 KiB defaults. Enforcing the ceiling per-frame instead (splitting
-        // before the check) would require `Buffer.concat`-ing an over-ceiling
-        // single frame before rejecting it, reintroducing the peak-memory
-        // doubling this pre-concat check and its regression tests exist to
-        // prevent; the memory-safety bound against hostile input at any config
-        // takes precedence over a false-reject reachable only at a pathological
-        // near-ceiling config.
-        if (pendingBytes > FRAME_CEILING_BYTES) {
+        // could in principle nudge the counter over the cap for one read window
+        // — but only when maxLogBytes/maxValueBytes is configured within one
+        // pipe read of the 64 MiB cap, orders of magnitude past the 32/64 KiB
+        // defaults.
+        //
+        // The cap used HERE is FRAME_PARSE_CAP_BYTES, not the 256 MiB wire
+        // ceiling: a single frame between 64 MiB and the ceiling would otherwise
+        // be fully `Buffer.concat`-ed (a second copy of its bytes) and only then
+        // dropped in the line loop — the peak-memory doubling this pre-concat
+        // check exists to prevent, now for a frame the parser is guaranteed to
+        // discard. Dropping the oversized unframed buffer before the join keeps
+        // the peak at one copy of the wire bytes.
+        if (pendingBytes > FRAME_PARSE_CAP_BYTES) {
           pendingChunks = []
           sealedBlocks = []
           pendingBytes = 0
-          finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${FRAME_CEILING_BYTES} bytes on fd 3` } })
+          finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${FRAME_PARSE_CAP_BYTES} bytes on fd 3` } })
           return
         }
         // Bound the FRAGMENT COUNT as well as the byte total, but only AFTER the

+ 27 - 14
packages/code-runtime/code-runtime-python/tests/runtime.spec.ts

@@ -1734,32 +1734,45 @@ describe('PythonCodeRuntime — programs and bindings', () => {
     expect(result.error?.kind).not.toBe('worker-exit')
   }, 15_000)
 
-  it('drops an fd-3 frame whose raw length exceeds the parse cap before decoding it', async () => {
+  it('rejects an fd-3 frame whose raw length exceeds the parse cap before joining it', async () => {
     // The 256 MiB wire ceiling bounds the RAW frame bytes, not the decoded
     // structure; a compact wide frame near that ceiling could decode to far
-    // more host memory. The receive path caps raw frames at
-    // FRAME_PARSE_CAP_BYTES before toString/JSON.parse and drops the oversized
-    // one like any junk frame, so the following normal frame is still
-    // processed. Fail-before: without the cap the oversized log text would be
-    // parsed and admitted (truncating the ledger), and the trailing frame
-    // would be dropped as post-truncation instead of appearing in logs.
+    // more host memory. The unframed-buffer counter is checked against
+    // FRAME_PARSE_CAP_BYTES BEFORE the Buffer.concat join, so an oversized
+    // frame is dropped at one copy of its wire bytes instead of being fully
+    // joined (a second copy) and only then discarded in the line loop — the
+    // peak-memory doubling the pre-join check exists to prevent. Fail-before:
+    // without the check the frame is joined whole and parsed (its log text
+    // admitted, truncating the ledger), and the run completes normally.
     const { runtime } = await setup({ maxWallMs: 60_000 })
     const result = await runtime.run({
       program: [
         'import os',
         // One frame just past the 64 MiB parse cap.
         'os.write(3, b"{\\"type\\":\\"log\\",\\"text\\":\\"" + b"a" * (65 * 1024 * 1024) + b"\\"}\\n")',
-        'os.write(3, b"{\\"type\\":\\"log\\",\\"text\\":\\"after-cap\\"}\\n")',
         'return "done"',
       ].join('\n'),
       bindings: [],
     })
-    expect(result.error).toBeUndefined()
-    expect(result.value).toBe('done')
-    // The oversized frame was dropped before parse; the trailing frame was
-    // processed normally (its text survives in logs).
-    expect(result.logs).toContain('after-cap')
-    expect(result.logs.some(line => line.length > 1024 * 1024)).toBe(false)
+    expect(result.error?.kind).toBe('worker-exit')
+    expect(result.error?.message).toContain('protocol frame exceeded')
+  }, 90_000)
+
+  it('caps an oversized rejection diagnostic so an invalid completion stays invalid-output', async () => {
+    // _done_with_value caps its rejection diagnostic through _cap_message: a
+    // hostile class name (huge type(value).__name__) would otherwise push the
+    // done frame past the host's 64 MiB parse cap, misreporting an
+    // invalid-output run as a worker-exit. The diagnostic is capped to the
+    // value budget, so the frame always crosses the parser.
+    const { runtime } = await setup({ maxWallMs: 60_000 })
+    const result = await runtime.run({
+      program: [
+        'return type("N" * (70 * 1024 * 1024), (), {})()',
+      ].join('\n'),
+      bindings: [],
+    })
+    expect(result.error?.kind).toBe('invalid-output')
+    expect(result.error?.kind).not.toBe('worker-exit')
   }, 90_000)
 
   it('bounds an over-cap exception-group nesting on the copy', async () => {

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.