fix(code-runtime-python): make the float encoder context-independent; correct the binding-reply README entry

The review's critical: Decimal(repr(value)).normalize() read the process-global
decimal context, so a legitimate program setting getcontext().prec = 2 silently
rounded the completion value's digits and traps[Inexact] = True made the encode
raise, misclassifying a successful run as an exception. A fixed module-level
Context(prec=28) makes the spelling decision context-independent; a regression
case mutates both context knobs and asserts the float round-trips exactly.

The binding-reply README entry now states the fact (no seam-level cap;
maxValueBytes meters only the done frame; a wide reply is rebuilt and encoded
whole, bounded by process memory), matching the earlier reviewer wording.
This commit is contained in:
Chinesezjc
2026-08-31 15:02:38 +08:00
committed by Tianyi Cui
parent 666ff2855e
commit 35de0682c7
5 changed files with 35 additions and 7 deletions
@@ -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: 557ff7f58b70fcbc5cd9d7901c0c6b66d123c0eb
README.zh.md: d98fdcc52a05541c66e050445ebc04264686451b
README.md: c6ab48b5ccd4e386f9ad416dee64b090500594f6
README.zh.md: 637de914212be9b7e6a3b63e443d7252c092acf2
@@ -113,7 +113,7 @@ These limits define what the package does and does not cover; they are current p
- **The cross-language guard covers the executed surfaces and the frame field shapes, not the field types** — the mirror e2e compares required/optional field sets, not that `cpuSeconds` is an `int` on both sides; a type-level drift is caught by review plus the backend's real-subprocess suite.
- **A descendant that escapes the child's process group with `setsid()` is not reaped by the group teardown** — `kill(-pid)` cannot reach it; the run still settles on the value the done frame decided, and the close-deadline backstop forces settlement if the orphan holds the pipes open, but the orphan itself outlives the fiber until it exits on its own.
- **A `log` frame that arrives after settlement is dropped** — once the run has settled, host-side capture is closed; a late fd-3 `log` frame (from a thread that outlived the done frame) is discarded rather than appended to `logs`.
- **The host-side memory bound for a binding VALUE is the frame parse cap, not a seam budget** — a binding reply's value is rebuilt host-side and billed against `maxValueBytes`; an intermediate binding value has no seam-level byte cap and is bounded by the lossless-JSON serialization cost and process memory (see the binding-argument entry).
- **A binding REPLY value has no seam-level byte or depth cap** — `maxValueBytes` meters only the done frame's completion value; a wide binding reply is rebuilt host-side (`snapshotJsonValue` traversal) and encoded whole, bounded on both sides only by process memory (like a binding argument, which has no child-side budget either).
- **`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.
@@ -113,7 +113,7 @@ kind: "package-reference"
- **跨语言 guard 覆盖执行的表面与帧字段形状,而非字段类型**——mirror e2e 比较必填/可选字段集,而非 `cpuSeconds` 在两侧是否都是 `int`;类型级漂移由评审加后端的真实子进程套件捕获。
- **以 `setsid()` 逃出子进程组后代不被组拆卸回收**——`kill(-pid)` 够不到它;运行仍按 done 帧决定的值结算,若该孤儿持有管道,close 截止兜底会强制结算,但孤儿本身在自行退出前一直存活到 fiber 之外。
- **结算后到达的 `log` 帧被丢弃**——运行一旦结算,宿主侧捕获即关闭;迟到的 fd-3 `log` 帧(来自比 done 帧存活更久的线程)会被丢弃,而不是追加到 `logs`
- **binding 值的宿主侧内存界是帧解析上限,而非 seam 预算**——binding 回复的值在宿主侧重建并按 `maxValueBytes` 计费;中间 binding 值没有 seam 级字节上限,受无损 JSON 序列化成本与进程内存约束(见 binding 实参条目)。
- **binding 回复值没有 seam 级字节或深度上限**——`maxValueBytes` 只计量 done 帧的完成值;宽 binding 回复在宿主侧重建(`snapshotJsonValue` 遍历)并整帧编码,两侧都只受进程内存约束(与没有子进程侧预算的 binding 实参一样)。
- **`run()` 是一次性的**——`logs` 只有在 `CodeRunResult` resolve 后才能获得;没有为运行中程序产生的输出提供流式日志或进度接口。
- **运行之间不保留状态**——每次请求都在全新子进程中执行;持久 REPL 风格内核在某个后端带来自己的日志方案之前保持延期。
- **原始长度超过 64 MiB 的 fd-3 帧会让本次运行以 worker-exit 结算**——`maxLogBytes`/`maxValueBytes` 在加载期被限制到同一解析器上限,因此诚实子进程的帧总能放得下;模型构造的超过 64 MiB 的 binding 实参(一个在 seam 层没有预算的值)会触发同一上限——这是该 OOM 防护的已接受残余。
@@ -25,7 +25,15 @@ import signal
import sys
import threading
import traceback
from decimal import Decimal
from decimal import Context, Decimal
# The float encoder must NOT depend on the process-global decimal context: a
# legitimate program may set `getcontext().prec = 2` (silently rounding the
# completion value's digits) or `traps[Inexact] = True` (making the encode
# raise, misclassifying a successful run as an exception). A fixed context with
# prec=28 (more than the 17 significant digits a double needs) makes the
# normalize() spelling decision context-independent.
_FLOAT_CONTEXT = Context(prec=28)
from pathlib import Path
from typing import Any
@@ -1727,7 +1735,7 @@ def _dump_float(value: float) -> str:
if value.is_integer() and value > float(2**53 - 1):
# The host's BigInt branch: exact digits, not shortest-round-trip.
return str(int(value))
parts = Decimal(repr(value)).normalize().as_tuple()
parts = Decimal(repr(value)).normalize(context=_FLOAT_CONTEXT).as_tuple()
digits = "".join(str(digit) for digit in parts.digits)
k = len(digits)
n = parts.exponent + k
@@ -1843,6 +1843,27 @@ describe('PythonCodeRuntime — programs and bindings', () => {
expect(result.error?.kind).not.toBe('worker-exit')
}, 90_000)
it('keeps a float completion exact when the program mutates the decimal context', async () => {
// The float encoder's Decimal(repr(value)).normalize() used the process
// GLOBAL decimal context: a legitimate program setting
// `getcontext().prec = 2` silently rounded the completion value's digits,
// and `traps[Inexact] = True` made the encode raise, misclassifying a
// successful run as an exception. A fixed module-level Context(prec=28)
// makes the spelling decision context-independent.
const { runtime } = await setup()
const result = await runtime.run({
program: [
'from decimal import getcontext',
'getcontext().prec = 2',
'getcontext().traps[__import__("decimal").Inexact] = True',
'return 1.2345678901234567',
].join('\n'),
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe(1.2345678901234567)
}, 15_000)
it('bounds an over-cap exception-group nesting on the copy', async () => {
// Exception groups link through `exceptions`, not the cause/context
// dunders, so the cap has to count that edge too — otherwise a deeply
@@ -2952,7 +2973,6 @@ describe('PythonCodeRuntime — budgets, termination, disposal', () => {
expect(still).toBe(true)
}, 20_000)
it('dispose awaits reaping of a same-group survivor from a completed run', async () => {
// The quiescence contract also holds for a run that ALREADY resolved: the run
// stays tracked in `live` until its process group is reaped, so a `dispose()`