mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
fix(code-runtime-python): bound reply and call backlogs, snapshot binding metadata, and compact the reply queue
Review findings on the CPython backend: a child that never reads fd 3 leaves
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) would grow until the wall
clock. sendReply now caps the pending backlog at MAX_PENDING_REPLIES and
settles the run as worker-exit past it, mirroring the frame cap; a child
flooding calls against a binding that never settles would otherwise bypass
that cap (pendingReplies grows only after the await), so the dispatcher
counts in-flight binding calls before dispatch and releases the slot in the
async body's finally, capping outstanding closures at the same bound. The
drain also compacts its consumed prefix (replyQueue.splice(0, head)) once
head reaches the bound, so a drain that stays alive without emptying cannot
grow the backing store linearly with cumulative throughput.
The completion-value meter counted lone surrogates with
_SURROGATE.findall(folded), materializing one single-character string per
surrogate: a surrogate-dense value near the budget (millions of surrogates,
each serializing to six bytes) allocated millions of objects before the meter
returned, defeating the meter's counting-without-building contract. The count
is now the length difference between folded and the without string the meter
already computes; a standalone equivalence check confirms it matches findall
across lone-high, lone-low, paired, astral, and mixed cases.
validateBindings read namespace.global/errorClass.name/memberNameProperty
several times and retained the original errorClass object for the boot
frame, whose JSON.stringify re-read it after validation: a stateful getter
could throw or change between the two stages, turning the seam-misuse
rejection into a worker-exit or injecting an unvalidated name. Each field is
now read once into a plain value and the bindings map stores a plain
{ name, memberNameProperty } copy, so validation and the boot frame see
identical values.
Regression tests: a hostile child floods 5000 sequential valid calls without
reading fd 3 and the run settles worker-exit with the reply-queue message
before maxWallMs; a 3,000,000-surrogate completion succeeds at an
18,000,002-byte budget and reports output-limit one byte under; a 5000-call
flood against a never-settling binding settles worker-exit with the
call-backlog message; getter-backed namespace metadata that throws or
changes on a second read boots and runs with each field read exactly once; a
two-wave flood whose replies exceed the writable high-water mark drives the
drain past the compaction bound mid-delivery and verifies all 1524 replies
arrive. README Known Limitations gains the reply-backlog and call-backlog
bounds (en/zh, pairing re-recorded); a new Agent Note registers the findings.
This commit is contained in:
@@ -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/experimental/code-runtime-python/README.md
|
||||
README.md: 11f845626fe7aa06e7725c70dcab764482eb9552
|
||||
README.zh.md: 13f60ebc191fb5ed82566ec145f526c34dfe0714
|
||||
README.md: 1009c150a320e23811bae01e989e82cefeb9b907
|
||||
README.zh.md: b3dd8803855b9f579f2d1cfdd155ff3691b4573b
|
||||
|
||||
@@ -119,6 +119,8 @@ These limits define what the package does and does not cover; they are current p
|
||||
- **`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.
|
||||
- **A child that stops reading its replies settles the run as a worker-exit once the reply backlog passes 1024 frames** — the host writes replies one at a time, waiting for `drain` when the pipe is full; a child that keeps sending calls without consuming replies would otherwise grow the retained backlog (and the binding results it pins) until the wall clock, so the backlog cap fails the run early. Binding results carry no seam-level byte cap, so this is a count bound, not a byte bound.
|
||||
- **A child that floods calls against a binding that never settles settles the run as a worker-exit once 1024 calls are in flight** — binding calls are counted before dispatch and released when the async body settles, so a binding whose promise never resolves would otherwise accumulate one async closure per call frame until the wall clock. Like the reply backlog, this is a count bound, not a byte bound.
|
||||
- **A combined log-and-value peak is not modelled by the load gate** — a model daemon thread that keeps writing while the completion value is metered and framed can add the two peaks in a way no gate admits or rejects; the run dies as `worker-exit`, containment holds, and only the failure classification is degraded.
|
||||
- **A 1-second dual-limit `ulimit -t 1` CPU overrun is reported as `worker-exit`, not a timeout** — when the host starts under a hard CPU limit equal to the soft and that limit is 1, `_clamped` cannot lower the soft, so the kernel SIGKILLs the busy loop and SIGXCPU is never delivered; containment holds, only the classification is degraded.
|
||||
- **No byte cap on intermediate binding values** — the implementation remains bounded by the lossless-JSON serialization cost and process memory, and a provider or executor may apply its own fetch cap.
|
||||
|
||||
@@ -117,6 +117,8 @@ kind: "package-reference"
|
||||
- **`run()` 是一次性的**——`logs` 只有在 `CodeRunResult` resolve 后才能获得;没有为运行中程序产生的输出提供流式日志或进度接口。
|
||||
- **运行之间不保留状态**——每次请求都在全新子进程中执行;持久 REPL 风格内核在某个后端带来自己的日志方案之前保持延期。
|
||||
- **原始长度超过 64 MiB 的 fd-3 帧会让本次运行以 worker-exit 结算**——`maxLogBytes`/`maxValueBytes` 在加载期被限制到同一解析器上限,因此诚实子进程的帧总能放得下;模型构造的超过 64 MiB 的 binding 实参(一个在 seam 层没有预算的值)会触发同一上限——这是该 OOM 防护的已接受残余。
|
||||
- **停止读取回复的子进程会在回复积压超过 1024 帧时以 worker-exit 结算运行**——宿主每次写一条回复,管道满时等待 `drain`;只持续发送调用而不消费回复的子进程会让保留的积压(及其钉住的 binding 结果)一直增长到墙钟,因此积压上限让运行提前失败。binding 结果在 seam 层没有字节上限,所以这是计数上限而非字节上限。
|
||||
- **向永不结算的 binding 洪泛调用的子进程会在 1024 个调用在途时以 worker-exit 结算运行**——binding 调用在分发前计数、异步体结算时释放,否则 promise 永不 resolve 的 binding 会让每个调用帧累积一个异步闭包直到墙钟。与回复积压一样,这是计数上限而非字节上限。
|
||||
- **组合日志与值的峰值不被加载门建模**——持续写入的模型 daemon 线程与完成值计量、分帧相加的峰值没有任何门会放行或拒绝;运行以 `worker-exit` 告终,隔离成立,只有失败分类降级。
|
||||
- **1 秒双限 `ulimit -t 1` CPU 超限被报告为 `worker-exit` 而非 timeout**——当宿主在一个与软限相等的硬 CPU 限下启动且该限为 1 时,`_clamped` 无法下调软限,内核在同一 tick SIGKILL 忙循环,SIGXCPU 永远不会送达;隔离成立,只有分类降级。
|
||||
- **中间 binding 值没有字节上限**——实现仍受无损 JSON 序列化成本与进程内存约束,提供方或执行器可能应用自己的获取上限。
|
||||
|
||||
@@ -1725,10 +1725,19 @@ def _json_str_cost(text: str) -> int:
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
folded = _SURROGATE_PAIR.sub(_combine_surrogate_pair, text)
|
||||
lone = len(_SURROGATE.findall(folded))
|
||||
# Remove the lone surrogates first, then count them as the length
|
||||
# difference: `_SURROGATE.findall(folded)` materialized one single-character
|
||||
# string PER surrogate, so a surrogate-dense value near the budget
|
||||
# (millions of lone surrogates, each serializing to six bytes) allocated
|
||||
# millions of objects before the meter returned -- an RLIMIT_AS death
|
||||
# surfacing as `exception` instead of the promised `output-limit`. After
|
||||
# pair-combining, every remaining surrogate is lone and exactly one code
|
||||
# point, so the removed length is the count, and the `without` string is
|
||||
# needed for the meter anyway.
|
||||
without = _SURROGATE.sub("", folded)
|
||||
lone = len(folded) - len(without)
|
||||
# Six ASCII bytes per lone surrogate; the remainder is ordinary text whose
|
||||
# own quotes are dropped here because the outer call adds them once.
|
||||
without = _SURROGATE.sub("", folded)
|
||||
return _json_string_cost(without.encode("utf-8")) + lone * 6
|
||||
|
||||
|
||||
|
||||
@@ -216,6 +216,19 @@ const FRAME_PARSE_CAP_BYTES = 64 * 1024 * 1024
|
||||
*/
|
||||
const MAX_PENDING_CHUNKS = 1024
|
||||
|
||||
/**
|
||||
* Replies the host retains before fd 3 accepts them. The drain loop writes one
|
||||
* reply per iteration and waits for `drain` when the pipe is full; a child
|
||||
* that never reads its replies (hostile or wedged) leaves the pipe full, so
|
||||
* every call frame it keeps sending adds a reply the drain cannot write, and
|
||||
* the backlog would grow without bound until the wall clock. 1024 keeps
|
||||
* legitimate concurrent gathers (measured queue depths reach 11) far below
|
||||
* the ceiling while bounding the hostile backlog; the run settles as a
|
||||
* worker-exit past it, like the frame cap settles an oversized frame. A
|
||||
* framing invariant, not a deployment choice.
|
||||
*/
|
||||
const MAX_PENDING_REPLIES = 1024
|
||||
|
||||
/**
|
||||
* Bytes a frame spends on its own JSON structure around a capped payload, used
|
||||
* to bound `maxLogBytes`/`maxValueBytes` against {@link FRAME_PARSE_CAP_BYTES}
|
||||
@@ -969,32 +982,46 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
injectedGlobals.add(name)
|
||||
}
|
||||
for (const namespace of request.bindings) {
|
||||
if (!IDENTIFIER.test(namespace.global) || RESERVED_NAMES.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-python: binding global ${JSON.stringify(namespace.global)} is not a usable Python identifier`)
|
||||
// Snapshot the caller-supplied fields into plain values ONCE. The
|
||||
// namespace and errorClass objects may expose `global`/`name`/
|
||||
// `memberNameProperty` through getters: validation reads each several
|
||||
// times, and the ORIGINAL errorClass object would otherwise be retained
|
||||
// for the boot frame, whose JSON.stringify re-reads it after validation.
|
||||
// A getter that changes or throws on a later read would turn the
|
||||
// seam-misuse rejection into a worker-exit (or inject a different name
|
||||
// than validation approved); reading each field once here and keeping
|
||||
// the plain copy makes validation and the boot frame agree.
|
||||
const global = namespace.global
|
||||
if (!IDENTIFIER.test(global) || RESERVED_NAMES.has(global)) {
|
||||
throw new Error(`dsh-code-runtime-python: binding global ${JSON.stringify(global)} is not a usable Python identifier`)
|
||||
}
|
||||
if (bindings.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-python: duplicate binding global ${JSON.stringify(namespace.global)}`)
|
||||
if (bindings.has(global)) {
|
||||
throw new Error(`dsh-code-runtime-python: duplicate binding global ${JSON.stringify(global)}`)
|
||||
}
|
||||
claimGlobal(namespace.global, 'binding global')
|
||||
claimGlobal(global, 'binding global')
|
||||
// The error class becomes a program global and its member property an
|
||||
// attribute name, so both face the Python identifier rules; the member
|
||||
// additionally must be assignable on a BaseException instance.
|
||||
const errorClass = namespace.errorClass
|
||||
let validatedErrorClass: CodeBindingErrorClass | undefined
|
||||
if (errorClass) {
|
||||
if (!IDENTIFIER.test(errorClass.name) || RESERVED_NAMES.has(errorClass.name)) {
|
||||
throw new Error(`dsh-code-runtime-python: errorClass.name ${JSON.stringify(errorClass.name)} is not a usable Python identifier`)
|
||||
const name = errorClass.name
|
||||
const memberNameProperty = errorClass.memberNameProperty
|
||||
if (!IDENTIFIER.test(name) || RESERVED_NAMES.has(name)) {
|
||||
throw new Error(`dsh-code-runtime-python: errorClass.name ${JSON.stringify(name)} is not a usable Python identifier`)
|
||||
}
|
||||
// Any non-empty own attribute name is settable via setattr (the
|
||||
// program reads exotic names like `tool-name` with getattr), matching
|
||||
// the seam contract and the worker backend — only the seam-excluded
|
||||
// and protocol-reserved members below are refused.
|
||||
if (errorClass.memberNameProperty.length === 0) {
|
||||
if (memberNameProperty.length === 0) {
|
||||
throw new Error('dsh-code-runtime-python: errorClass.memberNameProperty must be a non-empty attribute name')
|
||||
}
|
||||
if (EXCEPTION_RESERVED_MEMBERS.has(errorClass.memberNameProperty) || DUNDER.test(errorClass.memberNameProperty)) {
|
||||
throw new Error(`dsh-code-runtime-python: errorClass.memberNameProperty ${JSON.stringify(errorClass.memberNameProperty)} is a reserved error member and cannot be assigned`)
|
||||
if (EXCEPTION_RESERVED_MEMBERS.has(memberNameProperty) || DUNDER.test(memberNameProperty)) {
|
||||
throw new Error(`dsh-code-runtime-python: errorClass.memberNameProperty ${JSON.stringify(memberNameProperty)} is a reserved error member and cannot be assigned`)
|
||||
}
|
||||
claimGlobal(errorClass.name, 'errorClass.name')
|
||||
claimGlobal(name, 'errorClass.name')
|
||||
validatedErrorClass = { name, memberNameProperty }
|
||||
}
|
||||
// Snapshot the callables into a plain own-property record before the
|
||||
// child can dispatch. `namespace.functions` is caller-supplied, so it may
|
||||
@@ -1020,7 +1047,7 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
const fn = namespace.functions[name]
|
||||
if (typeof fn === 'function') functions[name] = fn
|
||||
}
|
||||
bindings.set(namespace.global, { functions, ...errorClass ? { errorClass } : {} })
|
||||
bindings.set(global, { functions, ...validatedErrorClass ? { errorClass: validatedErrorClass } : {} })
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
@@ -1736,6 +1763,18 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
sendReply({ type: 'reply', id: message.id, ok: false, message: capMessage(`unknown binding ${preview}`, cap) })
|
||||
return
|
||||
}
|
||||
// A binding that never settles (or resolves too slowly to keep up
|
||||
// with the child's call rate) must not let the flood accumulate one
|
||||
// async closure per frame until the wall clock: the reply cap only
|
||||
// counts resolved calls, so it never trips for in-flight ones.
|
||||
// Count the outstanding binding calls here, before dispatch, and
|
||||
// release the slot in the body's finally — bounding in-flight
|
||||
// closures to MAX_PENDING_REPLIES exactly like the reply backlog.
|
||||
if (pendingCalls >= MAX_PENDING_REPLIES) {
|
||||
finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
|
||||
return
|
||||
}
|
||||
pendingCalls += 1
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await fn(message.args)
|
||||
@@ -1773,6 +1812,13 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
if (settled) return
|
||||
/* oxlint-enable typescript/no-unnecessary-condition */
|
||||
sendReply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
|
||||
} finally {
|
||||
// Release the in-flight slot on every exit — reply written,
|
||||
// resolution rejected, or the run settling mid-wait (the
|
||||
// `settled` early returns above). Without this, a binding that
|
||||
// never resolves would leak its slot past the cap check and the
|
||||
// flood bound would erode.
|
||||
pendingCalls -= 1
|
||||
}
|
||||
})()
|
||||
return
|
||||
@@ -1800,6 +1846,19 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
// and the bindings themselves still run concurrently. Only the host's peak
|
||||
// memory and the flush timing change.
|
||||
const replyQueue: ReplyMessage[] = []
|
||||
// Replies queued but not yet written, tracked separately from
|
||||
// `replyQueue.length`: the drain loop clears consumed slots to `undefined`
|
||||
// but does not shrink the array until it finishes, so `length` counts
|
||||
// consumed frames too. The counter is what the cap in `sendReply` reads.
|
||||
let pendingReplies = 0
|
||||
// Binding calls dispatched but not yet settled (the async body below
|
||||
// still awaits the binding's promise). The reply backlog cap only counts
|
||||
// RESOLVED calls — `pendingReplies` grows after the await — so a child
|
||||
// flooding calls against a binding that never settles would accumulate
|
||||
// one async closure per frame until the wall clock without tripping it.
|
||||
// Counted here before dispatch and released in the body's finally, the
|
||||
// in-flight closures are bounded to the same MAX_PENDING_REPLIES.
|
||||
let pendingCalls = 0
|
||||
let draining = false
|
||||
// Resolve when fd 3 can take another frame, OR when it is gone: a pipe
|
||||
// destroyed under the drain (child exited, close-deadline teardown) never
|
||||
@@ -1847,6 +1906,18 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
const payload = replyQueue[head] as ReplyMessage
|
||||
replyQueue[head] = undefined as unknown as ReplyMessage
|
||||
head += 1
|
||||
pendingReplies -= 1
|
||||
// Compact the consumed prefix once it reaches the backlog bound:
|
||||
// the array never shrinks until the drain finishes, and a child
|
||||
// that reads replies just fast enough to keep the drain alive but
|
||||
// never empty would otherwise grow the backing store linearly with
|
||||
// cumulative throughput (consumed slots are undefined, but `length`
|
||||
// keeps counting them). The splice is O(head) once per
|
||||
// MAX_PENDING_REPLIES consumed frames — amortized O(1) per reply.
|
||||
if (head >= MAX_PENDING_REPLIES) {
|
||||
replyQueue.splice(0, head)
|
||||
head = 0
|
||||
}
|
||||
// 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.
|
||||
@@ -1859,12 +1930,24 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
// the child died. The close path settles the run either way.
|
||||
} finally {
|
||||
draining = false
|
||||
pendingReplies = 0
|
||||
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
|
||||
// A child that stops reading fd 3 leaves the drain loop blocked on
|
||||
// `drain` forever while its call frames keep resolving into replies:
|
||||
// the backlog would grow without bound until the wall clock, pinning
|
||||
// every binding result the child provokes. Cap the retained backlog and
|
||||
// settle the run as a worker-exit, the same hostile-peer bound the
|
||||
// frame cap applies to inbound bytes.
|
||||
if (pendingReplies >= MAX_PENDING_REPLIES) {
|
||||
finish({ error: { kind: 'worker-exit', message: `reply queue exceeded ${MAX_PENDING_REPLIES} pending frames on fd 3 (the child stopped consuming its replies)` } })
|
||||
return
|
||||
}
|
||||
pendingReplies += 1
|
||||
replyQueue.push(payload)
|
||||
void drainReplies()
|
||||
}
|
||||
|
||||
@@ -2491,6 +2491,64 @@ describe('PythonCodeRuntime — programs and bindings', () => {
|
||||
expect(result.value).toBe('ToolCallError:fail')
|
||||
}, 15_000)
|
||||
|
||||
it('runs when errorClass metadata is exposed through one-read getters', async () => {
|
||||
// Validation reads errorClass.name and errorClass.memberNameProperty, and
|
||||
// the ORIGINAL object used to ride along to the boot frame, whose
|
||||
// JSON.stringify re-read it after validation: a getter that throws or
|
||||
// changes on a second read turned the seam-misuse rejection into a
|
||||
// worker-exit (or injected a different name than validation approved).
|
||||
// The snapshot reads each field exactly once into a plain copy, so a
|
||||
// getter that only tolerates one read must boot and run cleanly.
|
||||
let nameReads = 0
|
||||
let memberReads = 0
|
||||
const errorClass = {
|
||||
get name(): string {
|
||||
nameReads += 1
|
||||
if (nameReads > 1) throw new Error(`errorClass.name read ${nameReads} times`)
|
||||
return 'ToolCallError'
|
||||
},
|
||||
get memberNameProperty(): string {
|
||||
memberReads += 1
|
||||
if (memberReads > 1) throw new Error(`errorClass.memberNameProperty read ${memberReads} times`)
|
||||
return 'toolName'
|
||||
},
|
||||
}
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'return "ok"',
|
||||
bindings: [{ global: 'tools', functions: {}, errorClass }],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('ok')
|
||||
expect(nameReads).toBe(1)
|
||||
expect(memberReads).toBe(1)
|
||||
}, 15_000)
|
||||
|
||||
it('runs when the binding global is exposed through a one-read getter', async () => {
|
||||
// Validation reads namespace.global several times (identifier check, map
|
||||
// key, claim, boot frame), and the map key came from a fresh read each
|
||||
// time: a getter returning a different name on a later read injected a
|
||||
// global validation never approved, and the program referencing the
|
||||
// approved name died with NameError. Snapshotting reads it exactly once,
|
||||
// so the child must receive the name the program was written against.
|
||||
let globalReads = 0
|
||||
const namespace = {
|
||||
get global(): string {
|
||||
globalReads += 1
|
||||
return globalReads === 1 ? 'tools' : 'evil'
|
||||
},
|
||||
functions: { echo: async (args: unknown) => args as CodeJsonValue },
|
||||
}
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'return await tools.echo(41)',
|
||||
bindings: [namespace],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe(41)
|
||||
expect(globalReads).toBe(1)
|
||||
}, 15_000)
|
||||
|
||||
it('rejects an errorClass name colliding with its namespace global at the seam', async () => {
|
||||
const { runtime } = await setup()
|
||||
await expect(runtime.run({
|
||||
@@ -3812,6 +3870,27 @@ describe('PythonCodeRuntime — hostile peer', () => {
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
})
|
||||
|
||||
it('meters a surrogate-dense completion by counting, not by materializing a match list', async () => {
|
||||
// `_json_str_cost` counted lone surrogates with `_SURROGATE.findall`,
|
||||
// which materializes one single-character string PER surrogate: a
|
||||
// surrogate-dense value near the budget (each surrogate serializes to six
|
||||
// bytes, so a budget-sized value holds millions of them) would allocate
|
||||
// millions of objects before the meter returned — an O(N)-objects spike
|
||||
// that defeats the meter's documented contract of counting without
|
||||
// building. The count is now a length difference over the removal `sub`
|
||||
// already performs. Three million lone surrogates pin the boundary at
|
||||
// scale: 18,000,002 serialized bytes succeed at an 18,000,002 budget and
|
||||
// report output-limit one byte under, proving the meter counts every
|
||||
// surrogate exactly rather than dropping or over-charging any.
|
||||
const { runtime } = await setup({ maxValueBytes: 18_000_002 })
|
||||
const ok = await runtime.run({ program: 'return "\\ud800" * 3000000', bindings: [] })
|
||||
expect(ok.error).toBeUndefined()
|
||||
expect(ok.value).toBe('\ud800'.repeat(3_000_000))
|
||||
const over = await setup({ maxValueBytes: 18_000_001 })
|
||||
const result = await over.runtime.run({ program: 'return "\\ud800" * 3000000', bindings: [] })
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
}, 60_000)
|
||||
|
||||
it('passes a lone-surrogate binding argument through instead of failing the call', async () => {
|
||||
// The argument validator shared the same over-narrow rejection; a host
|
||||
// binding must receive the code unit the program passed.
|
||||
@@ -5071,6 +5150,114 @@ describe('PythonCodeRuntime — hostile peer', () => {
|
||||
expect(result.value).toBe('done')
|
||||
}, 30_000)
|
||||
|
||||
it('caps the pending reply backlog when a child floods calls without reading its replies', async () => {
|
||||
// drainReplies writes one reply at a time and waits for `drain` when fd 3's
|
||||
// buffer is full. A child that never reads its replies (it only writes
|
||||
// call frames, never draining the reply side) leaves the pipe full, so
|
||||
// every call frame it keeps sending resolves a binding and adds a reply the
|
||||
// drain cannot write: without a bound, the backlog grows until the wall
|
||||
// clock, pinning each binding result in host memory. The cap settles the
|
||||
// run as worker-exit instead, mirroring the frame cap's treatment of an
|
||||
// oversized frame. The child floods 5000 sequential valid calls and never
|
||||
// reads fd 3 (its reply pump is starved by the synchronous write loop and
|
||||
// the blocking sleep); the pipe buffer absorbs ~1600 tiny replies, so the
|
||||
// pending backlog crosses MAX_PENDING_REPLIES long before maxWallMs, and
|
||||
// the run must settle worker-exit with the reply-queue message, not a
|
||||
// wall-clock timeout.
|
||||
const { runtime } = await setup({ maxWallMs: 30_000 })
|
||||
const result = await runtime.run({
|
||||
program: [
|
||||
'import os, time',
|
||||
'frame = b\'{"type":"call","id":%d,"global":"tools","name":"echo","args":{}}\\n\'',
|
||||
'for i in range(5000):',
|
||||
' view = memoryview(frame % i)',
|
||||
' while view:',
|
||||
' view = view[os.write(3, view):]',
|
||||
// Keep the child alive without reading fd 3: the run must settle via
|
||||
// the reply-backlog cap, not by the child finishing or exiting.
|
||||
'time.sleep(30)',
|
||||
'return "unreachable"',
|
||||
].join('\n'),
|
||||
bindings: [{ global: 'tools', functions: { echo: async (args: unknown) => args as CodeJsonValue } }],
|
||||
})
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
expect(result.error?.message).toContain('reply queue exceeded')
|
||||
}, 30_000)
|
||||
|
||||
it('caps the outstanding binding-call backlog when a child floods calls against a binding that never settles', async () => {
|
||||
// The reply backlog cap only counts RESOLVED calls (`pendingReplies` grows
|
||||
// after the await), so a child flooding calls against a binding whose
|
||||
// promise never settles would accumulate one async closure per frame until
|
||||
// the wall clock without tripping it. The outstanding-call counter bounds
|
||||
// the in-flight closures to MAX_PENDING_REPLIES and settles the run as
|
||||
// worker-exit, mirroring the reply cap. The binding below never resolves,
|
||||
// so no reply is ever produced; the flood of 5000 sequential calls must
|
||||
// cross the in-flight bound long before maxWallMs.
|
||||
const { runtime } = await setup({ maxWallMs: 30_000 })
|
||||
const result = await runtime.run({
|
||||
program: [
|
||||
'import os, time',
|
||||
'frame = b\'{"type":"call","id":%d,"global":"tools","name":"hang","args":{}}\\n\'',
|
||||
'for i in range(5000):',
|
||||
' view = memoryview(frame % i)',
|
||||
' while view:',
|
||||
' view = view[os.write(3, view):]',
|
||||
'time.sleep(30)',
|
||||
'return "unreachable"',
|
||||
].join('\n'),
|
||||
bindings: [{ global: 'tools', functions: { hang: async () => await new Promise<never>(() => {}) } }],
|
||||
})
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
expect(result.error?.message).toContain('call backlog exceeded')
|
||||
}, 30_000)
|
||||
|
||||
it('compacts the reply queue mid-drain without dropping pending frames', async () => {
|
||||
// A reply larger than the writable high-water mark makes the FIRST write
|
||||
// return false, suspending the drain loop while the child's synchronous
|
||||
// flood starves the reply pump; the frames queued behind it push the
|
||||
// drain's consumed head past MAX_PENDING_REPLIES, so the resumed drain
|
||||
// compacts the queue mid-run. The child reads fd 3 itself (blocking the
|
||||
// asyncio pump, so its reads cannot race the host's pushes) and sends a
|
||||
// second wave of calls AFTER reading part of the first wave's replies —
|
||||
// those replies are still pending when the drain's head crosses the
|
||||
// compaction bound, so a compaction that dropped pending frames would
|
||||
// leave the child's reply count short and the read loop spinning to the
|
||||
// wall clock. The second wave is sent mid-delivery (not with the first
|
||||
// flood): pushing it earlier would trip the 1024-pending reply cap
|
||||
// before the drain resumed.
|
||||
const { runtime } = await setup({ maxWallMs: 30_000 })
|
||||
const result = await runtime.run({
|
||||
program: [
|
||||
'import os, time',
|
||||
'frame = b\'{"type":"call","id":%d,"global":"tools","name":"big","args":{}}\\n\'',
|
||||
'for i in range(1024):',
|
||||
' view = memoryview(frame % i)',
|
||||
' while view:',
|
||||
' view = view[os.write(3, view):]',
|
||||
'time.sleep(0.5)',
|
||||
'total = b""',
|
||||
'while total.count(b"\\n") < 500:',
|
||||
' chunk = os.read(3, 65536)',
|
||||
' if not chunk:',
|
||||
' break',
|
||||
' total += chunk',
|
||||
'for i in range(500):',
|
||||
' view = memoryview(frame % (1024 + i))',
|
||||
' while view:',
|
||||
' view = view[os.write(3, view):]',
|
||||
'while total.count(b"\\n") < 1524:',
|
||||
' chunk = os.read(3, 65536)',
|
||||
' if not chunk:',
|
||||
' break',
|
||||
' total += chunk',
|
||||
'return "done"',
|
||||
].join('\n'),
|
||||
bindings: [{ global: 'tools', functions: { big: async () => 'x'.repeat(65 * 1024) } }],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('done')
|
||||
}, 30_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
|
||||
|
||||
Reference in New Issue
Block a user