Bladeren bron

fix(code-runtime-python): size the output-budget/address-space gate by worst-case Unicode and gate both budgets

The load-time addressSpaceMb gate used a 1/8 fraction derived for ASCII, but the
child ledgers trigger on character count against a serialized-byte budget: an
astral character is one character yet ~4 bytes stored and ~4 encoded, live at
once, so the true worst-case peak is ~8x the budget, not ~2x. Replace the
fraction with an explicit OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE (8)
and a strict `>`, and gate maxValueBytes the same way as maxLogBytes — the value
path builds and encodes a near-budget completion under the same RLIMIT_AS, so
the incompatible pair was previously admitted there too.

Slice the newline branch's unterminated tail to a budget-sized prefix: it
buffered the whole text[pos:] before the flush trigger could bound it, so an
early newline plus a huge tail made a second full copy of the model's string —
an RLIMIT_AS death the config gate cannot cover since the tail can far exceed
maxLogBytes.

Disclose the cross-field constraint in the maxLogBytes/maxValueBytes/addressSpaceMb
JSDoc (regenerating config-catalog); refresh the note's stale
Buffer.byteLength(JSON.stringify) reference; reconcile the arrival-order rebuttal
with the seam's "in order" logs JSDoc (within-stream, cross-stream best-effort).
Extend the load-rejection test to both budgets and add a tail-copy regression;
sync the zh pair.
Chinesezjc 1 maand geleden
bovenliggende
commit
d9307ae2a4

+ 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: 3ce090b8399311f3a27a03ea409379cbe55f2c54
-2026-07-31-code-runtime-python-settlement-fixes.zh.md: d14d71cb9390c6cfbdc196a0dd7323e7c6f3a53a
+2026-07-31-code-runtime-python-settlement-fixes.md: c58aa659057f94ea695425f93c6301bfcfcce80a
+2026-07-31-code-runtime-python-settlement-fixes.zh.md: 8a660f9477593740fb88496a3f292436de9ee593

File diff suppressed because it is too large
+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md


File diff suppressed because it is too large
+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md


+ 17 - 3
docs/config-catalog.md

