fix(code-runtime-python): bind the reply-pump exception names as def-time default arguments
A body-local X = X binding in _pump_replies is too late: _run reaches the model's top-level statements (which run first, since there is no suspension point between create_task and await __dsh_main__) before the pump's first step, so a __main__.RuntimeError rebind there would be captured by the body local and a closed-loop failure would escape the except, killing the pump. Bind _RuntimeError, _BindingRejection, str, and bool as DEF-TIME default arguments of _pump_replies (evaluated at import, before any model code runs). Add a regression test that rebinds __main__.RuntimeError as the first program statement and drives the closed-loop worker pattern, asserting the pump survives and delivers the later binding. Update the settlement note (en + zh) to describe the default-arg capture; pairing re-recorded and consistent.
This commit is contained in:
1 parent
b018abf405
commit
923fb56128
5 files changed
+64
-14
No files matched your search
+2
-2
@@ -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: 2a0bfd206a4f1ce8017e175aff6b79308b9b16b4
|
||||
2026-07-31-code-runtime-python-settlement-fixes.zh.md: d79612bd73c1b385e12785ef88182944b779d98f
|
||||
2026-07-31-code-runtime-python-settlement-fixes.md: 97555414342a02d7340febf68caa03545e168a60
|
||||
2026-07-31-code-runtime-python-settlement-fixes.zh.md: 8e536b438adfa2c4c756d67d76dd936f97c987da
|
||||
+1
-1
@@ -92,7 +92,7 @@ In [`py/bootstrap.py`](../../../../packages/code-runtime/code-runtime-python/py/
|
||||
|
||||
`_clamped` also lowers a clamped RLIMIT_CPU soft limit that EQUALS the hard by one unit (when the hard is at least 2). A `ulimit -t N` sets both, and with soft == hard the kernel checks the hard limit in the same tick and SIGKILLs a busy loop directly, so SIGXCPU is never delivered — and the host classifies a CPU overrun ONLY on `signal === 'SIGXCPU'`, so a definite budget exhaustion would be misreported as a `worker-exit`. Lowering the soft one unit gives SIGXCPU a window to fire, so the overrun is reported as a timeout. This is scoped to RLIMIT_CPU (a one-byte soft differential on RLIMIT_AS would only misalign the child's applied limit with the host budget gate, with no signal to preserve). The `hard >= 2` guard leaves a `hard == 1` blind spot — a 1-second dual limit cannot lower the soft to 0, so a definite overrun there is still reported as `worker-exit`.
|
||||
|
||||
`send_done` wraps its encode+write in a try and, on any throw from a rebound transitive name (`_dump_scalar`/`os`), writes a fixed pre-encoded done frame via the `_run`-local bound `_os_write`/`_memoryview`/`_FALLBACK_DONE_FRAME` — so a settled `exception` verdict is never downgraded to a `worker-exit`, and the host still gets a verdict. The reply queue's head-cursor drain clears each consumed slot so a wide written payload is released immediately, bounding host memory to the current backlog under sustained fd-3 backpressure. The exception classes the settlement-path `except` clauses catch are likewise bound into `_run` LOCALS (`_BaseException`, `_RuntimeError`) and a closure cell in `_make_failure_reporter`, before any model code runs — a rebind of `__main__.BaseException` or `__main__.RuntimeError` cannot make a program exception escape the handler and lose the `done` frame.
|
||||
`send_done` wraps its encode+write in a try and, on any throw from a rebound transitive name (`_dump_scalar`/`os`), writes a fixed pre-encoded done frame via the `_run`-local bound `_os_write`/`_memoryview`/`_FALLBACK_DONE_FRAME` — so a settled `exception` verdict is never downgraded to a `worker-exit`, and the host still gets a verdict. The reply queue's head-cursor drain clears each consumed slot so a wide written payload is released immediately, bounding host memory to the current backlog under sustained fd-3 backpressure. The exception classes the settlement-path `except` clauses catch are likewise bound before any model code runs: `_BaseException` is a `_run` LOCAL and a closure cell in `_make_failure_reporter`; `_RuntimeError`, `_BindingRejection`, `str`, and `bool` are DEF-TIME default arguments of `_pump_replies` (a body-local `X = X` binding is too late — the model's top-level statements run before the pump's first step). A rebind of `__main__.BaseException` or `__main__.RuntimeError` cannot make a program exception escape the handler and lose the `done` frame.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ Status: implemented
|
||||
|
||||
`_clamped` 还会把钳制出的、与硬限制相等的 RLIMIT_CPU 软限制降低一个单位(当硬限制至少为 2 时)。`ulimit -t N` 会同时设置两者,而当 soft == hard 时,内核会在同一 tick 检查硬限制并直接 SIGKILL 一个忙循环,因此 SIGXCPU 永远不会送达——而宿主只在 `signal === 'SIGXCPU'` 时把 CPU 超限分类为超时,所以一次确定的预算耗尽会被误报为 `worker-exit`。把软限制降低一个单位给 SIGXCPU 一个触发窗口,因此超限会被报告为超时。这仅限定于 RLIMIT_CPU(在 RLIMIT_AS 上的一字节软差异只会让子进程实际应用的限制与宿主预算门失步,没有需要保留的信号)。`hard >= 2` 守卫留下了 `hard == 1` 盲区——一个 1 秒的双限制无法把软限制降到 0,因此那里的确定超限仍被报告为 `worker-exit`。
|
||||
|
||||
`send_done` 将其 encode+write 包进 try,任何来自被重绑的传递名(`_dump_scalar`/`os`)的抛出都会写入一条固定的预编码 done 帧,经由 `_run` 局部绑定的 `_os_write`/`_memoryview`/`_FALLBACK_DONE_FRAME`——因此一个已结算的 `exception` 判决绝不会被降级为 `worker-exit`,宿主仍会拿到一个判决。回复队列的头游标排空会清除每个已消费槽位,因此一个已写出的宽 payload 会被立即释放,把宿主内存限制在持续的 fd-3 背压下的当前积压量。 结算路径各 `except` 子句所捕获的异常类同样被绑定进 `_run` 的局部(`_BaseException`、`_RuntimeError`)与 `_make_failure_reporter` 的闭包单元,在任何模型代码运行之前——重绑 `__main__.BaseException` 或 `__main__.RuntimeError` 无法让程序异常逃出处理器、丢失 `done` 帧。
|
||||
`send_done` 将其 encode+write 包进 try,任何来自被重绑的传递名(`_dump_scalar`/`os`)的抛出都会写入一条固定的预编码 done 帧,经由 `_run` 局部绑定的 `_os_write`/`_memoryview`/`_FALLBACK_DONE_FRAME`——因此一个已结算的 `exception` 判决绝不会被降级为 `worker-exit`,宿主仍会拿到一个判决。回复队列的头游标排空会清除每个已消费槽位,因此一个已写出的宽 payload 会被立即释放,把宿主内存限制在持续的 fd-3 背压下的当前积压量。 结算路径各 `except` 子句所捕获的异常类同样在任何模型代码运行之前绑定:`_BaseException` 是 `_run` 的局部与 `_make_failure_reporter` 的闭包单元;`_RuntimeError`、`_BindingRejection`、`str` 与 `bool` 是 `_pump_replies` 的 def 期默认参数(函数体内的 `X = X` 绑定太晚——模型顶层语句会先于泵体首步执行)。重绑 `__main__.BaseException` 或 `__main__.RuntimeError` 无法让程序异常逃出处理器、丢失 `done` 帧。
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1115,6 +1115,19 @@ async def _pump_replies(
|
||||
channel: ProtocolChannel,
|
||||
pending: dict[int, tuple[asyncio.AbstractEventLoop, asyncio.Future[Any]]],
|
||||
pending_lock: "threading.Lock",
|
||||
# Bound as DEFAULT ARGUMENTS so they are captured at def/import time, before
|
||||
# ANY model code runs. This bootstrap IS `__main__`, so `__main__.RuntimeError
|
||||
# = ...` (or `__main__._BindingRejection`, `__main__.str`, `__main__.bool`)
|
||||
# as a program top-level statement would otherwise rebind the module globals
|
||||
# these clauses resolve at runtime. A body-local `X = X` binding is too late:
|
||||
# `_run` reaches `await __dsh_main__` (whose top-level statements run first)
|
||||
# with no suspension point after `create_task`, so the model's rebind executes
|
||||
# before the pump body. Defaults are evaluated in the enclosing scope at def
|
||||
# time, truly before the program.
|
||||
_RuntimeError: Any = RuntimeError,
|
||||
_BindingRejection: Any = _BindingRejection,
|
||||
_str: Any = str,
|
||||
_bool: Any = bool,
|
||||
) -> None:
|
||||
"""Background task: read reply frames and settle pending futures.
|
||||
|
||||
@@ -1131,14 +1144,6 @@ async def _pump_replies(
|
||||
so a reply cannot race the claim that registers its id.
|
||||
"""
|
||||
|
||||
# The exception class the closed-loop catch below resolves is bound into a
|
||||
# LOCAL here, after the docstring, before any model code runs. This bootstrap
|
||||
# IS `__main__`, so `__main__.RuntimeError = ...` would otherwise rebind the
|
||||
# module global the `except RuntimeError` clause resolves at runtime, and a
|
||||
# closed-loop scheduling failure would then escape the catch, killing the
|
||||
# pump and stranding every later reply.
|
||||
_RuntimeError = RuntimeError
|
||||
|
||||
def complete(fut: asyncio.Future[Any], ok: bool, value: Any, message: Any) -> None:
|
||||
# Runs on the Future's own loop. `done()` re-checked here because
|
||||
# cancellation or a duplicate reply may have settled it between the pop
|
||||
@@ -1148,7 +1153,7 @@ async def _pump_replies(
|
||||
if ok:
|
||||
fut.set_result(value)
|
||||
else:
|
||||
fut.set_exception(_BindingRejection(str(message)))
|
||||
fut.set_exception(_BindingRejection(_str(message)))
|
||||
|
||||
while True:
|
||||
frame = await channel.read_frame_async()
|
||||
@@ -1161,7 +1166,7 @@ async def _pump_replies(
|
||||
if entry is None:
|
||||
continue
|
||||
loop, fut = entry
|
||||
ok = bool(frame.get("ok"))
|
||||
ok = _bool(frame.get("ok"))
|
||||
value = frame.get("value")
|
||||
message = frame.get("message")
|
||||
try:
|
||||
|
||||
@@ -3100,6 +3100,51 @@ describe('PythonCodeRuntime — hostile peer', () => {
|
||||
expect(result.value).toBe('released')
|
||||
}, 15_000)
|
||||
|
||||
it('keeps the reply pump alive when RuntimeError is rebound before the program runs', async () => {
|
||||
// `_pump_replies` catches a closed-loop scheduling failure with `except
|
||||
// _RuntimeError`. If that name were bound as a pump BODY local, it would be
|
||||
// captured at pump-start — but `_run` reaches the model's top-level
|
||||
// statements (which run before the pump's first step, since there is no
|
||||
// suspension point between `create_task` and `await __dsh_main__`) with the
|
||||
// rebind already applied, so `_RuntimeError` would capture the REBOUND class
|
||||
// and the closed-loop `RuntimeError` would escape, killing the pump. Binding
|
||||
// it as a DEF-TIME default argument captures the original before any model
|
||||
// code runs. This rebinds `__main__.RuntimeError` as the very first program
|
||||
// statement and drives the closed-loop worker pattern: the pump must survive
|
||||
// the dead-loop reply and deliver the later binding.
|
||||
let releaseSlow!: () => void
|
||||
const slowGate = new Promise<void>((resolve) => { releaseSlow = resolve })
|
||||
const { runtime } = await setup({ maxWallMs: 6_000 })
|
||||
const result = await runtime.run({
|
||||
program: [
|
||||
'import __main__',
|
||||
'__main__.RuntimeError = ValueError',
|
||||
'import asyncio, threading',
|
||||
'closed = threading.Event()',
|
||||
'def worker():',
|
||||
' async def body():',
|
||||
' try:',
|
||||
' await asyncio.wait_for(tools.slow({}), timeout=0.1)',
|
||||
' except asyncio.TimeoutError:',
|
||||
' pass',
|
||||
' asyncio.run(body())',
|
||||
' closed.set()',
|
||||
't = threading.Thread(target=worker)',
|
||||
't.start()',
|
||||
'while not closed.is_set():',
|
||||
' await asyncio.sleep(0.02)',
|
||||
'after = await tools.release({})',
|
||||
'return after',
|
||||
].join('\n'),
|
||||
bindings: tools({
|
||||
slow: async () => { await slowGate; return 'late' },
|
||||
release: async () => { releaseSlow(); await new Promise(resolve => setImmediate(resolve)); return 'released' },
|
||||
}),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('released')
|
||||
}, 15_000)
|
||||
|
||||
it('round-trips an exactly representable large integer through a binding echo', async () => {
|
||||
// The reply serializer must print BigInt digits for a beyond-safe
|
||||
// integral double: String(2**60) emits a rounded form, and the child
|
||||
|
||||
Reference in new issue
Block a user