mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
fix(code-runtime-python): close the log-fragment OOM, CPU classification, and done-send transitive-dependency findings
Addresses the bot's v16 review on the settlement-path code: - critical: _LogStream._pending now seals the fragment list past a chunk cap (like the host captureStray seal), so a newline-free single-character drip no longer accumulates one list slot per write and OOMs on its own accounting. - _clamped lowers a soft==hard result by one unit (when hard >= 2) so a dual-limit ulimit -t leaves SIGXCPU a window to fire and a definite CPU overrun is reported as a timeout, not a 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 import-time captured os.write, so a settled exception verdict is never downgraded to worker-exit. - drainReplies clears the consumed replyQueue slot so a wide written payload is released immediately, bounding host memory to the current backlog under sustained fd-3 backpressure. Tests added for each (fragment cap drip, dual-limit CPU overrun, transitive-name rebind done frame).
This commit is contained in:
@@ -511,6 +511,37 @@ describe('PythonCodeRuntime — inherited resource limits', () => {
|
||||
expect(result.value).toBe(5)
|
||||
}, 15_000)
|
||||
|
||||
it('reports a CPU overrun under a dual-limit ulimit as a timeout, not a worker-exit', async () => {
|
||||
// `ulimit -t N` sets BOTH the soft and hard CPU limit to N. 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 the overrun would be misreported as a
|
||||
// `worker-exit` instead of a timeout. `_clamped` now lowers a clamped
|
||||
// soft==hard result by one unit (when hard >= 2), so SIGXCPU fires at the
|
||||
// softer limit and the run reports a timeout. This drives a busy loop past
|
||||
// the inherited cap and asserts the run classifies as a timeout.
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-rlimit-dual-'))
|
||||
const wrapper = join(dir, 'python3-dual-capped')
|
||||
// Both soft and hard CPU 1 s; configured cpuSeconds 30 s.
|
||||
await writeFile(wrapper, '#!/bin/sh\nulimit -t 1\nexec python3 "$@"\n', { mode: 0o755 })
|
||||
const { runtime } = await setup({ pythonBin: wrapper, cpuSeconds: 30, maxWallMs: 12_000 })
|
||||
const result = await runtime.run({
|
||||
program: [
|
||||
'import signal',
|
||||
// Trap SIGXCPU; with the soft limit one unit below the hard it fires at
|
||||
// 1 s and the run is classified as a CPU timeout, not a worker-exit.
|
||||
'signal.signal(signal.SIGXCPU, lambda *a: None)',
|
||||
'end = 2.5',
|
||||
'while True:',
|
||||
' pass',
|
||||
'return "unreachable"',
|
||||
].join('\n'),
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error?.kind).toBe('timeout')
|
||||
expect(result.error?.message).toContain('SIGXCPU')
|
||||
}, 15_000)
|
||||
|
||||
it('rechecks CPU at settlement against the effective inherited soft limit', async () => {
|
||||
// The settlement-time CPU recheck must compare against the EFFECTIVE soft
|
||||
// limit (`_clamped` may have lowered it to a stricter inherited value), not
|
||||
@@ -793,6 +824,31 @@ describe('PythonCodeRuntime — programs and bindings', () => {
|
||||
expect(result.logs.join('').length).toBeLessThan(4096)
|
||||
})
|
||||
|
||||
it('bounds a newline-free single-character Python write drip by the fragment cap, not OOM', async () => {
|
||||
// The child-side `_LogStream` buffers one fragment per `write` (so
|
||||
// `print("x", end="")` does not concatenate quadratically). A newline-free
|
||||
// drip of one character per call past a large `maxLogBytes` would otherwise
|
||||
// accumulate one list slot (and one str object) per call — 25 M calls =
|
||||
// ~25 M slots, which OOMs the host on its own accounting before the byte
|
||||
// budget is reached. The stream seals the fragment list past
|
||||
// `_PENDING_MAX_CHUNKS` into one joined block (character count unchanged),
|
||||
// bounding the live fragment count exactly as the host-side `captureStray`
|
||||
// seal does. This drives well past the cap and asserts the run still
|
||||
// completes with a truncation marker rather than a MemoryError.
|
||||
const { runtime } = await setup({ maxLogBytes: 4096 })
|
||||
const result = await runtime.run({
|
||||
program: [
|
||||
'import sys',
|
||||
'for _ in range(200_000):',
|
||||
' sys.stdout.write("x")',
|
||||
'return None',
|
||||
].join('\n'),
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs.at(-1)).toBe(logTruncationMarker(4096))
|
||||
})
|
||||
|
||||
it('bounds a control-char-dense native residual by serialized cost, not raw length', async () => {
|
||||
// A newline-free NUL flood passes the cheap `length + 3` lower bound at a
|
||||
// raw length well under the budget, but each NUL serializes to ` | ||||