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 1dca984554..28eaf56134 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: fbf562ac7eeb407d137905649160916c6ce63c4f -2026-07-31-code-runtime-python-settlement-fixes.zh.md: 594d5074a5b96885416c4019d228eaafd10b08ae +2026-07-31-code-runtime-python-settlement-fixes.md: 1b2840c83b3058c7cd6082c78ced565c30ee530d +2026-07-31-code-runtime-python-settlement-fixes.zh.md: 1eab0807e94fa6cc0dcf9d425ba1726fe6412150 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 fbf562ac7e..1b2840c83b 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 @@ -10,7 +10,7 @@ The CPython subprocess backend for Code Mode, built on the [fd-3 frame protocol] ## Decision -Six independent corrections, each in the package that owns the defect. +Seven independent corrections, each in the package that owns the defect. ### Boot-write failure no longer rejects run() @@ -32,15 +32,22 @@ The load-time check that rejects a `maxLogBytes`/`maxValueBytes` larger than one A model program can leave a descendant in the child's OWN process group (no `setsid`, so `kill(-pid)` reaches it) that ignores SIGTERM but releases the inherited stdout/stderr/fd-3 pipes. The leader then exits, its `close` fires because the pipes drained, and settlement runs while that descendant is still alive. `kill()` arms an `unref`'d SIGKILL timer after SIGTERM; the fix is that `settle()` no longer resolves the run's `finished` promise immediately when an escalation is in flight. Instead, when `killing` is set and the process group is not yet empty (`process.kill(-pid, 0)` does not throw ESRCH), it polls the group on a REF'd timer, bounded by `graceMs + CLOSE_REAP_MARGIN_MS`, and resolves `finished` only once the group has emptied. The ref'd poll is the load-bearing part: it keeps the host event loop alive until the SIGKILL has actually reaped the group, so even a short-lived host — a one-shot headless run, a config subprocess — cannot exit and reparent the survivor to init. In the normal case (the leader was the only member) the first probe returns ESRCH and settlement resolves with zero added latency. `teardown()` awaits each run's `finished`, so disposal is genuinely quiescent, matching its JSDoc. +Settlement also CANCELS the SIGKILL timer the moment the group is confirmed empty (the normal path, and when the poll sees the survivor gone). Leaving it armed would expose a PID-reuse hazard: a `kill(-pid)` left pending for up to `graceMs` after the leader was reaped could hit a RECYCLED pgid once the kernel reused the leader's pid, SIGKILLing an unrelated group (`killGroup` swallowing ESRCH does not help — the danger is precisely the kill that SUCCEEDS against a reused group). Clearing it on the empty probe bounds the reuse window to only the genuine-survivor case, where the group cannot be empty to reuse. + +### RLIMIT clamps against the inherited soft limit, not only the hard + +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. + ### 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. ## Testing - `tests/boot-write-failure.spec.ts` mocks `spawn` so the fd-3 pipe throws on the boot write — the one path a real subprocess cannot be coerced into — and asserts `run()` resolves a `worker-exit` rather than rejecting. Isolated in its own spec so the real-subprocess suite is untouched. - `tests/residual-detach.spec.ts` unit-tests `detachResidual`: the carried copy equals the residual, owns a backing store sized to its own length (fixture kept above Node's Buffer pool threshold), and does not share the source frame's `ArrayBuffer`. -- `tests/runtime.spec.ts` — the output-cap case asserts the `ceiling - envelope` bound (268435392) and its message. A daemon-thread case drives four threads emitting unterminated writes through settlement's flush. The same-group reap case spawns a SIGTERM-ignoring same-group descendant that releases the pipes and bumps a heartbeat file; the test asserts the heartbeat STOPS after the grace-window SIGKILL — an assertion robust whether the killed descendant is reaped or lingers as a zombie, so it holds where PID 1 does not wait() orphans. The cross-loop case runs a binding from a worker thread's own `asyncio.run` loop while the main coroutine yields with `await asyncio.sleep`, asserting the reply round-trips instead of timing out. +- `tests/runtime.spec.ts` — the output-cap case asserts the `ceiling - envelope` bound (268435392) and its message. A daemon-thread case drives four threads emitting unterminated writes through settlement's flush. The same-group reap case spawns a SIGTERM-ignoring same-group descendant that releases the pipes and bumps a heartbeat file; the test asserts the heartbeat STOPS after the grace-window SIGKILL — an assertion robust whether the killed descendant is reaped or lingers as a zombie, so it holds where PID 1 does not wait() orphans. The cross-loop case runs a binding from a worker thread's own `asyncio.run` loop while the main coroutine yields with `await asyncio.sleep`, asserting the reply round-trips instead of timing out. The inherited-soft-limit case runs the interpreter through a `ulimit -S -t` wrapper that sets a CPU soft limit below `cpuSeconds` and asserts the applied `RLIMIT_CPU` soft is the inherited value, not the configured one (CPU rather than address space, since macOS ignores `ulimit -v`). ## Alternatives considered @@ -58,6 +65,10 @@ Also in `py/bootstrap.py`, a binding reply Future is created on the loop that ra **Complete the cross-loop Future with a plain `set_result` and rely on the GIL.** Rejected: the GIL serializes bytecode but does not make `asyncio.Future` cross-loop-safe — completing a Future from a thread other than its loop's does not schedule its callbacks or wake the loop. `call_soon_threadsafe` on the owning loop is the documented mechanism. +**Leave the SIGKILL timer armed after settlement (the earlier same-group fix).** Rejected: an unref'd timer left to fire up to `graceMs` after the leader was reaped can `kill(-pid)` a RECYCLED pgid, striking an unrelated group; the danger is the kill that succeeds, which `killGroup`'s ESRCH swallow cannot prevent. Clearing the timer once the group is confirmed empty bounds the reuse window to the genuine-survivor case, where the group is not empty to reuse. + +**Clamp rlimits by the inherited hard limit only.** Rejected: that silently RAISES an inherited soft limit stricter than the request, loosening the very containment the clamp exists to preserve. Clamping each side against its own inherited bound (then pinning soft under hard) keeps the strictest of configured and inherited on both. + ## Consequences -The seam's resolve-don't-reject contract holds on the boot-write path with measured coverage. Log capture is thread-safe at the cost of one re-entrant lock acquisition per write and flush. Fd-3 residual memory is bounded by the actual retained bytes. The output caps admit every value a frame can carry. Disposal is genuinely quiescent against a same-group survivor — bounded by the existing grace budget, zero-cost when the group is already empty — and bindings called from model-created threads complete instead of timing out. Each fix carries a test that fails without it, so a future regression on any of the six goes red. +The seam's resolve-don't-reject contract holds on the boot-write path with measured coverage. Log capture is thread-safe at the cost of one re-entrant lock acquisition per write and flush. Fd-3 residual memory is bounded by the actual retained bytes. The output caps admit every value a frame can carry. Disposal is genuinely quiescent against a same-group survivor — bounded by the existing grace budget, zero-cost when the group is already empty, with the SIGKILL timer cleared once the group empties so a stale kill cannot strike a recycled pgid — RLIMIT enforcement keeps the strictest of configured and inherited on both soft and hard, and bindings called from model-created threads complete instead of timing out. Each fix carries a test that fails without it, so a future regression on any of the seven goes red. 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 594d5074a5..1eab0807e9 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 @@ -10,7 +10,7 @@ Status: implemented ## Decision -六处相互独立的修正,各自位于拥有对应缺陷的包中。 +七处相互独立的修正,各自位于拥有对应缺陷的包中。 ### Boot-write failure no longer rejects run() @@ -32,6 +32,12 @@ Status: implemented 模型程序可能在子进程自己的进程组里(没有 `setsid`,因此 `kill(-pid)` 能到达它)留下一个后代,它忽略 SIGTERM,但释放了继承而来的 stdout/stderr/fd-3 管道。随后 leader 退出,由于管道已被抽空,它的 `close` 触发,于是结算在那个后代仍存活时运行。`kill()` 在 SIGTERM 之后装设一个 `unref` 的 SIGKILL 定时器;本次修复是,当有一次升级正在进行时,`settle()` 不再立即 resolve 该次运行的 `finished` promise。取而代之的是,当 `killing` 被置位且进程组尚未为空时(`process.kill(-pid, 0)` 不抛出 ESRCH),它在一个 ref 的定时器上轮询该进程组,以 `graceMs + CLOSE_REAP_MARGIN_MS` 为界,仅当进程组已清空后才 resolve `finished`。这个 ref 的轮询是承重部分:它让宿主事件循环保持存活,直到 SIGKILL 真正回收了该进程组,因此即使是一个短命的宿主(一次性的 headless 运行、一个配置子进程)也无法退出并把存活者 reparent 给 init。在正常情况下(leader 是唯一成员),第一次探测返回 ESRCH,结算以零附加延迟完成 resolve。`teardown()` 会 await 每次运行的 `finished`,因此 dispose 是真正完全停稳的,与其 JSDoc 相符。 +结算还会在进程组被确认为空的那一刻取消 SIGKILL 定时器(正常路径,以及轮询看到存活者已消失时)。让它继续处于装设状态会暴露一个 PID 复用隐患:一个在 leader 被回收后仍挂起长达 `graceMs` 的 `kill(-pid)`,可能在内核复用了 leader 的 pid 之后击中一个被回收(recycled)的 pgid,从而 SIGKILL 掉一个无关的进程组(`killGroup` 吞掉 ESRCH 并无帮助——危险恰恰是那次针对被复用进程组成功执行的 kill)。在空进程组探测时清除它,把复用窗口收窄到只剩真正存在存活者的情形,此时进程组不可能为空以供复用。 + +### RLIMIT clamps against the inherited soft limit, not only the hard + +在 [`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` 绝不会看到一个倒置的对。 + ### 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 顺序来交错帧。 @@ -40,7 +46,7 @@ Status: implemented - `tests/boot-write-failure.spec.ts` 对 `spawn` 做 mock,使 fd-3 管道在引导写入时抛出异常(这是真实子进程无法被迫进入的唯一路径),并断言 `run()` resolve 出一个 `worker-exit` 而非 reject。它被隔离在自己的 spec 中,因此真实子进程测试套件不受影响。 - `tests/residual-detach.spec.ts` 对 `detachResidual` 做单元测试:向前传递的副本与残余数据相等、拥有一个大小与其自身长度一致的底层存储(fixture 保持在 Node 的 Buffer 池阈值之上),并且不与源帧的 `ArrayBuffer` 共享。 -- `tests/runtime.spec.ts`:output-cap 用例断言 `ceiling - envelope` 上界(268435392)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时。 +- `tests/runtime.spec.ts`:output-cap 用例断言 `ceiling - envelope` 上界(268435392)及其消息。一个 daemon 线程用例驱动四个线程穿过结算的 flush 发出未结束的写入。same-group 回收用例 spawn 一个忽略 SIGTERM 的同进程组后代,它释放管道并递增一个心跳文件;该测试断言在宽限窗口的 SIGKILL 之后心跳停止:无论被杀死的后代是被回收还是作为僵尸进程滞留,这个断言都成立,因此它在 PID 1 不 wait() 孤儿进程的环境下同样成立。cross-loop 用例在主协程通过 `await asyncio.sleep` 让出时,从一个工作线程自己的 `asyncio.run` 事件循环运行一个绑定,断言该回复完成往返而不是超时。inherited-soft-limit 用例通过一个 `ulimit -S -t` 包装脚本运行解释器,将 CPU 软限制设为低于 `cpuSeconds`,并断言实际应用的 `RLIMIT_CPU` 软限制是继承来的值,而不是配置的值(用 CPU 而非地址空间,因为 macOS 忽略 `ulimit -v`)。 ## Alternatives considered @@ -58,6 +64,10 @@ Status: implemented **用一个普通的 `set_result` 完成跨事件循环的 Future 并依赖 GIL。** 已否决:GIL 序列化字节码,但并不使 `asyncio.Future` 跨事件循环安全:从一个并非其事件循环所属的线程完成一个 Future,不会调度它的回调,也不会唤醒该事件循环。在拥有该 Future 的事件循环上调用 `call_soon_threadsafe` 才是有文档记载的机制。 +**在结算之后让 SIGKILL 定时器继续处于装设状态(早先的同进程组修复)。** 已否决:一个被留待在 leader 被回收后长达 `graceMs` 才触发的 `unref` 定时器,可能 `kill(-pid)` 一个被回收(recycled)的 pgid,击中一个无关的进程组;危险是那次成功执行的 kill,而 `killGroup` 吞掉 ESRCH 无法阻止它。在进程组被确认为空后清除该定时器,把复用窗口收窄到真正存在存活者的情形,此时进程组不为空以供复用。 + +**只用继承而来的硬限制来约束 rlimit。** 已否决:那会静默地抬高一个比请求更严格的继承软限制,放松了该约束本应保持的那种收束。用每一侧各自继承而来的界来约束该侧(随后把 soft 钉在 hard 之下),在 soft 和 hard 两者上都保持配置值与继承值中的最严格者。 + ## Consequences -seam 的"只 resolve、不 reject"契约在引导写入路径上得以成立,且覆盖率是被度量的。日志捕获是线程安全的,代价是每次写入和 flush 都要获取一次可重入锁。fd-3 残余数据的内存受实际保留的字节数约束。输出上限放行一个帧所能承载的每一个值。dispose 面对同进程组存活者是真正完全停稳的(以既有的宽限预算为界,在进程组已为空时代价为零),并且从模型创建的线程调用的绑定会完成而不是超时。每处修复都附带一个在缺少它时会失败的测试,因此这六处中任何一处未来若发生回归都会变红。 +seam 的"只 resolve、不 reject"契约在引导写入路径上得以成立,且覆盖率是被度量的。日志捕获是线程安全的,代价是每次写入和 flush 都要获取一次可重入锁。fd-3 残余数据的内存受实际保留的字节数约束。输出上限放行一个帧所能承载的每一个值。dispose 面对同进程组存活者是真正完全停稳的(以既有的宽限预算为界,在进程组已为空时代价为零,并且一旦进程组清空就清除 SIGKILL 定时器,从而一次滞留的 kill 无法击中一个被回收的 pgid),RLIMIT 强制在 soft 和 hard 两者上都保持配置值与继承值中的最严格者,并且从模型创建的线程调用的绑定会完成而不是超时。每处修复都附带一个在缺少它时会失败的测试,因此这七处中任何一处未来若发生回归都会变红。 diff --git a/packages/code-runtime/code-runtime-python/py/bootstrap.py b/packages/code-runtime/code-runtime-python/py/bootstrap.py index e3866da968..bd61b282e3 100644 --- a/packages/code-runtime/code-runtime-python/py/bootstrap.py +++ b/packages/code-runtime/code-runtime-python/py/bootstrap.py @@ -534,7 +534,7 @@ def _make_error_class(name: str, member_name_property: str) -> type: def _clamped(which: int, soft: int, hard: int) -> tuple[int, int]: - """Bound a requested (soft, hard) rlimit pair by the inherited hard limit. + """Bound a requested (soft, hard) rlimit pair by BOTH inherited limits. An unprivileged process may lower a hard limit but never raise it, so a harness already started under a tighter ceiling (``ulimit -v`` below @@ -542,13 +542,24 @@ def _clamped(which: int, soft: int, hard: int) -> tuple[int, int]: ``setrlimit`` raise ``ValueError`` and fail every run — despite the inherited limit being STRONGER than the one requested. Clamping keeps the stricter of the two, which still satisfies the containment contract. - ``RLIM_INFINITY`` compares as -1, so it is special-cased rather than - treated as the smallest bound. + + Both inherited bounds matter, not just the hard one. A deployment that + inherited a soft limit BELOW what is requested (e.g. inherited ``(100, 200)``, + requested ``(150, 160)``) must keep the stricter soft — returning the + requested ``150`` would RAISE the effective soft limit, loosening RLIMIT_AS + memory or deferring the RLIMIT_CPU SIGXCPU, the opposite of "strictest of + configured and inherited". So each side is clamped against its inherited + counterpart. ``RLIM_INFINITY`` compares as -1, so an infinite inherited bound + imposes no ceiling and the requested value stands. """ - inherited = resource.getrlimit(which)[1] - if inherited == resource.RLIM_INFINITY: - return (soft, hard) - return (min(soft, inherited), min(hard, inherited)) + inherited_soft, inherited_hard = resource.getrlimit(which) + clamped_soft = soft if inherited_soft == resource.RLIM_INFINITY else min(soft, inherited_soft) + clamped_hard = hard if inherited_hard == resource.RLIM_INFINITY else min(hard, inherited_hard) + # setrlimit requires soft <= hard. Clamping the two sides independently can + # invert them (a finite inherited soft below the clamped hard is fine, but a + # requested hard below the inherited soft would leave soft > hard), so pin + # soft under hard as the final step; the stricter hard ceiling wins. + return (min(clamped_soft, clamped_hard), clamped_hard) # --------------------------------------------------------------------------- diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index a811185567..d662e36e07 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -1064,24 +1064,27 @@ export class PythonCodeRuntime extends CodeRuntime { } resolve({ ...result, logs }) // `finished` is what teardown awaits to honor "no subprocess outlives the - // fiber". When no escalation ran (normal completion, no kill) or the group - // is already empty, resolve it now. Otherwise a same-group descendant that - // ignored SIGTERM but released the pipes is still alive here (its `close` - // is what got us to settle); withhold `finished` until the grace-window - // SIGKILL has emptied the group. The poll timers are REF'd on purpose: a - // short-lived host (a one-shot headless run, a config subprocess) would - // otherwise exit before the unref'd SIGKILL timer fired, reparenting the - // survivor to init — the leak this await exists to prevent. The wait is - // bounded by the same graceMs + margin the SIGKILL escalation uses, so a - // truly unreapable process (it cannot be, since it is in the group - // `kill(-pid)` reaches) could not hang disposal. + // fiber". When no escalation ran (normal completion, no kill) or the + // group is already empty, cancel the pending SIGKILL and resolve now. + // Clearing it is what bounds the PID-reuse hazard: an armed `kill(-pid)` + // left to fire up to graceMs later could hit a RECYCLED pgid once the + // kernel reused the leader's pid, SIGKILLing an unrelated group. So the + // timer stays armed only while a real survivor exists — a same-group + // descendant that ignored SIGTERM but released the pipes, still alive + // here because its `close` is what got us to settle. In that case + // withhold `finished` and poll the group on REF'd timers (a short-lived + // host would otherwise exit before the unref'd SIGKILL fired, reparenting + // the survivor to init), clearing the timer the moment the group empties; + // the wait is bounded by the same graceMs + margin the escalation uses. if (!killing || groupEmpty()) { + if (graceTimer !== undefined) clearTimeout(graceTimer) finishResolve() return } const deadline = Date.now() + this.config.graceMs + CLOSE_REAP_MARGIN_MS const pollGroup = (): void => { if (groupEmpty() || Date.now() >= deadline) { + if (graceTimer !== undefined) clearTimeout(graceTimer) finishResolve() return } 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 30d4134aa3..2dc810c47c 100644 --- a/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/runtime.spec.ts @@ -411,6 +411,29 @@ describe('PythonCodeRuntime — inherited resource limits', () => { // the configured megabytes — exactly what the unclamped path applied. expect(result.value).toEqual([42, 43, 400 * 1024 * 1024]) }, 15_000) + + it('preserves an inherited soft limit stricter than the configured cap', async () => { + // Clamping reads BOTH inherited bounds, not just the hard one. A deployment + // that inherited a soft rlimit below the configured cap must keep that + // stricter soft: returning the configured value would RAISE the effective + // soft limit, loosening containment. The wrapper lowers only the SOFT CPU + // limit (`ulimit -S -t`) and leaves the hard limit unlimited, so the + // requested soft (`cpuSeconds`) sits above the inherited soft — the case that + // exposed the bug. RLIMIT_CPU is used because macOS ignores `ulimit -v` + // (RLIMIT_AS), which is exactly why the backend skips address space there. + const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-soft-')) + const wrapper = join(dir, 'python3-soft-capped') + // Soft CPU 5 s, well below the configured 30 s, hard left unlimited. + await writeFile(wrapper, '#!/bin/sh\nulimit -S -t 5\nexec python3 "$@"\n', { mode: 0o755 }) + const { runtime } = await setup({ pythonBin: wrapper, cpuSeconds: 30 }) + const result = await runtime.run({ + program: 'import resource\nreturn resource.getrlimit(resource.RLIMIT_CPU)[0]', + bindings: [], + }) + expect(result.error).toBeUndefined() + // The applied SOFT limit is the inherited 5 s, not the configured 30 s. + expect(result.value).toBe(5) + }, 15_000) }) describe('PythonCodeRuntime — programs and bindings', () => {