From 8e9d5467b00cde0122834eccba05ea480a410fdc Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 29 Aug 2026 23:51:17 +0800 Subject: [PATCH] fix(code-runtime-python): bound reply and call backlogs, snapshot binding metadata, and compact the reply queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the CPython backend: a child that never reads fd 3 leaves the reply pipe full forever, so the drain loop waits on 'drain' while every call frame it keeps sending resolves a binding and queues another reply — the backlog (and the binding results it pins) would grow until the wall clock. sendReply now caps the pending backlog at MAX_PENDING_REPLIES and settles the run as worker-exit past it, mirroring the frame cap; a child flooding calls against a binding that never settles would otherwise bypass that cap (pendingReplies grows only after the await), so the dispatcher counts in-flight binding calls before dispatch and releases the slot in the async body's finally, capping outstanding closures at the same bound. The drain also compacts its consumed prefix (replyQueue.splice(0, head)) once head reaches the bound, so a drain that stays alive without emptying cannot grow the backing store linearly with cumulative throughput. The completion-value meter counted lone surrogates with _SURROGATE.findall(folded), materializing one single-character string per surrogate: a surrogate-dense value near the budget (millions of surrogates, each serializing to six bytes) allocated millions of objects before the meter returned, defeating the meter's counting-without-building contract. The count is now the length difference between folded and the without string the meter already computes; a standalone equivalence check confirms it matches findall across lone-high, lone-low, paired, astral, and mixed cases. validateBindings read namespace.global/errorClass.name/memberNameProperty several times and retained the original errorClass object for the boot frame, whose JSON.stringify re-read it after validation: a stateful getter could throw or change between the two stages, turning the seam-misuse rejection into a worker-exit or injecting an unvalidated name. Each field is now read once into a plain value and the bindings map stores a plain { name, memberNameProperty } copy, so validation and the boot frame see identical values. Regression tests: a hostile child floods 5000 sequential valid calls without reading fd 3 and the run settles worker-exit with the reply-queue message before maxWallMs; a 3,000,000-surrogate completion succeeds at an 18,000,002-byte budget and reports output-limit one byte under; a 5000-call flood against a never-settling binding settles worker-exit with the call-backlog message; getter-backed namespace metadata that throws or changes on a second read boots and runs with each field read exactly once; a two-wave flood whose replies exceed the writable high-water mark drives the drain past the compaction bound mid-delivery and verifies all 1524 replies arrive. README Known Limitations gains the reply-backlog and call-backlog bounds (en/zh, pairing re-recorded); a new Agent Note registers the findings. --- ...og-and-binding-metadata-snapshot.i18n.yaml | 6 + ...l-backlog-and-binding-metadata-snapshot.md | 41 ++++ ...acklog-and-binding-metadata-snapshot.zh.md | 41 ++++ ...eply-backlog-and-surrogate-count.i18n.yaml | 6 + ...ython-reply-backlog-and-surrogate-count.md | 33 ++++ ...on-reply-backlog-and-surrogate-count.zh.md | 33 ++++ .../code-runtime-python/README.i18n.yaml | 4 +- .../code-runtime-python/README.md | 2 + .../code-runtime-python/README.zh.md | 2 + .../code-runtime-python/py/bootstrap.py | 13 +- .../code-runtime-python/src/index.ts | 107 ++++++++-- .../code-runtime-python/tests/runtime.spec.ts | 187 ++++++++++++++++++ 12 files changed, 459 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.i18n.yaml new file mode 100644 index 0000000000..a636d18288 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md +2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md: 6213a5d28493c388f7a05d57d353896e0b86f352 +2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md: daf02b895a06039cd06976edb7c10cf9481b7166 diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md new file mode 100644 index 0000000000..6213a5d284 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md @@ -0,0 +1,41 @@ +# Agent Note: Bound in-flight binding calls, snapshot binding metadata, and compact the reply queue in the CPython backend + +Status: implemented + +English | [中文](2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md) + +## Problem + +A further review round on the CPython subprocess backend (packages/experimental/code-runtime-python) surfaced three findings on the binding-dispatch and validation paths. First, the reply-backlog cap counts only RESOLVED calls — `pendingReplies` grows after the binding's `await` resolves — so a child flooding calls against a binding whose promise never settles accumulates one async closure per frame until the wall clock without ever tripping the cap. Second, `validateBindings` reads `errorClass.name`, `errorClass.memberNameProperty`, and `namespace.global` several times and retains the original errorClass object for the boot frame, whose `JSON.stringify` re-reads it after validation: a getter that returns a valid value during validation and then throws or returns a conflicting value at stringify time turns the seam-misuse rejection into a worker-exit, or injects a different name than validation approved. Third, `replyQueue` never shrinks mid-drain: the drain loop clears consumed slots to `undefined` but leaves `length` (and the backing store) growing, so a child that reads replies just fast enough to keep the drain alive but never empty grows the array linearly with cumulative throughput. + +## Decision + +### In-flight binding calls are capped at 1024 + +`case 'call'` counts the outstanding binding calls before dispatch (`pendingCalls`) and releases the slot in the async body's `finally`, covering the reply-written, resolution-rejected, and settled-drop exits. When the count reaches `MAX_PENDING_REPLIES`, the run settles as a `worker-exit` with a call-backlog message, bounding in-flight closures exactly like the reply backlog. This is a count bound, not a byte bound. + +### Binding metadata is snapshotted into plain values before validation and the boot frame + +`validateBindings` reads `namespace.global`, `errorClass.name`, and `errorClass.memberNameProperty` each exactly once into a plain local, validates the copies, and stores a plain `{ name, memberNameProperty }` object in the bindings map. The boot frame serializes that stored copy, so validation and the boot frame see identical values regardless of getter state; a stateful getter cannot change or throw between the two stages. + +### The reply queue compacts its consumed prefix mid-drain + +`drainReplies` compacts the consumed prefix (`replyQueue.splice(0, head); head = 0`) once `head` reaches `MAX_PENDING_REPLIES`. The splice is O(head) once per bound of consumed frames — amortized O(1) per reply — bounding the backing store to O(backlog + bound) for a drain that never empties. + +## Testing + +- `tests/runtime.spec.ts` — a hostile child floods 5000 sequential calls against a binding that never settles (`await new Promise(() => {})`); the run settles as `worker-exit` with the call-backlog message long before `maxWallMs`. Verified fail-before: without the cap the run times out at the wall clock. +- Two namespace-shape tests — `errorClass.name`/`errorClass.memberNameProperty` and `namespace.global` exposed through getters that throw or change on a second read; the run boots and completes, and each field is read exactly once (asserted). Verified fail-before: without the snapshot, the errorClass getter threw inside validation and the global getter injected a different name, failing the program with `NameError`. +- `tests/runtime.spec.ts` — a child floods calls whose replies exceed the writable high-water mark, blocking the first drain write; the resumed drain consumes a backlog past the compaction bound while a second wave of calls is still pending, and the child reads fd 3 itself (blocking the reply pump) to verify all 1524 replies arrive. Verified fail-before: a splice that removed pending frames dropped the second wave and the run hung to the wall clock. + +## Alternatives considered + +**Pause the fd-3 read side instead of counting in-flight calls.** Rejected: pausing reads would also stall processing of `done` and `log` frames the child may send after its last call, changing settlement timing; a count cap is deterministic and matches the existing frame-cap pattern. + +**Read metadata once but keep the original errorClass object.** Rejected: the boot frame's `JSON.stringify` re-invokes the getters; only a plain stored copy guarantees both stages read the same values. + +**Rely on the drain's `finally` reset for queue memory.** Rejected: the reset runs only when the drain ends; a drain that never empties keeps growing. Mid-drain compaction bounds the backing store while the drain is alive. + +## Consequences + +In-flight binding closures are bounded like the reply backlog, so a child flooding calls against a never-settling binding fails the run early instead of accumulating closures until the wall clock. The boot frame serializes exactly the metadata validation approved, regardless of getter state. The reply queue's backing store stays bounded during sustained partial drains; the compaction is internal memory hygiene with no observable behavior change. diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md new file mode 100644 index 0000000000..daf02b895a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 在 CPython 后端限制在途 binding 调用、快照 binding 元数据并压缩回复队列 + +Status: implemented + +[English](2026-08-29-code-runtime-python-call-backlog-and-binding-metadata-snapshot.md) | 中文 + +## Problem + +对 CPython 子进程后端(packages/experimental/code-runtime-python)的又一轮评审在 binding 分发与校验路径上浮出三项发现。其一,回复积压上限只计数已解析的调用——`pendingReplies` 在 binding 的 `await` 解析后才增长——因此向 promise 永不结算的 binding 洪泛调用的子进程会每个帧累积一个异步闭包直到墙钟,却始终不触发该上限。其二,`validateBindings` 多次读取 `errorClass.name`、`errorClass.memberNameProperty` 与 `namespace.global`,并把原始 errorClass 对象保留到引导帧,其 `JSON.stringify` 在校验后重读该对象:getter 在校验时返回合法值、在序列化时抛错或返回冲突值,会把 seam 误用拒绝变成 worker-exit,或注入一个未经校验批准的名字。其三,`replyQueue` 在排空进行中从不收缩:排空循环把已消费槽位清成 `undefined`,但 `length`(及其后备存储)继续增长,因此以恰好能让排空持续存活却永不排空的速率读取回复的子进程,会让数组随累计吞吐量线性增长。 + +## Decision + +### 在途 binding 调用限制为 1024 + +`case 'call'` 在分发前对在途 binding 调用计数(`pendingCalls`),并在异步体的 `finally` 中释放槽位,覆盖回复已写入、解析被拒绝与结算后丢弃三种出口。计数达到 `MAX_PENDING_REPLIES` 时,运行以带 call-backlog 消息的 `worker-exit` 结算,与回复积压一样限制在途闭包。这是计数上限而非字节上限。 + +### binding 元数据在校验与引导帧之前快照为纯值 + +`validateBindings` 把 `namespace.global`、`errorClass.name` 与 `errorClass.memberNameProperty` 各恰好读取一次到普通局部变量,对副本做校验,并在 bindings 映射中存入普通 `{ name, memberNameProperty }` 对象。引导帧序列化该存储副本,因此无论 getter 处于何种状态,校验与引导帧看到的都是相同的值;有状态的 getter 无法在两个阶段之间改变或抛错。 + +### 回复队列在排空进行中压缩已消费前缀 + +`drainReplies` 在 `head` 达到 `MAX_PENDING_REPLIES` 时压缩已消费前缀(`replyQueue.splice(0, head); head = 0`)。该 splice 为 O(head),每消费一上限的帧执行一次——均摊到每条回复为 O(1)——使永不排空的排空把后备存储限制在 O(积压 + 上限)。 + +## Testing + +- `tests/runtime.spec.ts`——敌意子进程向永不结算的 binding(`await new Promise(() => {})`)洪泛 5000 个连续调用;运行在远早于 `maxWallMs` 时以带 call-backlog 消息的 `worker-exit` 结算。已实测失败前置:没有该上限时运行在墙钟处超时。 +- 两个 namespace 形态测试——`errorClass.name`/`errorClass.memberNameProperty` 与 `namespace.global` 经由第二次读取即抛错或改变的 getter 暴露;运行正常引导并完成,且每个字段恰好读取一次(已断言)。已实测失败前置:没有快照时,errorClass getter 在校验内抛错,global getter 注入不同名字,程序以 `NameError` 失败。 +- `tests/runtime.spec.ts`——子进程洪泛回复超过可写高水位线的调用,阻塞第一次排空写入;恢复的排空在第二波调用仍待发时消费超过压缩上限的积压,子进程直接读取 fd 3(阻塞回复泵)验证全部 1524 条回复送达。已实测失败前置:移除待发帧的 splice 会丢掉第二波回复,运行挂到墙钟。 + +## Alternatives considered + +**暂停 fd-3 读侧而非计数在途调用。** 拒绝:暂停读取也会让子进程在最后一个调用后可能发送的 `done` 与 `log` 帧处理停滞,改变结算时机;计数上限是确定性的,且与既有帧上限模式一致。 + +**只读取一次元数据但保留原始 errorClass 对象。** 拒绝:引导帧的 `JSON.stringify` 会重新调用 getter;只有存入普通副本才能保证两个阶段读到相同的值。 + +**依赖排空的 `finally` 重置来回收队列内存。** 拒绝:重置只在排空结束时运行;永不排空的排空会持续增长。排空进行中的压缩在排空存活期间限制后备存储。 + +## Consequences + +在途 binding 闭包与回复积压一样受限,向永不结算的 binding 洪泛调用的子进程会让运行提前失败,而不是把闭包累积到墙钟。引导帧序列化校验批准的元数据,与 getter 状态无关。回复队列的后备存储在持续的部分排空期间保持有界;压缩是内部内存卫生,无可观察的行为变化。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.i18n.yaml new file mode 100644 index 0000000000..0e084349e7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-08-29-code-runtime-python-reply-backlog-and-surrogate-count.md +2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.md: 5ae31f669e2e207bc2f496d11ca3464f032783f1 +2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.zh.md: 799178dd54ceddd9b80b11e94d723282a037d398 diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.md b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.md new file mode 100644 index 0000000000..5ae31f669e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.md @@ -0,0 +1,33 @@ +# Agent Note: Bound the reply backlog and count lone surrogates without a match list in the CPython backend + +Status: implemented + +English | [中文](2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.zh.md) + +## Problem + +A further review round on the CPython subprocess backend (packages/experimental/code-runtime-python) surfaced two unbounded-allocation findings. First, `replyQueue` had no bound: a child that never reads fd 3 keeps the reply pipe full forever, so the drain loop waits on `drain` while every call frame it keeps sending resolves a binding and queues another reply — the backlog (and the binding results it pins) grows until the wall clock. Second, `_json_str_cost` counted lone surrogates with `_SURROGATE.findall(folded)`, which materializes one single-character string per surrogate: a surrogate-dense completion value near the budget (each surrogate serializes to six bytes, so a budget-sized value holds millions of them) allocates millions of objects before the meter returns, defeating the meter's own contract of counting without building. + +## Decision + +### The reply backlog is capped at 1024 pending frames + +`sendReply` now counts pending replies separately from the consumed slots the drain loop clears, and settles the run as a `worker-exit` with a reply-queue message before pushing when the backlog reaches `MAX_PENDING_REPLIES`. The counter is decremented as the drain writes each frame and reset when the drain finishes, so it measures only replies the host still holds. This mirrors the frame cap's treatment of an oversized inbound frame: a child that stops participating in the protocol fails the run early instead of growing host memory until the wall clock. It is a count bound, not a byte bound — binding results carry no seam-level byte cap, so the bound limits how many are retained, not how large any one is. + +### Lone surrogates are counted by length difference, not by a match list + +`_json_str_cost` computed `lone = len(_SURROGATE.findall(folded))`, building a list of one single-character string per lone surrogate. The count is now the length difference between `folded` and `without = _SURROGATE.sub("", folded)`: after pair-combining, every remaining surrogate is lone and exactly one code point, so the number removed is the count, and the `without` string is needed by the meter anyway. The meter returns the identical byte cost with no per-surrogate objects. + +## Testing + +- `tests/runtime.spec.ts` — a hostile child floods 5000 sequential valid call frames and never reads fd 3; the run settles as `worker-exit` with the reply-queue message long before `maxWallMs`, proving the backlog cap fires instead of a wall-clock timeout. A surrogate-dense completion of 3,000,000 lone surrogates pins the boundary at scale: 18,000,002 serialized bytes succeed at an 18,000,002 budget and report `output-limit` one byte under, proving the meter counts every surrogate exactly (the len-diff is verified equal to the old findall count across lone-high, lone-low, paired, astral, and mixed cases). + +## Alternatives considered + +**Pause the fd-3 read side while waiting for drain instead of capping the queue.** Rejected: pausing reads would also stall processing of `done` and `log` frames the child may send after its last call, changing settlement timing; a count cap is deterministic and matches the existing frame-cap pattern. + +**Keep findall and rely on the character-count lower bound.** Rejected: the lower bound admits a string by CHARACTER count while each surrogate serializes to six bytes, so a budget-sized surrogate-dense string passes it and reaches the meter; the match list is exactly the allocation the meter exists to avoid. + +## Consequences + +A child that stops consuming its replies now fails the run as a `worker-exit` once 1024 replies are retained, bounding host memory without a wall-clock wait. The completion-value meter counts lone surrogates with no per-surrogate allocation, keeping its documented counting-without-building contract for surrogate-dense values. diff --git a/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.zh.md b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.zh.md new file mode 100644 index 0000000000..799178dd54 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 在 CPython 后端限制回复积压并改用长度差计数孤立代理项 + +Status: implemented + +[English](2026-08-29-code-runtime-python-reply-backlog-and-surrogate-count.md) | 中文 + +## Problem + +对 CPython 子进程后端(packages/experimental/code-runtime-python)的又一轮评审浮出两项无界分配发现。其一,`replyQueue` 没有上限:从不读取 fd 3 的子进程让回复管道永远占满,排空循环只能等待 `drain`,而它持续发送的每个调用帧都会解析一个 binding 并入队一条回复——积压(连同其钉住的 binding 结果)一直增长到墙钟。其二,`_json_str_cost` 用 `_SURROGATE.findall(folded)` 计数孤立代理项,每个代理项物化一个单字符字符串:接近预算的代理项密集完成值(每个代理项序列化为六个字节,预算大小的值可容纳数百万个)会在计量返回前分配数百万个对象,违背计量器自身「计数而不构建」的契约。 + +## Decision + +### 回复积压限制为 1024 个待发帧 + +`sendReply` 现在把待发回复数与排空循环已清空的槽位分开计数,当积压达到 `MAX_PENDING_REPLIES` 时,在入队前以带回复队列消息的 `worker-exit` 结算运行。计数器在排空写入每帧时递减、排空结束时重置,因此只度量宿主仍持有的回复。这与帧上限对超大入站帧的处理一致:停止参与协议的子进程让运行提前失败,而不是让宿主内存增长到墙钟。这是计数上限而非字节上限——binding 结果在 seam 层没有字节上限,因此该上限限制保留的数量,而非单个结果的大小。 + +### 孤立代理项改用长度差计数,而非匹配列表 + +`_json_str_cost` 原先计算 `lone = len(_SURROGATE.findall(folded))`,为每个孤立代理项构建一个单字符字符串的列表。现在计数改为 `folded` 与 `without = _SURROGATE.sub("", folded)` 的长度差:配对合并后,剩余的每个代理项都是孤立且恰好一个码点,因此被移除的数量即计数,而 `without` 字符串本就是计量需要的。计量器返回完全相同的字节成本,且不产生任何按代理项计的对象。 + +## Testing + +- `tests/runtime.spec.ts`——敌意子进程洪泛 5000 个连续合法调用帧且从不读取 fd 3;运行在远早于 `maxWallMs` 时以带回复队列消息的 `worker-exit` 结算,证明积压上限先于墙钟超时触发。3,000,000 个孤立代理项的代理项密集完成值在规模上钉住边界:18,000,002 个序列化字节在 18,000,002 预算下成功、少一个字节时报 `output-limit`,证明计量器精确计数每个代理项(长度差在孤立高、孤立低、配对、星面和混合用例下与旧 findall 计数逐一相等,已实测验证)。 + +## Alternatives considered + +**在等待 drain 时暂停 fd-3 读侧而非限制队列。** 拒绝:暂停读取也会让子进程在最后一个调用后可能发送的 `done` 与 `log` 帧处理停滞,改变结算时机;计数上限是确定性的,且与既有帧上限模式一致。 + +**保留 findall 并依赖字符计数下界。** 拒绝:下界按字符数放行字符串,而每个代理项序列化为六个字节,因此预算大小的代理项密集字符串能通过下界并进入计量器;匹配列表正是计量器要避免的分配。 + +## Consequences + +停止消费回复的子进程现在会在保留 1024 条回复时以 `worker-exit` 结算运行,无需等待墙钟即可限制宿主内存。完成值计量器对孤立代理项的计数不再产生按代理项计的分,保持其对代理项密集值「计数而不构建」的既有契约。 diff --git a/packages/experimental/code-runtime-python/README.i18n.yaml b/packages/experimental/code-runtime-python/README.i18n.yaml index 591149b4ae..09426bcc0d 100644 --- a/packages/experimental/code-runtime-python/README.i18n.yaml +++ b/packages/experimental/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/experimental/code-runtime-python/README.md -README.md: 11f845626fe7aa06e7725c70dcab764482eb9552 -README.zh.md: 13f60ebc191fb5ed82566ec145f526c34dfe0714 +README.md: 1009c150a320e23811bae01e989e82cefeb9b907 +README.zh.md: b3dd8803855b9f579f2d1cfdd155ff3691b4573b diff --git a/packages/experimental/code-runtime-python/README.md b/packages/experimental/code-runtime-python/README.md index 11f845626f..1009c150a3 100644 --- a/packages/experimental/code-runtime-python/README.md +++ b/packages/experimental/code-runtime-python/README.md @@ -119,6 +119,8 @@ These limits define what the package does and does not cover; they are current p - **`run()` is one-shot** — `logs` become available only after `CodeRunResult` resolves; there is no streaming-log or progress interface for output produced by a running program. - **No state persists across runs** — every request executes in a fresh subprocess; a persistent REPL-style kernel stays deferred until a backend brings its own logging scheme. - **An fd-3 frame whose raw length exceeds 64 MiB settles the run as a worker-exit** — `maxLogBytes`/`maxValueBytes` are load-bounded to the same 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) trips the same cap — an accepted residual of the OOM guard. +- **A child that stops reading its replies settles the run as a worker-exit once the reply backlog passes 1024 frames** — the host writes replies one at a time, waiting for `drain` when the pipe is full; a child that keeps sending calls without consuming replies would otherwise grow the retained backlog (and the binding results it pins) until the wall clock, so the backlog cap fails the run early. Binding results carry no seam-level byte cap, so this is a count bound, not a byte bound. +- **A child that floods calls against a binding that never settles settles the run as a worker-exit once 1024 calls are in flight** — binding calls are counted before dispatch and released when the async body settles, so a binding whose promise never resolves would otherwise accumulate one async closure per call frame until the wall clock. Like the reply backlog, this is a count bound, not a byte bound. - **A combined log-and-value peak is not modelled by the load gate** — a model daemon thread that keeps writing while the completion value is metered and framed can add the two peaks in a way no gate admits or rejects; the run dies as `worker-exit`, containment holds, and only the failure classification is degraded. - **A 1-second dual-limit `ulimit -t 1` CPU overrun is reported as `worker-exit`, not a timeout** — when the host starts under a hard CPU limit equal to the soft and that limit is 1, `_clamped` cannot lower the soft, so the kernel SIGKILLs the busy loop and SIGXCPU is never delivered; containment holds, only the classification is degraded. - **No byte cap on intermediate binding values** — the implementation remains bounded by the lossless-JSON serialization cost and process memory, and a provider or executor may apply its own fetch cap. diff --git a/packages/experimental/code-runtime-python/README.zh.md b/packages/experimental/code-runtime-python/README.zh.md index 13f60ebc19..b3dd880385 100644 --- a/packages/experimental/code-runtime-python/README.zh.md +++ b/packages/experimental/code-runtime-python/README.zh.md @@ -117,6 +117,8 @@ kind: "package-reference" - **`run()` 是一次性的**——`logs` 只有在 `CodeRunResult` resolve 后才能获得;没有为运行中程序产生的输出提供流式日志或进度接口。 - **运行之间不保留状态**——每次请求都在全新子进程中执行;持久 REPL 风格内核在某个后端带来自己的日志方案之前保持延期。 - **原始长度超过 64 MiB 的 fd-3 帧会让本次运行以 worker-exit 结算**——`maxLogBytes`/`maxValueBytes` 在加载期被限制到同一解析器上限,因此诚实子进程的帧总能放得下;模型构造的超过 64 MiB 的 binding 实参(一个在 seam 层没有预算的值)会触发同一上限——这是该 OOM 防护的已接受残余。 +- **停止读取回复的子进程会在回复积压超过 1024 帧时以 worker-exit 结算运行**——宿主每次写一条回复,管道满时等待 `drain`;只持续发送调用而不消费回复的子进程会让保留的积压(及其钉住的 binding 结果)一直增长到墙钟,因此积压上限让运行提前失败。binding 结果在 seam 层没有字节上限,所以这是计数上限而非字节上限。 +- **向永不结算的 binding 洪泛调用的子进程会在 1024 个调用在途时以 worker-exit 结算运行**——binding 调用在分发前计数、异步体结算时释放,否则 promise 永不 resolve 的 binding 会让每个调用帧累积一个异步闭包直到墙钟。与回复积压一样,这是计数上限而非字节上限。 - **组合日志与值的峰值不被加载门建模**——持续写入的模型 daemon 线程与完成值计量、分帧相加的峰值没有任何门会放行或拒绝;运行以 `worker-exit` 告终,隔离成立,只有失败分类降级。 - **1 秒双限 `ulimit -t 1` CPU 超限被报告为 `worker-exit` 而非 timeout**——当宿主在一个与软限相等的硬 CPU 限下启动且该限为 1 时,`_clamped` 无法下调软限,内核在同一 tick SIGKILL 忙循环,SIGXCPU 永远不会送达;隔离成立,只有分类降级。 - **中间 binding 值没有字节上限**——实现仍受无损 JSON 序列化成本与进程内存约束,提供方或执行器可能应用自己的获取上限。 diff --git a/packages/experimental/code-runtime-python/py/bootstrap.py b/packages/experimental/code-runtime-python/py/bootstrap.py index da6bf92525..3095a770f8 100644 --- a/packages/experimental/code-runtime-python/py/bootstrap.py +++ b/packages/experimental/code-runtime-python/py/bootstrap.py @@ -1725,10 +1725,19 @@ def _json_str_cost(text: str) -> int: except UnicodeEncodeError: pass folded = _SURROGATE_PAIR.sub(_combine_surrogate_pair, text) - lone = len(_SURROGATE.findall(folded)) + # Remove the lone surrogates first, then count them as the length + # difference: `_SURROGATE.findall(folded)` materialized one single-character + # string PER surrogate, so a surrogate-dense value near the budget + # (millions of lone surrogates, each serializing to six bytes) allocated + # millions of objects before the meter returned -- an RLIMIT_AS death + # surfacing as `exception` instead of the promised `output-limit`. After + # pair-combining, every remaining surrogate is lone and exactly one code + # point, so the removed length is the count, and the `without` string is + # needed for the meter anyway. + without = _SURROGATE.sub("", folded) + lone = len(folded) - len(without) # Six ASCII bytes per lone surrogate; the remainder is ordinary text whose # own quotes are dropped here because the outer call adds them once. - without = _SURROGATE.sub("", folded) return _json_string_cost(without.encode("utf-8")) + lone * 6 diff --git a/packages/experimental/code-runtime-python/src/index.ts b/packages/experimental/code-runtime-python/src/index.ts index ca67a9fc06..b9cb276f4b 100644 --- a/packages/experimental/code-runtime-python/src/index.ts +++ b/packages/experimental/code-runtime-python/src/index.ts @@ -216,6 +216,19 @@ const FRAME_PARSE_CAP_BYTES = 64 * 1024 * 1024 */ const MAX_PENDING_CHUNKS = 1024 +/** + * Replies the host retains before fd 3 accepts them. The drain loop writes one + * reply per iteration and waits for `drain` when the pipe is full; a child + * that never reads its replies (hostile or wedged) leaves the pipe full, so + * every call frame it keeps sending adds a reply the drain cannot write, and + * the backlog would grow without bound until the wall clock. 1024 keeps + * legitimate concurrent gathers (measured queue depths reach 11) far below + * the ceiling while bounding the hostile backlog; the run settles as a + * worker-exit past it, like the frame cap settles an oversized frame. A + * framing invariant, not a deployment choice. + */ +const MAX_PENDING_REPLIES = 1024 + /** * Bytes a frame spends on its own JSON structure around a capped payload, used * to bound `maxLogBytes`/`maxValueBytes` against {@link FRAME_PARSE_CAP_BYTES} @@ -969,32 +982,46 @@ export class PythonCodeRuntime extends CodeRuntime { injectedGlobals.add(name) } for (const namespace of request.bindings) { - if (!IDENTIFIER.test(namespace.global) || RESERVED_NAMES.has(namespace.global)) { - throw new Error(`dsh-code-runtime-python: binding global ${JSON.stringify(namespace.global)} is not a usable Python identifier`) + // Snapshot the caller-supplied fields into plain values ONCE. The + // namespace and errorClass objects may expose `global`/`name`/ + // `memberNameProperty` through getters: validation reads each several + // times, and the ORIGINAL errorClass object would otherwise be retained + // for the boot frame, whose JSON.stringify re-reads it after validation. + // A getter that changes or throws on a later read would turn the + // seam-misuse rejection into a worker-exit (or inject a different name + // than validation approved); reading each field once here and keeping + // the plain copy makes validation and the boot frame agree. + const global = namespace.global + if (!IDENTIFIER.test(global) || RESERVED_NAMES.has(global)) { + throw new Error(`dsh-code-runtime-python: binding global ${JSON.stringify(global)} is not a usable Python identifier`) } - if (bindings.has(namespace.global)) { - throw new Error(`dsh-code-runtime-python: duplicate binding global ${JSON.stringify(namespace.global)}`) + if (bindings.has(global)) { + throw new Error(`dsh-code-runtime-python: duplicate binding global ${JSON.stringify(global)}`) } - claimGlobal(namespace.global, 'binding global') + claimGlobal(global, 'binding global') // The error class becomes a program global and its member property an // attribute name, so both face the Python identifier rules; the member // additionally must be assignable on a BaseException instance. const errorClass = namespace.errorClass + let validatedErrorClass: CodeBindingErrorClass | undefined if (errorClass) { - if (!IDENTIFIER.test(errorClass.name) || RESERVED_NAMES.has(errorClass.name)) { - throw new Error(`dsh-code-runtime-python: errorClass.name ${JSON.stringify(errorClass.name)} is not a usable Python identifier`) + const name = errorClass.name + const memberNameProperty = errorClass.memberNameProperty + if (!IDENTIFIER.test(name) || RESERVED_NAMES.has(name)) { + throw new Error(`dsh-code-runtime-python: errorClass.name ${JSON.stringify(name)} is not a usable Python identifier`) } // Any non-empty own attribute name is settable via setattr (the // program reads exotic names like `tool-name` with getattr), matching // the seam contract and the worker backend — only the seam-excluded // and protocol-reserved members below are refused. - if (errorClass.memberNameProperty.length === 0) { + if (memberNameProperty.length === 0) { throw new Error('dsh-code-runtime-python: errorClass.memberNameProperty must be a non-empty attribute name') } - if (EXCEPTION_RESERVED_MEMBERS.has(errorClass.memberNameProperty) || DUNDER.test(errorClass.memberNameProperty)) { - throw new Error(`dsh-code-runtime-python: errorClass.memberNameProperty ${JSON.stringify(errorClass.memberNameProperty)} is a reserved error member and cannot be assigned`) + if (EXCEPTION_RESERVED_MEMBERS.has(memberNameProperty) || DUNDER.test(memberNameProperty)) { + throw new Error(`dsh-code-runtime-python: errorClass.memberNameProperty ${JSON.stringify(memberNameProperty)} is a reserved error member and cannot be assigned`) } - claimGlobal(errorClass.name, 'errorClass.name') + claimGlobal(name, 'errorClass.name') + validatedErrorClass = { name, memberNameProperty } } // Snapshot the callables into a plain own-property record before the // child can dispatch. `namespace.functions` is caller-supplied, so it may @@ -1020,7 +1047,7 @@ export class PythonCodeRuntime extends CodeRuntime { const fn = namespace.functions[name] if (typeof fn === 'function') functions[name] = fn } - bindings.set(namespace.global, { functions, ...errorClass ? { errorClass } : {} }) + bindings.set(global, { functions, ...validatedErrorClass ? { errorClass: validatedErrorClass } : {} }) } return bindings } @@ -1736,6 +1763,18 @@ export class PythonCodeRuntime extends CodeRuntime { sendReply({ type: 'reply', id: message.id, ok: false, message: capMessage(`unknown binding ${preview}`, cap) }) return } + // A binding that never settles (or resolves too slowly to keep up + // with the child's call rate) must not let the flood accumulate one + // async closure per frame until the wall clock: the reply cap only + // counts resolved calls, so it never trips for in-flight ones. + // Count the outstanding binding calls here, before dispatch, and + // release the slot in the body's finally — bounding in-flight + // closures to MAX_PENDING_REPLIES exactly like the reply backlog. + if (pendingCalls >= MAX_PENDING_REPLIES) { + finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } }) + return + } + pendingCalls += 1 void (async () => { try { const resolved = await fn(message.args) @@ -1773,6 +1812,13 @@ export class PythonCodeRuntime extends CodeRuntime { if (settled) return /* oxlint-enable typescript/no-unnecessary-condition */ sendReply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) }) + } finally { + // Release the in-flight slot on every exit — reply written, + // resolution rejected, or the run settling mid-wait (the + // `settled` early returns above). Without this, a binding that + // never resolves would leak its slot past the cap check and the + // flood bound would erode. + pendingCalls -= 1 } })() return @@ -1800,6 +1846,19 @@ export class PythonCodeRuntime extends CodeRuntime { // and the bindings themselves still run concurrently. Only the host's peak // memory and the flush timing change. const replyQueue: ReplyMessage[] = [] + // Replies queued but not yet written, tracked separately from + // `replyQueue.length`: the drain loop clears consumed slots to `undefined` + // but does not shrink the array until it finishes, so `length` counts + // consumed frames too. The counter is what the cap in `sendReply` reads. + let pendingReplies = 0 + // Binding calls dispatched but not yet settled (the async body below + // still awaits the binding's promise). The reply backlog cap only counts + // RESOLVED calls — `pendingReplies` grows after the await — so a child + // flooding calls against a binding that never settles would accumulate + // one async closure per frame until the wall clock without tripping it. + // Counted here before dispatch and released in the body's finally, the + // in-flight closures are bounded to the same MAX_PENDING_REPLIES. + let pendingCalls = 0 let draining = false // Resolve when fd 3 can take another frame, OR when it is gone: a pipe // destroyed under the drain (child exited, close-deadline teardown) never @@ -1847,6 +1906,18 @@ export class PythonCodeRuntime extends CodeRuntime { const payload = replyQueue[head] as ReplyMessage replyQueue[head] = undefined as unknown as ReplyMessage head += 1 + pendingReplies -= 1 + // Compact the consumed prefix once it reaches the backlog bound: + // the array never shrinks until the drain finishes, and a child + // that reads replies just fast enough to keep the drain alive but + // never empty would otherwise grow the backing store linearly with + // cumulative throughput (consumed slots are undefined, but `length` + // keeps counting them). The splice is O(head) once per + // MAX_PENDING_REPLIES consumed frames — amortized O(1) per reply. + if (head >= MAX_PENDING_REPLIES) { + replyQueue.splice(0, head) + head = 0 + } // Encode inside the loop, not up front: a queued reply the run no // longer needs is dropped by the `settled` check above without ever // being serialized. @@ -1859,12 +1930,24 @@ export class PythonCodeRuntime extends CodeRuntime { // the child died. The close path settles the run either way. } finally { draining = false + pendingReplies = 0 replyQueue.length = 0 } } const sendReply = (payload: ReplyMessage): void => { /* v8 ignore next -- `settled` covers a race where the child exits between decision and write. */ if (settled) return + // A child that stops reading fd 3 leaves the drain loop blocked on + // `drain` forever while its call frames keep resolving into replies: + // the backlog would grow without bound until the wall clock, pinning + // every binding result the child provokes. Cap the retained backlog and + // settle the run as a worker-exit, the same hostile-peer bound the + // frame cap applies to inbound bytes. + if (pendingReplies >= MAX_PENDING_REPLIES) { + finish({ error: { kind: 'worker-exit', message: `reply queue exceeded ${MAX_PENDING_REPLIES} pending frames on fd 3 (the child stopped consuming its replies)` } }) + return + } + pendingReplies += 1 replyQueue.push(payload) void drainReplies() } diff --git a/packages/experimental/code-runtime-python/tests/runtime.spec.ts b/packages/experimental/code-runtime-python/tests/runtime.spec.ts index ce43d19580..fa32f35dad 100644 --- a/packages/experimental/code-runtime-python/tests/runtime.spec.ts +++ b/packages/experimental/code-runtime-python/tests/runtime.spec.ts @@ -2491,6 +2491,64 @@ describe('PythonCodeRuntime — programs and bindings', () => { expect(result.value).toBe('ToolCallError:fail') }, 15_000) + it('runs when errorClass metadata is exposed through one-read getters', async () => { + // Validation reads errorClass.name and errorClass.memberNameProperty, and + // the ORIGINAL object used to ride along to the boot frame, whose + // JSON.stringify re-read it after validation: a getter that throws or + // changes on a second read turned the seam-misuse rejection into a + // worker-exit (or injected a different name than validation approved). + // The snapshot reads each field exactly once into a plain copy, so a + // getter that only tolerates one read must boot and run cleanly. + let nameReads = 0 + let memberReads = 0 + const errorClass = { + get name(): string { + nameReads += 1 + if (nameReads > 1) throw new Error(`errorClass.name read ${nameReads} times`) + return 'ToolCallError' + }, + get memberNameProperty(): string { + memberReads += 1 + if (memberReads > 1) throw new Error(`errorClass.memberNameProperty read ${memberReads} times`) + return 'toolName' + }, + } + const { runtime } = await setup() + const result = await runtime.run({ + program: 'return "ok"', + bindings: [{ global: 'tools', functions: {}, errorClass }], + }) + expect(result.error).toBeUndefined() + expect(result.value).toBe('ok') + expect(nameReads).toBe(1) + expect(memberReads).toBe(1) + }, 15_000) + + it('runs when the binding global is exposed through a one-read getter', async () => { + // Validation reads namespace.global several times (identifier check, map + // key, claim, boot frame), and the map key came from a fresh read each + // time: a getter returning a different name on a later read injected a + // global validation never approved, and the program referencing the + // approved name died with NameError. Snapshotting reads it exactly once, + // so the child must receive the name the program was written against. + let globalReads = 0 + const namespace = { + get global(): string { + globalReads += 1 + return globalReads === 1 ? 'tools' : 'evil' + }, + functions: { echo: async (args: unknown) => args as CodeJsonValue }, + } + const { runtime } = await setup() + const result = await runtime.run({ + program: 'return await tools.echo(41)', + bindings: [namespace], + }) + expect(result.error).toBeUndefined() + expect(result.value).toBe(41) + expect(globalReads).toBe(1) + }, 15_000) + it('rejects an errorClass name colliding with its namespace global at the seam', async () => { const { runtime } = await setup() await expect(runtime.run({ @@ -3812,6 +3870,27 @@ describe('PythonCodeRuntime — hostile peer', () => { expect(result.error?.kind).toBe('output-limit') }) + it('meters a surrogate-dense completion by counting, not by materializing a match list', async () => { + // `_json_str_cost` counted lone surrogates with `_SURROGATE.findall`, + // which materializes one single-character string PER surrogate: a + // surrogate-dense value near the budget (each surrogate serializes to six + // bytes, so a budget-sized value holds millions of them) would allocate + // millions of objects before the meter returned — an O(N)-objects spike + // that defeats the meter's documented contract of counting without + // building. The count is now a length difference over the removal `sub` + // already performs. Three million lone surrogates pin the boundary at + // scale: 18,000,002 serialized bytes succeed at an 18,000,002 budget and + // report output-limit one byte under, proving the meter counts every + // surrogate exactly rather than dropping or over-charging any. + const { runtime } = await setup({ maxValueBytes: 18_000_002 }) + const ok = await runtime.run({ program: 'return "\\ud800" * 3000000', bindings: [] }) + expect(ok.error).toBeUndefined() + expect(ok.value).toBe('\ud800'.repeat(3_000_000)) + const over = await setup({ maxValueBytes: 18_000_001 }) + const result = await over.runtime.run({ program: 'return "\\ud800" * 3000000', bindings: [] }) + expect(result.error?.kind).toBe('output-limit') + }, 60_000) + it('passes a lone-surrogate binding argument through instead of failing the call', async () => { // The argument validator shared the same over-narrow rejection; a host // binding must receive the code unit the program passed. @@ -5071,6 +5150,114 @@ describe('PythonCodeRuntime — hostile peer', () => { expect(result.value).toBe('done') }, 30_000) + it('caps the pending reply backlog when a child floods calls without reading its replies', async () => { + // drainReplies writes one reply at a time and waits for `drain` when fd 3's + // buffer is full. A child that never reads its replies (it only writes + // call frames, never draining the reply side) leaves the pipe full, so + // every call frame it keeps sending resolves a binding and adds a reply the + // drain cannot write: without a bound, the backlog grows until the wall + // clock, pinning each binding result in host memory. The cap settles the + // run as worker-exit instead, mirroring the frame cap's treatment of an + // oversized frame. The child floods 5000 sequential valid calls and never + // reads fd 3 (its reply pump is starved by the synchronous write loop and + // the blocking sleep); the pipe buffer absorbs ~1600 tiny replies, so the + // pending backlog crosses MAX_PENDING_REPLIES long before maxWallMs, and + // the run must settle worker-exit with the reply-queue message, not a + // wall-clock timeout. + const { runtime } = await setup({ maxWallMs: 30_000 }) + const result = await runtime.run({ + program: [ + 'import os, time', + 'frame = b\'{"type":"call","id":%d,"global":"tools","name":"echo","args":{}}\\n\'', + 'for i in range(5000):', + ' view = memoryview(frame % i)', + ' while view:', + ' view = view[os.write(3, view):]', + // Keep the child alive without reading fd 3: the run must settle via + // the reply-backlog cap, not by the child finishing or exiting. + 'time.sleep(30)', + 'return "unreachable"', + ].join('\n'), + bindings: [{ global: 'tools', functions: { echo: async (args: unknown) => args as CodeJsonValue } }], + }) + expect(result.error?.kind).toBe('worker-exit') + expect(result.error?.message).toContain('reply queue exceeded') + }, 30_000) + + it('caps the outstanding binding-call backlog when a child floods calls against a binding that never settles', async () => { + // The reply backlog cap only counts RESOLVED calls (`pendingReplies` grows + // after the await), so a child flooding calls against a binding whose + // promise never settles would accumulate one async closure per frame until + // the wall clock without tripping it. The outstanding-call counter bounds + // the in-flight closures to MAX_PENDING_REPLIES and settles the run as + // worker-exit, mirroring the reply cap. The binding below never resolves, + // so no reply is ever produced; the flood of 5000 sequential calls must + // cross the in-flight bound long before maxWallMs. + const { runtime } = await setup({ maxWallMs: 30_000 }) + const result = await runtime.run({ + program: [ + 'import os, time', + 'frame = b\'{"type":"call","id":%d,"global":"tools","name":"hang","args":{}}\\n\'', + 'for i in range(5000):', + ' view = memoryview(frame % i)', + ' while view:', + ' view = view[os.write(3, view):]', + 'time.sleep(30)', + 'return "unreachable"', + ].join('\n'), + bindings: [{ global: 'tools', functions: { hang: async () => await new Promise(() => {}) } }], + }) + expect(result.error?.kind).toBe('worker-exit') + expect(result.error?.message).toContain('call backlog exceeded') + }, 30_000) + + it('compacts the reply queue mid-drain without dropping pending frames', async () => { + // A reply larger than the writable high-water mark makes the FIRST write + // return false, suspending the drain loop while the child's synchronous + // flood starves the reply pump; the frames queued behind it push the + // drain's consumed head past MAX_PENDING_REPLIES, so the resumed drain + // compacts the queue mid-run. The child reads fd 3 itself (blocking the + // asyncio pump, so its reads cannot race the host's pushes) and sends a + // second wave of calls AFTER reading part of the first wave's replies — + // those replies are still pending when the drain's head crosses the + // compaction bound, so a compaction that dropped pending frames would + // leave the child's reply count short and the read loop spinning to the + // wall clock. The second wave is sent mid-delivery (not with the first + // flood): pushing it earlier would trip the 1024-pending reply cap + // before the drain resumed. + const { runtime } = await setup({ maxWallMs: 30_000 }) + const result = await runtime.run({ + program: [ + 'import os, time', + 'frame = b\'{"type":"call","id":%d,"global":"tools","name":"big","args":{}}\\n\'', + 'for i in range(1024):', + ' view = memoryview(frame % i)', + ' while view:', + ' view = view[os.write(3, view):]', + 'time.sleep(0.5)', + 'total = b""', + 'while total.count(b"\\n") < 500:', + ' chunk = os.read(3, 65536)', + ' if not chunk:', + ' break', + ' total += chunk', + 'for i in range(500):', + ' view = memoryview(frame % (1024 + i))', + ' while view:', + ' view = view[os.write(3, view):]', + 'while total.count(b"\\n") < 1524:', + ' chunk = os.read(3, 65536)', + ' if not chunk:', + ' break', + ' total += chunk', + 'return "done"', + ].join('\n'), + bindings: [{ global: 'tools', functions: { big: async () => 'x'.repeat(65 * 1024) } }], + }) + expect(result.error).toBeUndefined() + expect(result.value).toBe('done') + }, 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