mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
fix(code-runtime-python): flush logs before framing the completion value
The load gate bounds maxLogBytes and maxValueBytes independently against the address space, but the child framed the completion value (materializing its escaped form to meter it, then encoding the frame) while a newline-free log tail still sat unflushed in _pending. Those two peaks added, so two budgets each admitted alone could together breach RLIMIT_AS and die as worker-exit instead of settling. The success path now flushes both log streams before _done_with_value runs; the trailing flush stays for the exception path and is an idempotent no-op after a successful settle. A combined-peak regression test (32 MiB each against 512 MiB) asserts the over-budget value reports output-limit rather than OOMing. Also corrects the worst-case-multiple JSDoc and Agent Note: after 1088d6f03d made flush_line drop pending before its push, the settlement-flush path holds two copies, not three, so the newline path is the sole 12x worst case. The reorder is recorded as a called-out untested fix (the 12x gate already admits only configs safe under both flush orders).
This commit is contained in:
@@ -890,6 +890,16 @@ async def _run(channel: ProtocolChannel) -> None:
|
||||
exec(code, ns) # noqa: S102 -- defines __dsh_main__; executing model code is the point
|
||||
value = await ns["__dsh_main__"]()
|
||||
die_if_cpu_exhausted(cpu_seconds)
|
||||
# Flush the log buffers BEFORE metering and framing the completion value.
|
||||
# `_done_with_value` materializes the value's escaped JSON form to meter
|
||||
# it, and `send_done` encodes the frame — several copies of a near-budget
|
||||
# value live at once (see OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE).
|
||||
# Any unflushed log pending would add its own bytes to that peak, so a
|
||||
# `maxLogBytes` and a `maxValueBytes` each admitted alone by the load gate
|
||||
# could together breach RLIMIT_AS. Flushing first frees the log pending so
|
||||
# the value frame's peak stands alone against the address space.
|
||||
flush_out()
|
||||
flush_err()
|
||||
done = _done_with_value(value, max_value_bytes)
|
||||
except BaseException as exc: # noqa: BLE001 -- report every failure to host
|
||||
done = {
|
||||
@@ -911,7 +921,9 @@ async def _run(channel: ProtocolChannel) -> None:
|
||||
|
||||
# Flush any print output not terminated by a newline (a traceback always
|
||||
# ends in one, but `print(x, end="")` or a bare write may not), so the
|
||||
# final partial line is not silently dropped.
|
||||
# final partial line is not silently dropped. The success path already
|
||||
# flushed before framing the value; this is an idempotent no-op there and
|
||||
# the flush the exception path needs.
|
||||
flush_out()
|
||||
flush_err()
|
||||
reply_task.cancel()
|
||||
|
||||
@@ -240,13 +240,14 @@ const CLOSE_REAP_MARGIN_MS = 2_000
|
||||
* as a multiple of the budget. The child's ledgers trigger on CHARACTER count
|
||||
* against a serialized-BYTE budget, and an astral character is one character but
|
||||
* four bytes of CPython `str` storage and four UTF-8 bytes — so a budget's worth
|
||||
* of astral characters is ~4x the budget in each string that holds it. THREE
|
||||
* such copies are live at the peak: on the newline path a single
|
||||
* `sys.stdout.write(line + "\n")` holds the caller's `text` argument (alive for
|
||||
* of astral characters is ~4x the budget in each string that holds it. The
|
||||
* heaviest path holds THREE such copies at once: a single
|
||||
* `sys.stdout.write(line + "\n")` keeps the caller's `text` argument (alive for
|
||||
* the whole `write` call, ~4x), the line slice `text[pos:newline]` handed to
|
||||
* `LogBuffer.push` (~4x), and the `text.encode("utf-8")` copy `_push_locked`
|
||||
* takes to charge and ship it (~4x); the settlement `flush_line` path holds the
|
||||
* pending chunks, their `"".join(...)`, and that same encode copy. Twelve covers
|
||||
* takes to charge and ship it (~4x). The settlement `flush_line` path holds only
|
||||
* two (its `"".join(...)` and that encode copy — it drops the pending chunks
|
||||
* before pushing), so the newline path is the binding worst case. Twelve covers
|
||||
* those three simultaneous ~4x copies. The interpreter baseline is NOT in this
|
||||
* multiple — it is reserved separately as {@link INTERPRETER_BASELINE_BYTES} —
|
||||
* because it is a fixed cost, not one that scales with the budget. Used to bound
|
||||
|
||||
@@ -3649,6 +3649,42 @@ describe('PythonCodeRuntime — hostile peer', () => {
|
||||
expect(result.logs.some(line => line.includes('log capture truncated'))).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('flushes logs before framing the value so their peaks do not add against RLIMIT_AS', async () => {
|
||||
// The load gate bounds maxLogBytes and maxValueBytes INDEPENDENTLY against the
|
||||
// address space, each at the 12x worst case. But the child framed the
|
||||
// completion value (materializing its escaped form to meter it, then encoding
|
||||
// the frame) while a newline-free log tail still sat unflushed in _pending.
|
||||
// Those two peaks added: two budgets each admitted alone could together breach
|
||||
// RLIMIT_AS, dying as worker-exit instead of settling. The flush now runs
|
||||
// before the value is framed, so the log pending is freed first.
|
||||
//
|
||||
// Config: 32 MiB each against 512 MiB (each 32*12 = 384 MiB < 448 MiB
|
||||
// budgetable, so both load). The program writes ~33M astral chars with no
|
||||
// newline (buffered ~132 MB, under the char-count flush trigger) then returns
|
||||
// ~33M astral chars — a ~132 MB serialized value that is itself OVER the 32 MiB
|
||||
// maxValueBytes, so the correct outcome is `output-limit`. Pre-fix the
|
||||
// unflushed 132 MB plus the value's build-and-encode (~396 MB) exceeded 512 MiB
|
||||
// and OOM'd (reported as exception/worker-exit); flushing first lets the value
|
||||
// check complete (~460 MB alone) and report output-limit. On Darwin (no
|
||||
// RLIMIT_AS) the value is over budget too, so output-limit holds either way;
|
||||
// the OOM the reorder prevents is the Linux-only failure.
|
||||
const { runtime } = await setup({
|
||||
maxLogBytes: 32 * 1024 * 1024,
|
||||
maxValueBytes: 32 * 1024 * 1024,
|
||||
addressSpaceMb: 512,
|
||||
maxWallMs: 20_000,
|
||||
})
|
||||
const result = await runtime.run({
|
||||
program: [
|
||||
'import sys',
|
||||
'sys.stdout.write("\\U0001F600" * 33_000_000)',
|
||||
'return "\\U0001F600" * 33_000_000',
|
||||
].join('\n'),
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
}, 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