diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index db4ffa7a38..1bf05c450d 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/README.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 packages/code-runtime/code-runtime-python/README.md -README.md: 25d56e1e611a04523d933a16784e51a91d689fa3 -README.zh.md: 978a875c815d33dcfadfd5114ee8b88dbde4fc92 +README.md: 63fd9c5b03361a1970730cbf402daf5761ea7dc4 +README.zh.md: 8c0d119de131cba93224a81f7d3267daec7c8bd3 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 25d56e1e61..63fd9c5b03 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -39,6 +39,8 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **PID-reuse protection is inert on macOS** — `readProcessStart` reads `/proc//stat`, which Darwin does not provide, so the identity re-check that guards `killGroup` against signalling a recycled pgid always passes there; `killGroup` signals the pgid without the identity re-check on macOS rather than paying a `ps` fork on a teardown path. The process-group teardown and the `closeDeadline` bound still contain the run. - **C-ext stdio buffers are not drained at settlement.** The child runs with `-u` (unbuffered), so `sys.__stdout__`/`sys.__stderr__` and `os.write` bytes are visible to the host's stray capture immediately; but a C extension's private C-stdio (`FILE*`) buffering is outside the interpreter, and its unwritten bytes are lost when the host SIGTERMs the child after the done frame. Model code should flush C-level stdio explicitly before returning if it must survive. +- **An fd-3 frame whose raw length exceeds 64 MiB is dropped before decoding.** The receive path caps raw frames at `FRAME_PARSE_CAP_BYTES` before `toString`/`JSON.parse` (a compact wide frame near the 256 MiB wire ceiling could decode to far more host memory than the wire admitted). `maxLogBytes`/`maxValueBytes` are load-bounded to that parser cap so an honest child's frames always fit; a model-constructed binding ARGUMENT above 64 MiB (a value with no seam-level budget) is likewise dropped, stranding that call to the wall clock — an accepted residual of the same OOM guard. + - **A truncated log's serialized array runs to `maxLogBytes` plus the marker.** The truncation marker is envelope, not payload — it rides uncharged so it can always be emitted — and the outer-array envelope is reserved one byte in the ledger. A truncated run with admitted entries therefore serializes its `logs` array to at most `maxLogBytes + marker + 1`; the marker alone fits any admissible budget (the 64-byte floor guarantees it). - **A descendant that calls `setsid()` / `start_new_session=True` escapes teardown.** Termination signals the child's process group with `kill(-pid)`; a descendant that moves itself into a fresh session is no longer in that group and no signal reaches it. If it also releases the inherited stdout/stderr/fd-3 pipes, the leader's `close` still settles the run, and after the `closeDeadline` bound the fiber goes quiescent while that orphan keeps running. This is the containment boundary, not a security one — model code has bash-equivalent trust, and a bash tool can `setsid` away just the same. Reaching such an orphan would require tracking every descendant pid (as the bash-local backend's process-inspector does) and is deferred; the process-group teardown reaps everything that stays in the group. - **A combined log-and-value peak is not modelled by the load gate.** Each budget is checked against `addressSpaceMb` on its own. A model daemon thread that keeps writing while the completion value is metered and framed can refill the log pending toward `maxLogBytes` during that window, so the two peaks add in a way no gate admits or rejects. A gate over `(maxLogBytes + maxValueBytes)` was considered and deferred: its discriminating case cannot be scheduled deterministically under `RLIMIT_AS`, so the gate would only prove its own arithmetic. When the combined peak is reached the run dies as `worker-exit` -- containment holds and only the failure classification is degraded. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 978a875c81..8c0d119de1 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -39,6 +39,8 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **PID 复用防护在 macOS 上失效** —— `readProcessStart` 读取 `/proc//stat`,Darwin 不提供它,因此防止 `killGroup` 对已回收的 pgid 发信号的同一性复检在那里恒通过;`killGroup` 在 macOS 上不经同一性复检直接对 pgid 发信号,而非在拆卸路径上付出一次 `ps` fork。进程组拆卸与 `closeDeadline` 上界仍约束该次运行。 - **C 扩展的 stdio 缓冲在结算时不被排空。** 子进程以 `-u`(无缓冲)运行,因此 `sys.__stdout__`/`sys.__stderr__` 与 `os.write` 的字节立即可见;但 C 扩展私有的 C-stdio(`FILE*`)缓冲在解释器之外,其未写出的字节会在宿主于 done 帧后 SIGTERM 子进程时丢失。模型代码若需保留,应在返回前显式 flush C 层 stdio。 +- **原始长度超过 64 MiB 的 fd-3 帧会在解码前被丢弃。** 接收路径在 `toString`/`JSON.parse` 之前把原始帧限制在 `FRAME_PARSE_CAP_BYTES`(接近 256 MiB 线上上限的紧凑宽帧解码后可能占用远超线上字节的宿主内存)。`maxLogBytes`/`maxValueBytes` 在加载期被限制到该解析器上限,因此诚实子进程的帧总能放得下;模型构造的超过 64 MiB 的 binding 实参(一个在 seam 层没有预算的值)同样被丢弃,把该次调用搁置到墙钟——这是同一 OOM 防护的已接受残余。 + - **截断日志的序列化数组会到 `maxLogBytes` 加标记为止。** 截断标记是 envelope 而非 payload——它不计费地随行,因此总能发出——而外层数组外壳在账本中预留了一字节。因此带已放行条目的截断运行,其 `logs` 数组序列化后至多为 `maxLogBytes + marker + 1`;标记单独能放进任何可接受的预算(64 字节下限保证这一点)。 - **调用 `setsid()` / `start_new_session=True` 的后代会逃出 teardown。** 终止是用 `kill(-pid)` 向子进程的进程组发信号;一个把自己移入新会话的后代已不在该进程组内,任何信号都到不了它。若它同时释放了继承而来的 stdout/stderr/fd-3 管道,leader 的 `close` 仍会结算该次运行,在 `closeDeadline` 到界之后 fiber 变为完全停稳,而那个孤儿仍在运行。这是 containment 边界,而非安全边界——模型代码具有等同 bash 的信任级别,一个 bash 工具同样能 `setsid` 逃逸。要够到这样的孤儿需要追踪每一个后代 pid(如 bash-local 后端的 process-inspector 所做),此项已推迟;进程组 teardown 会回收所有留在组内的进程。 - **日志与完成值的叠加峰值未被加载门建模。** 每项预算都是各自对照 `addressSpaceMb` 检查的。模型的 daemon 线程可以在完成值被计量并分帧的窗口内持续写入、把日志 pending 重填到接近 `maxLogBytes`,于是两个峰值以任何门都不曾放行也不曾拒绝的方式相加。对 `(maxLogBytes + maxValueBytes)` 设门的方案经评估后推迟:它的判别用例无法在 `RLIMIT_AS` 之下确定性地构造出来,因此该门只能证明自己的算术。叠加峰值被触及时该次运行死为 `worker-exit`——containment 仍然成立,只是失败分类失真。 diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 81e6a131d3..80ba835b86 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -784,7 +784,10 @@ export class PythonCodeRuntime extends CodeRuntime { // it bills a forged `done.error.message` by RAW bytes, but that output goes // into `CodeRunResult.error.message` and never re-crosses a frame-bounded // channel, so it is not part of the wire-width bound (see its JSDoc). The - // admissible cap is therefore `ceiling - envelope`. + // admissible cap is therefore `parse-cap - envelope`: the receive path + // drops raw frames past FRAME_PARSE_CAP_BYTES before decoding (a hostile + // compact-wide-frame OOM guard), so a budget must not exceed what an honest + // child's frame can actually carry through that parser. for (const key of ['maxLogBytes', 'maxValueBytes'] as const) { // Require an integer: the child reads these budgets through `int(...)`, // which silently floors a float, so `maxLogBytes: 3.5` would truncate at 3 @@ -794,9 +797,9 @@ export class PythonCodeRuntime extends CodeRuntime { if (!Number.isInteger(this.config[key])) { throw new Error(`dsh-code-runtime-python: config.${key} must be a positive integer (the child reads it as an int, so a float diverges from the host), got ${String(this.config[key])}`) } - const limit = FRAME_CEILING_BYTES - FRAME_ENVELOPE_BYTES + const limit = FRAME_PARSE_CAP_BYTES - FRAME_ENVELOPE_BYTES if (this.config[key] > limit) { - 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])}`) + throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the fd-3 frame PARSER, which drops raw frames past ${FRAME_PARSE_CAP_BYTES} bytes before decoding to bound host memory — a larger budget would admit a config whose honest child frames the host then silently discards, stranding the run to the wall clock), got ${String(this.config[key])}`) } // Reject a log budget too small to honor: the truncation marker alone // must serialize within the budget, or a marker-only truncated run @@ -1308,7 +1311,7 @@ export class PythonCodeRuntime extends CodeRuntime { // most the newline-bearing chunk's own length (one pipe read): the // residual carried in is always a partial line, so nothing but the // current line can be larger than that. That over-count is deliberate and - // load-bounded on the OTHER side: the config cap is `ceiling - envelope`, + // 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 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 6fa85dc9c5..d7ba2b7f8f 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -108,14 +108,17 @@ describe('PythonCodeRuntime — seam descriptors and misuse', () => { // frame and fails the run as `worker-exit`, inverting the `output-limit` // the cap describes. Both budgets are metered in already-escaped serialized // bytes, so a payload occupies at most `cap + envelope` on the wire; the - // bound is `ceiling - envelope`, not `(ceiling - envelope) / 6` (that - // divided in escape expansion the charge already counts). - const admissible = 256 * 1024 * 1024 - 64 + // bound is `parse-cap - envelope`, not `(ceiling - envelope) / 6` (that + // divided in escape expansion the charge already counts). The receive path + // drops raw frames past the 64 MiB parse cap before decoding, so a budget + // above it would admit a config whose honest child frames the host then + // silently discards. + const admissible = 64 * 1024 * 1024 - 64 const ctx = new Context() await expect(ctx.plugin(PythonCodeRuntime, { maxLogBytes: admissible + 1 })) - .rejects.toThrow(/maxLogBytes must not exceed 268435392 .*fd-3 frame ceiling/) + .rejects.toThrow(/maxLogBytes must not exceed 67108800/) await expect(ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible + 1 })) - .rejects.toThrow(/maxValueBytes must not exceed 268435392 .*fd-3 frame ceiling/) + .rejects.toThrow(/maxValueBytes must not exceed 67108800/) // The boundary value itself loads: the bound is the largest cap a frame can // still carry, not one below it. It needs an address space large enough to // clear the separate maxValueBytes/addressSpaceMb worst-case gate (the cap