@@ -378,12 +378,26 @@ export interface Config {
    * RLIMIT_AS in mebibytes; caps address space so a runaway allocation fails
    * cleanly. Not applied on Darwin, where the dyld shared cache mapped into
    * every process at exec exceeds any practical cap and the kernel rejects
-   * the call; `cpuSeconds` and `maxWallMs` still bound the run there.
+   * the call; `cpuSeconds` and `maxWallMs` still bound the run there. Bounds
+   * `maxLogBytes`/`maxValueBytes` at load on EVERY platform (not just where the
+   * limit is enforced): each budget times a worst-case Unicode expansion must
+   * fit this byte count, so a near-budget output cannot breach the address space
+   * during the child's build-and-encode.
    */
   addressSpaceMb?: number
-  /** Shared byte budget for captured log text (host-side ledger). */
+  /**
+   * Shared byte budget for captured log text (host-side ledger). Bounded at load
+   * against `addressSpaceMb`: the child builds and encodes a near-budget entry
+   * under RLIMIT_AS, so this cap times the worst-case Unicode expansion must fit
+   * the address space (see `addressSpaceMb`).
+   */
   maxLogBytes?: number
-  /** Byte cap for the completion value. */
+  /**
+   * Byte cap for the completion value. Bounded at load against `addressSpaceMb`
+   * the same way `maxLogBytes` is: the child builds and encodes a near-budget
+   * value under RLIMIT_AS, so this cap times the worst-case Unicode expansion
+   * must fit the address space.
+   */
   maxValueBytes?: number
   /** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
   graceMs?: number

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

@@ -248,7 +248,17 @@ class _LogStream(io.TextIOBase):
                 pos = newline + 1
             if pos < length:
                 if self._logs.remaining > 0:
-                    tail = text[pos:]
+                    # Buffer only a budget-sized PREFIX of the tail, not the whole
+                    # `text[pos:]`: an early newline followed by a huge unterminated
+                    # tail (`"\n" + "A" * 30 MiB`) would otherwise copy the entire
+                    # tail into `_pending` here — a second full copy of the model's
+                    # own string, the RLIMIT_AS death this path exists to avoid —
+                    # before the newline-free trigger below could bound it. Anything
+                    # past `remaining` characters cannot be admitted (the char count
+                    # is a lower bound on the serialized cost), so a
+                    # `remaining + 4`-character prefix is all that can ever survive;
+                    # the flush trigger below rejects it and emits the marker.
+                    tail = text[pos:pos + self._logs.remaining + 4]
                     self._pending.append(tail)
                     self._pending_chars = len(tail)
                 else:

+ 54 - 33
packages/code-runtime/code-runtime-python/src/index.ts

@@ -56,12 +56,26 @@ export interface Config {
    * RLIMIT_AS in mebibytes; caps address space so a runaway allocation fails
    * cleanly. Not applied on Darwin, where the dyld shared cache mapped into
    * every process at exec exceeds any practical cap and the kernel rejects
-   * the call; `cpuSeconds` and `maxWallMs` still bound the run there.
+   * the call; `cpuSeconds` and `maxWallMs` still bound the run there. Bounds
+   * `maxLogBytes`/`maxValueBytes` at load on EVERY platform (not just where the
+   * limit is enforced): each budget times a worst-case Unicode expansion must
+   * fit this byte count, so a near-budget output cannot breach the address space
+   * during the child's build-and-encode.
    */
   addressSpaceMb?: number
-  /** Shared byte budget for captured log text (host-side ledger). */
+  /**
+   * Shared byte budget for captured log text (host-side ledger). Bounded at load
+   * against `addressSpaceMb`: the child builds and encodes a near-budget entry
+   * under RLIMIT_AS, so this cap times the worst-case Unicode expansion must fit
+   * the address space (see `addressSpaceMb`).
+   */
   maxLogBytes?: number
-  /** Byte cap for the completion value. */
+  /**
+   * Byte cap for the completion value. Bounded at load against `addressSpaceMb`
+   * the same way `maxLogBytes` is: the child builds and encodes a near-budget
+   * value under RLIMIT_AS, so this cap times the worst-case Unicode expansion
+   * must fit the address space.
+   */
   maxValueBytes?: number
   /** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
   graceMs?: number
@@ -217,16 +231,22 @@ const FRAME_ENVELOPE_BYTES = 64
 const CLOSE_REAP_MARGIN_MS = 2_000
 
 /**
- * The largest fraction of `addressSpaceMb` that `maxLogBytes` may claim, enforced
- * at load. The child's log ledger encodes an admitted entry to UTF-8 once to
- * charge its serialized cost, so a near-budget entry transiently needs the entry
- * plus its encode copy — roughly twice `maxLogBytes` — on top of the interpreter
- * baseline, all under `RLIMIT_AS`. One eighth leaves an 8x margin over the raw
- * budget, comfortably past that transient at any admissible cap, so a legitimate
- * near-budget log entry truncates instead of breaching the address space. A fixed
- * safety invariant tying two configs together, not a deployment knob.
+ * Worst-case peak child-process bytes a one-`maxLogBytes`/`maxValueBytes`-budget
+ * output can transiently occupy while the child charges and frames it, expressed
+ * as a multiple of the budget. The child's ledgers trigger on CHARACTER count
+ * against a serialized-BYTE budget, and an astral character is one character but
+ * four bytes of CPython `str` storage and four UTF-8 bytes — so a budget's worth
+ * of astral characters is ~4x the budget in the built string and ~4x again in
+ * the `encode` copy taken to measure or ship it, live at the same time (the
+ * concat that briefly holds both is bounded by those two). Eight covers that
+ * simultaneous pair with margin for the interpreter baseline. Used to bound
+ * `maxLogBytes`/`maxValueBytes` against `addressSpaceMb` at load, with a STRICT
+ * `>` so a budget whose worst-case peak exactly equals the address space is
+ * rejected, so a legitimate near-budget output truncates (log) or fails as
+ * `output-limit` (value) rather than breaching `RLIMIT_AS` as `worker-exit`. A
+ * fixed safety invariant tying the budgets to the address space, not a knob.
  */
-const LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION = 1 / 8
+const OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE = 8
 
 /**
  * Interval between process-group liveness probes while settlement waits for an
@@ -688,28 +708,29 @@ export class PythonCodeRuntime extends CodeRuntime {
         throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the ${FRAME_CEILING_BYTES}-byte fd-3 frame ceiling, so the run would fail as worker-exit rather than output-limit), got ${String(this.config[key])}`)
       }
     }
-    // The child's log ledger admits an entry up to `maxLogBytes` and, to charge
-    // its serialized cost, encodes it to UTF-8 once — a transient allocation of
-    // up to `maxLogBytes` more bytes (and a control-char-dense entry escapes up
-    // to sixfold on the wire, though the encode itself is the raw copy). That
-    // copy happens under `RLIMIT_AS`, so a `maxLogBytes` that approaches
-    // `addressSpaceMb` makes a legitimate near-budget log entry breach the
-    // address space and die as `worker-exit` instead of truncating. Rather than
-    // meter every child write against the address space at runtime — which trades
-    // the memory bound for a per-character CPU cost on the hot path — reject the
-    // incompatible pair at load: require `maxLogBytes` to leave the child room
-    // for the interpreter baseline plus the entry and its encode copy. The bound
-    // is `LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION` of the address space, well
-    // clear of the ~2x-plus-baseline the push path needs at the default 64 KiB
-    // cap. Checked on every platform, not just where `RLIMIT_AS` is enforced: the
-    // incompatibility is a property of the two config values, and the child OOMs
-    // on a Linux deployment regardless of the host that assembled the config, so
-    // a uniform load-time rejection is the fail-loud contract (Darwin skips only
-    // the runtime `setrlimit`, not this static check).
+    // The child builds, charges, and frames a `maxLogBytes` log entry or a
+    // `maxValueBytes` completion value under `RLIMIT_AS`, and both paths trigger
+    // on CHARACTER count against a serialized-BYTE budget. An astral character is
+    // one character but four bytes of `str` storage and four UTF-8 bytes, so a
+    // budget's worth of them peaks at several simultaneous ~4x copies (the built
+    // string, the concat that still references it, and the encode taken to
+    // measure or ship it). A budget approaching `addressSpaceMb` therefore makes
+    // a LEGITIMATE near-budget output breach the address space and die as
+    // `worker-exit` instead of truncating (log) or failing as `output-limit`
+    // (value). Metering every child write against the address space at runtime is
+    // the wrong fix — an exact serialized-cost check is either a full encode (the
+    // allocation being avoided) or a per-character Python loop that burns the CPU
+    // budget — so the incompatible pair is rejected at load: each budget times the
+    // worst-case multiple must fit the address space. Checked on every platform,
+    // not just where `RLIMIT_AS` is enforced: the incompatibility is a property of
+    // the config values, and the child OOMs on a Linux deployment regardless of
+    // the host that assembled the config, so a uniform load-time rejection is the
+    // fail-loud contract (Darwin skips only the runtime `setrlimit`).
     const addressSpaceBytes = this.config.addressSpaceMb * 1024 * 1024
-    const logCaptureCeiling = Math.floor(addressSpaceBytes * LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION)
-    if (this.config.maxLogBytes > logCaptureCeiling) {
-      throw new Error(`dsh-code-runtime-python: config.maxLogBytes must not exceed ${logCaptureCeiling} (${LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION} of the ${addressSpaceBytes}-byte addressSpaceMb, leaving the child room to encode a near-budget log entry without breaching RLIMIT_AS), got ${String(this.config.maxLogBytes)}`)
+    for (const key of ['maxLogBytes', 'maxValueBytes'] as const) {
+      if (this.config[key] * OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE > addressSpaceBytes) {
+        throw new Error(`dsh-code-runtime-python: config.${key} times the ${OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE}x worst-case Unicode expansion must fit the ${addressSpaceBytes}-byte addressSpaceMb, so a near-budget output truncates rather than breaching RLIMIT_AS as worker-exit; got ${String(this.config[key])} against an address space that admits at most ${Math.floor(addressSpaceBytes / OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE)}`)
+      }
     }
     ctx.effect(() => () => this.teardown(), 'python code-runtime teardown')
   }

+ 46 - 15
packages/code-runtime/code-runtime-python/tests/runtime.spec.ts

@@ -117,8 +117,11 @@ describe('PythonCodeRuntime — seam descriptors and misuse', () => {
     await expect(ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible + 1 }))
       .rejects.toThrow(/maxValueBytes must not exceed 268435392 .*fd-3 frame ceiling/)
     // The boundary value itself loads: the bound is the largest cap a frame can
-    // still carry, not one below it.
-    const boundary = await ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible })
+    // still carry, not one below it. It needs an address space large enough to
+    // clear the separate maxValueBytes/addressSpaceMb worst-case gate (the cap
+    // times the 8x Unicode expansion must fit), so this pairs it with a 4 GiB
+    // addressSpaceMb — the two load-time bounds are independent.
+    const boundary = await ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible, addressSpaceMb: 4096 })
     await boundary.dispose()
   })
 
@@ -747,20 +750,25 @@ describe('PythonCodeRuntime — programs and bindings', () => {
     expect(result.logs.at(-1)).toBe(logTruncationMarker(4096))
   })
 
-  it('rejects a maxLogBytes that could breach addressSpaceMb during log encode at load', async () => {
-    // The child's log ledger encodes an admitted entry to UTF-8 once to charge
-    // its serialized cost, so a `maxLogBytes` approaching `addressSpaceMb` lets a
-    // legitimate near-budget log entry breach RLIMIT_AS and die as worker-exit
-    // instead of truncating. The incompatible pair is rejected at load rather
-    // than metered per-write at runtime: `maxLogBytes` must stay within one
-    // eighth of the `addressSpaceMb` byte count. 50 MB against a 64 MiB address
-    // space is far over that bound; the default 64 KiB against 512 MiB is not.
-    const ctx = new Context()
-    await expect(ctx.plugin(PythonCodeRuntime, { maxLogBytes: 50_000_000, addressSpaceMb: 64 }))
-      .rejects.toThrow(/maxLogBytes must not exceed .* of the .*addressSpaceMb/)
-    // A compatible pair loads.
+  it('rejects an output budget that could breach addressSpaceMb during encode at load', async () => {
+    // The child builds, charges, and encodes a `maxLogBytes` log entry or a
+    // `maxValueBytes` completion value under RLIMIT_AS, and both trigger on
+    // character count against a serialized-byte budget — an astral character is
+    // one character but ~4 bytes stored and ~4 encoded, so a budget approaching
+    // the address space lets a legitimate near-budget output breach it and die as
+    // worker-exit. The incompatible pair is rejected at load: each budget times
+    // the worst-case multiple (8) must fit the addressSpaceMb byte count. 50 MB
+    // against a 64 MiB address space is far over; the default caps against 512 MiB
+    // are not. Both budgets are gated symmetrically.
+    const ctxLog = new Context()
+    await expect(ctxLog.plugin(PythonCodeRuntime, { maxLogBytes: 50_000_000, addressSpaceMb: 64 }))
+      .rejects.toThrow(/maxLogBytes times the 8x worst-case Unicode expansion must fit/)
+    const ctxValue = new Context()
+    await expect(ctxValue.plugin(PythonCodeRuntime, { maxValueBytes: 50_000_000, addressSpaceMb: 64 }))
+      .rejects.toThrow(/maxValueBytes times the 8x worst-case Unicode expansion must fit/)
+    // The default caps against the default 512 MiB address space load.
     const ok = new Context()
-    const fiber = await ok.plugin(PythonCodeRuntime, { maxLogBytes: 65536, addressSpaceMb: 512 })
+    const fiber = await ok.plugin(PythonCodeRuntime, { maxLogBytes: 65536, maxValueBytes: 32768, addressSpaceMb: 512 })
     await fiber.dispose()
   })
 
@@ -3571,6 +3579,29 @@ describe('PythonCodeRuntime — hostile peer', () => {
     expect(result.logs.every(line => !line.includes(String.fromCharCode(0)))).toBe(true)
   }, 30_000)
 
+  it('bounds a huge unterminated tail after an early newline without copying it whole', async () => {
+    // The newline branch of _LogStream.write buffered the whole unterminated
+    // tail after the last newline into `_pending` before the flush trigger could
+    // bound it, so an early newline followed by a huge tail made a second full
+    // copy of the model's own string — a MemoryError the config gate cannot
+    // catch (the tail far exceeds maxLogBytes). The tail is now sliced to a
+    // budget-sized prefix, so the run truncates and completes. Linux-only RLIMIT_AS
+    // repro (Darwin skips the limit); on macOS this asserts the happy path.
+    const { runtime } = await setup({ maxLogBytes: 256, addressSpaceMb: 384, maxWallMs: 20_000 })
+    const result = await runtime.run({
+      program: [
+        'import sys',
+        // A short first line, then a 200 MiB unterminated tail on the same write.
+        'sys.stdout.write("first\\n" + "A" * (200 * 1024 * 1024))',
+        'return "done"',
+      ].join('\n'),
+      bindings: [],
+    })
+    expect(result.error).toBeUndefined()
+    expect(result.value).toBe('done')
+    expect(result.logs.some(line => line.includes('log capture truncated'))).toBe(true)
+  }, 30_000)
+
   it('bounds a flood of zero-byte log lines through the per-entry separator charge', async () => {
     // Blank print() lines carry zero content bytes; without the +1 separator
     // charge they would bypass maxLogBytes entirely and grow the retained

Some files were not shown because too many files changed in this diff