From 6f58f9c336700671142362528d17bc38e2ea48c6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 19 Aug 2026 17:15:21 +0800 Subject: [PATCH] fix(code-runtime-python): pace concurrent binding replies against fd 3 `sendReply` ignored `proto.write`'s `false` return, so a program resolving several large values in one `asyncio.gather` round encoded every reply in the same turn and queued all of them in fd 3's writable buffer. Binding resolution carries no seam-level byte cap to bound that, and the failure kills the host process rather than failing the run: measured on a 64 KiB-highWaterMark pipe, eight 4 MiB replies buffered 32.0 MiB at once against 0.0 MiB once paced. Replies now go through a queue that encodes and writes one frame at a time, awaiting `drain` when the pipe is full. The encode happens inside the loop, so a queued reply the run no longer needs is dropped by the `settled` check without ever being serialized. This was previously deferred on the grounds that serializing would narrow the seam's concurrency contract. That reasoning was wrong: the child matches each reply to its `call` by id from a pump that reads fd 3 continuously, so arrival order was never observable, and the bindings still run concurrently. Only the host's peak memory and the flush timing change. The README entry recording the deferral is removed and the Agent Note records the mechanism instead. --- ...-runtime-python-settlement-fixes.i18n.yaml | 4 +- ...31-code-runtime-python-settlement-fixes.md | 6 ++ ...code-runtime-python-settlement-fixes.zh.md | 6 ++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 59 +++++++++++++++++++ .../code-runtime-python/README.i18n.yaml | 4 +- .../code-runtime-python/README.md | 1 - .../code-runtime-python/README.zh.md | 1 - .../code-runtime-python/src/index.ts | 44 ++++++++++++-- .../code-runtime-python/tests/runtime.spec.ts | 29 +++++++++ 11 files changed, 146 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml index 27f0e00a1b..a70c099a8f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.i18n.yaml +++ b/.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: b5568d9f4db8bfb34b00a1badcf697dbe2cc6b69 -2026-07-31-code-runtime-python-settlement-fixes.zh.md: eec8a8a5104c83620d890e5805d5e259487be88e +2026-07-31-code-runtime-python-settlement-fixes.md: ed0532b1792fa9d99f3b13bf12ecf20f73a9102c +2026-07-31-code-runtime-python-settlement-fixes.zh.md: 70106c28bbbbc65243d3693d7fbd5ad81415fa96 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md index b5568d9f4d..ed0532b179 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.md @@ -42,6 +42,12 @@ The reap poll also handles a host event loop BLOCKED past both timers. If a sync In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py) `_clamped` bounded a requested `(soft, hard)` rlimit pair by the inherited HARD limit alone. A deployment that inherited a soft limit below the requested one — say inherited `(100, 200)`, requested `(150, 160)` — got back `(150, 160)`, RAISING the effective soft from 100 to 150: for `RLIMIT_AS` that loosens the memory ceiling, for `RLIMIT_CPU` it defers SIGXCPU, both violating "strictest of configured and inherited". `_clamped` now clamps each side against its own inherited counterpart (`RLIM_INFINITY` imposing no ceiling), then pins soft under hard so `setrlimit` never sees an inverted pair. The settlement-time CPU recheck (`die_if_cpu_exhausted`) follows the same rule: it compares spent CPU against the EFFECTIVE clamped `cpu_soft`, not the configured `cpuSeconds`, so a program that traps SIGXCPU and burns past a stricter inherited soft before returning is reported as a timeout rather than a false success. The SIGXCPU diagnostic no longer names the configured `cpuSeconds` as the effective budget — under a stricter inherited soft that number is wrong — and instead reports that CPU time was exhausted at "at most the configured N seconds", which holds whichever limit fired. +### Concurrent binding replies are paced against fd 3 + +`sendReply` ignored `proto.write`'s `false` return, so a program resolving several large values in one `asyncio.gather` round encoded every reply in the same turn and queued all of them in fd 3's writable buffer. Binding resolution carries no seam-level byte cap to bound that, and the failure kills the HOST process rather than failing the run: measured on a 64 KiB-highWaterMark pipe, eight 4 MiB replies buffered 32.0 MiB at once. Replies now go through a queue that encodes and writes one frame at a time, awaiting `drain` when the pipe is full, which measured a 0.0 MiB peak for the same shape. The encode happens inside the loop so a queued reply the run no longer needs is dropped by the `settled` check without ever being serialized. + +Pacing changes nothing the model can observe. The child matches each reply to its `call` by id from a pump that reads fd 3 continuously, so arrival order was never observable, and the bindings themselves still run concurrently -- only the host's peak memory and the flush timing change. That is also why serializing is not a narrowing of the seam's concurrency contract, which was the reason this was first deferred; that reasoning was wrong. + ### Binding replies complete on the calling loop's thread Also in `py/bootstrap.py`, a binding reply Future is created on the loop that ran `dispatch`. When the model calls a binding from a worker THREAD via `asyncio.run(tools.x(...))`, that Future belongs to the thread's loop, not the main loop where `_pump_replies` reads the reply. `asyncio.Future` is not thread-safe: completing it from another thread does not wake its own loop, so the direct `set_result`/`set_exception` left the awaiting thread stranded and the run degraded to a wall-clock timeout. Each pending entry now records its Future's loop alongside the Future, and `_pump_replies` completes it via that loop's `call_soon_threadsafe`. The shared `pending`/`next_id` state is guarded by a `threading.Lock` held across the id claim, the fd-3 write, and the counter advance, so concurrent callers cannot interleave frames out of the id order the host requires. `call_soon_threadsafe` onto a loop that has already CLOSED (the worker thread finished and abandoned its call before the reply arrived) raises `RuntimeError`; that schedule is wrapped so the moot reply is dropped rather than letting the exception end the pump task and strand every later reply. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md index eec8a8a510..70106c28bb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-code-runtime-python-settlement-fixes.zh.md @@ -42,6 +42,12 @@ Status: implemented 在 [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/bootstrap.py) 中,`_clamped` 仅用继承而来的 HARD 限制来约束一个请求的 `(soft, hard)` rlimit 对。一个继承了低于请求值的软限制的部署——比如继承 `(100, 200)`、请求 `(150, 160)`——会拿回 `(150, 160)`,把有效软限制从 100 抬高到 150:对 `RLIMIT_AS` 而言这放松了内存上限,对 `RLIMIT_CPU` 而言它推迟了 SIGXCPU,两者都违反了"取配置值与继承值中最严格者"。现在 `_clamped` 用每一侧各自继承而来的对应值来约束该侧(`RLIM_INFINITY` 不施加任何上限),随后把 soft 钉在 hard 之下,因此 `setrlimit` 绝不会看到一个倒置的对。结算时的 CPU 复查(`die_if_cpu_exhausted`)遵循同一规则:它把已消耗的 CPU 与实际生效的、被夹紧的 `cpu_soft` 比较,而不是与配置的 `cpuSeconds` 比较,因此一个捕获 SIGXCPU、在返回前消耗超过更严格的继承软限制的程序会被报告为 timeout,而非误判为成功。SIGXCPU 诊断不再把配置的 `cpuSeconds` 说成实际生效的预算——在一个更严格的继承软限制之下那个数字是错的——而是报告 CPU 时间是在"至多配置的 N 秒"处被耗尽,这一表述无论哪个限制先触发都成立。 +### 并发 binding 回复对 fd 3 做节流 + +`sendReply` 忽略了 `proto.write` 的 `false` 返回值,因此一个在一轮 `asyncio.gather` 中解析多个大值的程序,会把每条回复都在同一个 turn 内编码、并全部排入 fd 3 的可写缓冲。binding 回复在 seam 层没有字节上限可以约束它,而且这个失败杀掉的是**宿主进程**而不是让本次运行失败:在 highWaterMark 为 64 KiB 的管道上实测,八条 4 MiB 回复会同时缓冲 32.0 MiB。现在回复走一个队列,一次编码并写出一帧,管道写满时等待 `drain`——同样形状实测峰值为 0.0 MiB。编码放在循环内部,因此一条运行已不再需要的排队回复会被 `settled` 检查丢弃,根本不会被序列化。 + +节流不改变任何模型可观测的行为。子进程通过一个持续读取 fd 3 的 pump、按 id 把每条回复匹配到它自己的 `call`,因此到达顺序从来不可观测,而各 binding 本身仍然并发执行——只有宿主的峰值内存与冲刷时延改变。这也正是为什么串行化并不构成对 seam 并发契约的收窄,而那恰是最初推迟此项的理由;那个理由是错的。 + ### Binding replies complete on the calling loop's thread 同样在 `py/bootstrap.py` 中,一个绑定回复 Future 是在运行 `dispatch` 的那个事件循环上创建的。当模型通过 `asyncio.run(tools.x(...))` 从一个工作线程调用某个绑定时,该 Future 属于该线程的事件循环,而不是 `_pump_replies` 读取回复的主事件循环。`asyncio.Future` 不是线程安全的:从另一个线程完成它并不会唤醒它自己的事件循环,因此直接的 `set_result`/`set_exception` 会让那个正在等待的线程被搁置,该次运行退化为墙钟超时。现在每个待处理条目都会在记录 Future 的同时记录其 Future 所属的事件循环,`_pump_replies` 通过该事件循环的 `call_soon_threadsafe` 来完成它。共享的 `pending`/`next_id` 状态由一把 `threading.Lock` 保护,该锁跨越 id 认领、fd-3 写入和计数器推进这三步持有,因此并发调用方无法以违反宿主所要求的 id 顺序来交错帧。对一个已经关闭的事件循环(工作线程已结束、在回复到达前放弃了它的调用)调用 `call_soon_threadsafe` 会抛出 `RuntimeError`;该调度被包裹起来,使这个已无意义的回复被丢弃,而不是让异常终结 pump 任务并搁置此后的每一个回复。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 49cb7bb2c7..503ffa970a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: ec077edd10962f324db242698b1d652563c3ac2f -config-catalog.zh.md: d349575cb2884bd2e80097c8345db8d0227107c7 +config-catalog.md: f49a76e01e2fceac0e306eeb1e714024d4006ff0 +config-catalog.zh.md: 51ef121bc31f1bbe52013b4ce218f0fcf9f06ac7 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b64905ce00..f49a76e01e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -413,7 +413,7 @@ export interface Config { } ``` -Source: [`packages/code-runtime/code-runtime-python/src/index.ts:43`](../packages/code-runtime/code-runtime-python/src/index.ts) +Source: [`packages/code-runtime/code-runtime-python/src/index.ts:44`](../packages/code-runtime/code-runtime-python/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d349575cb2..51ef121bc3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -358,6 +358,65 @@ export interface Config { 来源:[`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-code-runtime-python` + +```ts config-catalog +/** Plugin config: every cap, changeable from `cordis.yml` (no hardcoded tunables). */ +export interface Config { + /** + * RLIMIT_CPU in whole seconds (a positive integer — `setrlimit` in the child + * rejects a float). The child sets the soft limit to `cpuSeconds` and the + * hard limit to `cpuSeconds + 1`: the kernel delivers SIGXCPU at the soft + * limit, which the host classifies as a `timeout`; the +1s hard limit is a + * SIGKILL backstop for a program that traps SIGXCPU. Granularity is seconds — + * a coarser counterpart to the worker backend's millisecond `computeMs`. + */ + cpuSeconds?: number + /** Wall-clock ceiling in milliseconds; backstops CPU time for programs awaiting a promise nobody resolves. */ + maxWallMs?: number + /** + * 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. Bounds + * `maxLogBytes`/`maxValueBytes` at load on EVERY platform (this static check + * runs on Darwin too, where only the runtime `setrlimit` is skipped): each + * budget times a worst-case Unicode expansion must fit this byte count minus a + * fixed interpreter baseline, 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). Bounded at load + * against `addressSpaceMb`: the child builds and encodes a near-budget entry + * under RLIMIT_AS with several copies live at once, so this cap times the + * worst-case Unicode expansion must fit the address space left after the + * interpreter baseline (see `addressSpaceMb`) — a load-time rejection, not a + * runtime clamp. + */ + maxLogBytes?: number + /** + * 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 with several copies live at once, so this cap times the + * worst-case Unicode expansion must fit the address space left after the + * interpreter baseline. + */ + maxValueBytes?: number + /** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */ + graceMs?: number + /** + * Absolute path or basename of the CPython interpreter to spawn. Resolved + * through `PATH` when a basename is given. + */ + pythonBin?: string +} +``` + +来源:[`packages/code-runtime/code-runtime-python/src/index.ts:43`](../packages/code-runtime/code-runtime-python/src/index.ts) + ## `@deepseek-ai/dsh-code-runtime-worker-thread` diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 2f4a05fe65..c13bb31a48 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: e6f78893e32e03760a9d62bae701eb0e93776fd1 -README.zh.md: f4876cf4e13719de4e446bdd28ba58ae041be0a3 +README.md: 3a719c875a39cd2f19b481b8087b20a5b4c884a7 +README.zh.md: 3423763a04c5c18ef02ca52c1cc5aa1bce9ab586 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index e6f78893e3..3a719c875a 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -39,4 +39,3 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **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. - **A wide binding REPLY expands host-side state per member.** Resolutions cross through `snapshotJsonValue` in [`@deepseek-ai/dsh-session`](../../core/session/README.md), whose `walkJsonValue` pushes one task frame per member, and binding resolution carries no seam-level byte cap. A legitimate reply of several million elements can therefore exhaust the host heap. The property belongs to that shared walk, not to this backend -- the worker-thread backend consumes the same function -- so the fix belongs in `packages/core/session` where every consumer benefits. -- **Concurrent binding replies are not paced against fd 3.** `proto.write` returns `false` once the pipe's buffer is full and this backend does not wait for `drain`, so several bindings resolving large values in one `asyncio.gather` round encode and queue together in host memory. Serializing the replies would bound it, at the cost of changing the concurrency the seam currently allows; the sibling worker-thread backend has no equivalent (it posts structured clones, which carry no stream backpressure), so there is no in-repo precedent to copy. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index f4876cf4e1..3423763a04 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -39,4 +39,3 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **调用 `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 仍然成立,只是失败分类失真。 - **宽 binding 回复会按成员展开宿主侧状态。** 回复经由 [`@deepseek-ai/dsh-session`](../../core/session/README.md) 的 `snapshotJsonValue` 穿越,其 `walkJsonValue` 为每个成员压入一个任务帧,而 binding 回复在 seam 层没有字节上限。因此一个数百万元素的合法回复可以耗尽宿主堆。该性质属于那个共享遍历,而不属于本后端——worker-thread 后端消费同一个函数——所以修复应落在 `packages/core/session`,让所有消费方一并受益。 -- **并发 binding 回复没有对 fd 3 做节流。** 管道缓冲写满后 `proto.write` 返回 `false`,而本后端不等待 `drain`,因此在一轮 `asyncio.gather` 中多个 binding 同时返回大值时,它们会一起编码并排入宿主内存。把回复串行化可以给它设界,代价是改变 seam 当前允许的并发度;同类的 worker-thread 后端没有等价物(它投递结构化克隆,不存在流背压),因此仓库内没有可照抄的先例。 diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 89843b4962..096a0da2c1 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -13,6 +13,7 @@ */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { once } from 'node:events' import { accessSync, copyFileSync, constants as fsConstants, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, dirname, isAbsolute, join } from 'node:path' @@ -1382,14 +1383,47 @@ export class PythonCodeRuntime extends CodeRuntime { // strings/numbers), which is encodeJsonPlain's precondition. A closed // pipe (child already gone) is swallowed since the close path settles // the run. + // + // Replies are encoded and written ONE AT A TIME, waiting for `drain` + // whenever fd 3's buffer is full. Binding resolution carries no + // seam-level byte cap, so a program that resolves several large values in + // one `asyncio.gather` round would otherwise encode them all in the same + // turn and queue every frame in the writable stream's buffer -- measured + // to exhaust a 256 MiB Node heap, which kills the whole host process + // rather than failing this one run. Pacing changes no model-visible + // behavior: the child matches each reply to its `call` by id from a pump + // that reads fd 3 continuously, so arrival order was never observable, + // and the bindings themselves still run concurrently. Only the host's peak + // memory and the flush timing change. + const replyQueue: ReplyMessage[] = [] + let draining = false + const drainReplies = async (): Promise => { + if (draining) return + draining = true + try { + while (replyQueue.length > 0) { + if (settled) break + const payload = replyQueue.shift() as ReplyMessage + // 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. + if (!proto.write(`${encodeJsonPlain(payload)}\n`)) { + await once(proto, 'drain') + } + } + } catch { + // Pipe closed under us (child exited), or `drain` never arrives because + // the child died. The close path settles the run either way. + } finally { + draining = false + 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 - try { - proto.write(`${encodeJsonPlain(payload)}\n`) - } catch { - // Pipe closed under us (child exited). The close path finishes the run. - } + replyQueue.push(payload) + void drainReplies() } // Escalate SIGTERM → grace → SIGKILL on the entire process group. Idempotent 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 a47e5d6196..acb8a61ff3 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -3863,6 +3863,35 @@ describe('PythonCodeRuntime — hostile peer', () => { expect(resolvedLate).toBe(true) }, 90_000) + it('paces concurrent binding replies instead of queueing every frame at once', async () => { + // Binding resolution carries no seam-level byte cap. Before pacing, a program + // resolving several large values in one `asyncio.gather` round encoded them + // all in the same turn and queued every frame in fd 3's writable buffer, + // which exhausted the host heap and killed the whole process rather than + // failing the run. Replies are now encoded one at a time, waiting for + // `drain` when the pipe is full. + // + // Eight concurrent 4 MiB replies (32 MiB of frames) must all round-trip. The + // program sums the lengths, so the assertion proves every reply arrived and + // was matched to its own call -- pacing must not drop or misroute any. What + // this case cannot show is the peak itself, which lives in the stream's + // buffer: measured directly on a 64 KiB-highWaterMark pipe with this same + // 8x4 MiB shape, the unpaced writes buffered 32.0 MiB while the paced ones + // peaked at 0.0 MiB. + const chunk = 'A'.repeat(4 * 1024 * 1024) + const { runtime } = await setup({ maxWallMs: 60_000 }) + const result = await runtime.run({ + program: [ + 'import asyncio', + 'parts = await asyncio.gather(*[tools.chunk({}) for _ in range(8)])', + 'return sum(len(p) for p in parts)', + ].join('\n'), + bindings: [{ global: 'tools', functions: { chunk: async () => chunk } }], + }) + expect(result.error).toBeUndefined() + expect(result.value).toBe(8 * chunk.length) + }, 90_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