Bound the reply backlog and count lone surrogates without a match list in the CPython backend
在 CPython 后端限制回复积压并改用长度差计数孤立代理项
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 unt
English
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 asworker-exitwith the reply-queue message long beforemaxWallMs, 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 reportoutput-limitone 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.
中文
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 结算运行,无需等待墙钟即可限制宿主内存。完成值计量器对孤立代理项的计数不再产生按代理项计的分,保持其对代理项密集值「计数而不构建」的既有契约。