mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
fix(code-runtime-python): size the output-budget/address-space gate by worst-case Unicode and gate both budgets
The load-time addressSpaceMb gate used a 1/8 fraction derived for ASCII, but the child ledgers trigger on character count against a serialized-byte budget: an astral character is one character yet ~4 bytes stored and ~4 encoded, live at once, so the true worst-case peak is ~8x the budget, not ~2x. Replace the fraction with an explicit OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE (8) and a strict `>`, and gate maxValueBytes the same way as maxLogBytes — the value path builds and encodes a near-budget completion under the same RLIMIT_AS, so the incompatible pair was previously admitted there too. Slice the newline branch's unterminated tail to a budget-sized prefix: it buffered the whole text[pos:] before the flush trigger could bound it, so an early newline plus a huge tail made a second full copy of the model's string — an RLIMIT_AS death the config gate cannot cover since the tail can far exceed maxLogBytes. Disclose the cross-field constraint in the maxLogBytes/maxValueBytes/addressSpaceMb JSDoc (regenerating config-catalog); refresh the note's stale Buffer.byteLength(JSON.stringify) reference; reconcile the arrival-order rebuttal with the seam's "in order" logs JSDoc (within-stream, cross-stream best-effort). Extend the load-rejection test to both budgets and add a tail-copy regression; sync the zh pair.
This commit is contained in:
@@ -248,7 +248,17 @@ class _LogStream(io.TextIOBase):
|
||||
pos = newline + 1
|
||||
if pos < length:
|
||||
if self._logs.remaining > 0:
|
||||
tail = text[pos:]
|
||||
# Buffer only a budget-sized PREFIX of the tail, not the whole
|
||||
# `text[pos:]`: an early newline followed by a huge unterminated
|
||||
# tail (`"\n" + "A" * 30 MiB`) would otherwise copy the entire
|
||||
# tail into `_pending` here — a second full copy of the model's
|
||||
# own string, the RLIMIT_AS death this path exists to avoid —
|
||||
# before the newline-free trigger below could bound it. Anything
|
||||
# past `remaining` characters cannot be admitted (the char count
|
||||
# is a lower bound on the serialized cost), so a
|
||||
# `remaining + 4`-character prefix is all that can ever survive;
|
||||
# the flush trigger below rejects it and emits the marker.
|
||||
tail = text[pos:pos + self._logs.remaining + 4]
|
||||
self._pending.append(tail)
|
||||
self._pending_chars = len(tail)
|
||||
else:
|
||||
|
||||
@@ -56,12 +56,26 @@ export interface Config {
|
||||
* RLIMIT_AS in mebibytes; caps address space so a runaway allocation fails
|
||||
* cleanly. Not applied on Darwin, where the dyld shared cache mapped into
|
||||
* every process at exec exceeds any practical cap and the kernel rejects
|
||||
* the call; `cpuSeconds` and `maxWallMs` still bound the run there.
|
||||
* the call; `cpuSeconds` and `maxWallMs` still bound the run there. Bounds
|
||||
* `maxLogBytes`/`maxValueBytes` at load on EVERY platform (not just where the
|
||||
* limit is enforced): each budget times a worst-case Unicode expansion must
|
||||
* fit this byte count, so a near-budget output cannot breach the address space
|
||||
* during the child's build-and-encode.
|
||||
*/
|
||||
addressSpaceMb?: number
|
||||
/** Shared byte budget for captured log text (host-side ledger). */
|
||||
/**
|
||||
* Shared byte budget for captured log text (host-side ledger). Bounded at load
|
||||
* against `addressSpaceMb`: the child builds and encodes a near-budget entry
|
||||
* under RLIMIT_AS, so this cap times the worst-case Unicode expansion must fit
|
||||
* the address space (see `addressSpaceMb`).
|
||||
*/
|
||||
maxLogBytes?: number
|
||||
/** Byte cap for the completion value. */
|
||||
/**
|
||||
* Byte cap for the completion value. Bounded at load against `addressSpaceMb`
|
||||
* the same way `maxLogBytes` is: the child builds and encodes a near-budget
|
||||
* value under RLIMIT_AS, so this cap times the worst-case Unicode expansion
|
||||
* must fit the address space.
|
||||
*/
|
||||
maxValueBytes?: number
|
||||
/** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
|
||||
graceMs?: number
|
||||
@@ -217,16 +231,22 @@ const FRAME_ENVELOPE_BYTES = 64
|
||||
const CLOSE_REAP_MARGIN_MS = 2_000
|
||||
|
||||
/**
|
||||
* The largest fraction of `addressSpaceMb` that `maxLogBytes` may claim, enforced
|
||||
* at load. The child's log ledger encodes an admitted entry to UTF-8 once to
|
||||
* charge its serialized cost, so a near-budget entry transiently needs the entry
|
||||
* plus its encode copy — roughly twice `maxLogBytes` — on top of the interpreter
|
||||
* baseline, all under `RLIMIT_AS`. One eighth leaves an 8x margin over the raw
|
||||
* budget, comfortably past that transient at any admissible cap, so a legitimate
|
||||
* near-budget log entry truncates instead of breaching the address space. A fixed
|
||||
* safety invariant tying two configs together, not a deployment knob.
|
||||
* Worst-case peak child-process bytes a one-`maxLogBytes`/`maxValueBytes`-budget
|
||||
* output can transiently occupy while the child charges and frames it, expressed
|
||||
* 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 the built string and ~4x again in
|
||||
* the `encode` copy taken to measure or ship it, live at the same time (the
|
||||
* concat that briefly holds both is bounded by those two). Eight covers that
|
||||
* simultaneous pair with margin for the interpreter baseline. Used to bound
|
||||
* `maxLogBytes`/`maxValueBytes` against `addressSpaceMb` at load, with a STRICT
|
||||
* `>` so a budget whose worst-case peak exactly equals the address space is
|
||||
* rejected, so a legitimate near-budget output truncates (log) or fails as
|
||||
* `output-limit` (value) rather than breaching `RLIMIT_AS` as `worker-exit`. A
|
||||
* fixed safety invariant tying the budgets to the address space, not a knob.
|
||||
*/
|
||||
const LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION = 1 / 8
|
||||
const OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE = 8
|
||||
|
||||
/**
|
||||
* Interval between process-group liveness probes while settlement waits for an
|
||||
@@ -688,28 +708,29 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
throw new Error(`dsh-code-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the ${FRAME_CEILING_BYTES}-byte fd-3 frame ceiling, so the run would fail as worker-exit rather than output-limit), got ${String(this.config[key])}`)
|
||||
}
|
||||
}
|
||||
// The child's log ledger admits an entry up to `maxLogBytes` and, to charge
|
||||
// its serialized cost, encodes it to UTF-8 once — a transient allocation of
|
||||
// up to `maxLogBytes` more bytes (and a control-char-dense entry escapes up
|
||||
// to sixfold on the wire, though the encode itself is the raw copy). That
|
||||
// copy happens under `RLIMIT_AS`, so a `maxLogBytes` that approaches
|
||||
// `addressSpaceMb` makes a legitimate near-budget log entry breach the
|
||||
// address space and die as `worker-exit` instead of truncating. Rather than
|
||||
// meter every child write against the address space at runtime — which trades
|
||||
// the memory bound for a per-character CPU cost on the hot path — reject the
|
||||
// incompatible pair at load: require `maxLogBytes` to leave the child room
|
||||
// for the interpreter baseline plus the entry and its encode copy. The bound
|
||||
// is `LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION` of the address space, well
|
||||
// clear of the ~2x-plus-baseline the push path needs at the default 64 KiB
|
||||
// cap. Checked on every platform, not just where `RLIMIT_AS` is enforced: the
|
||||
// incompatibility is a property of the two config values, and the child OOMs
|
||||
// on a Linux deployment regardless of the host that assembled the config, so
|
||||
// a uniform load-time rejection is the fail-loud contract (Darwin skips only
|
||||
// the runtime `setrlimit`, not this static check).
|
||||
// The child builds, charges, and frames a `maxLogBytes` log entry or a
|
||||
// `maxValueBytes` completion value under `RLIMIT_AS`, and both paths trigger
|
||||
// on CHARACTER count against a serialized-BYTE budget. An astral character is
|
||||
// one character but four bytes of `str` storage and four UTF-8 bytes, so a
|
||||
// budget's worth of them peaks at several simultaneous ~4x copies (the built
|
||||
// string, the concat that still references it, and the encode taken to
|
||||
// measure or ship it). A budget approaching `addressSpaceMb` therefore makes
|
||||
// a LEGITIMATE near-budget output breach the address space and die as
|
||||
// `worker-exit` instead of truncating (log) or failing as `output-limit`
|
||||
// (value). Metering every child write against the address space at runtime is
|
||||
// the wrong fix — an exact serialized-cost check is either a full encode (the
|
||||
// allocation being avoided) or a per-character Python loop that burns the CPU
|
||||
// budget — so the incompatible pair is rejected at load: each budget times the
|
||||
// worst-case multiple must fit the address space. Checked on every platform,
|
||||
// not just where `RLIMIT_AS` is enforced: the incompatibility is a property of
|
||||
// the config values, and the child OOMs on a Linux deployment regardless of
|
||||
// the host that assembled the config, so a uniform load-time rejection is the
|
||||
// fail-loud contract (Darwin skips only the runtime `setrlimit`).
|
||||
const addressSpaceBytes = this.config.addressSpaceMb * 1024 * 1024
|
||||
const logCaptureCeiling = Math.floor(addressSpaceBytes * LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION)
|
||||
if (this.config.maxLogBytes > logCaptureCeiling) {
|
||||
throw new Error(`dsh-code-runtime-python: config.maxLogBytes must not exceed ${logCaptureCeiling} (${LOG_CAPTURE_ADDRESS_SPACE_HEADROOM_FRACTION} of the ${addressSpaceBytes}-byte addressSpaceMb, leaving the child room to encode a near-budget log entry without breaching RLIMIT_AS), got ${String(this.config.maxLogBytes)}`)
|
||||
for (const key of ['maxLogBytes', 'maxValueBytes'] as const) {
|
||||
if (this.config[key] * OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE > addressSpaceBytes) {
|
||||
throw new Error(`dsh-code-runtime-python: config.${key} times the ${OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE}x worst-case Unicode expansion must fit the ${addressSpaceBytes}-byte addressSpaceMb, so a near-budget output truncates rather than breaching RLIMIT_AS as worker-exit; got ${String(this.config[key])} against an address space that admits at most ${Math.floor(addressSpaceBytes / OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE)}`)
|
||||
}
|
||||
}
|
||||
ctx.effect(() => () => this.teardown(), 'python code-runtime teardown')
|
||||
}
|
||||
|
||||
@@ -117,8 +117,11 @@ describe('PythonCodeRuntime — seam descriptors and misuse', () => {
|
||||
await expect(ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible + 1 }))
|
||||
.rejects.toThrow(/maxValueBytes must not exceed 268435392 .*fd-3 frame ceiling/)
|
||||
// The boundary value itself loads: the bound is the largest cap a frame can
|
||||
// still carry, not one below it.
|
||||
const boundary = await ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible })
|
||||
// still carry, not one below it. It needs an address space large enough to
|
||||
// clear the separate maxValueBytes/addressSpaceMb worst-case gate (the cap
|
||||
// times the 8x Unicode expansion must fit), so this pairs it with a 4 GiB
|
||||
// addressSpaceMb — the two load-time bounds are independent.
|
||||
const boundary = await ctx.plugin(PythonCodeRuntime, { maxValueBytes: admissible, addressSpaceMb: 4096 })
|
||||
await boundary.dispose()
|
||||
})
|
||||
|
||||
@@ -747,20 +750,25 @@ describe('PythonCodeRuntime — programs and bindings', () => {
|
||||
expect(result.logs.at(-1)).toBe(logTruncationMarker(4096))
|
||||
})
|
||||
|
||||
it('rejects a maxLogBytes that could breach addressSpaceMb during log encode at load', async () => {
|
||||
// The child's log ledger encodes an admitted entry to UTF-8 once to charge
|
||||
// its serialized cost, so a `maxLogBytes` approaching `addressSpaceMb` lets a
|
||||
// legitimate near-budget log entry breach RLIMIT_AS and die as worker-exit
|
||||
// instead of truncating. The incompatible pair is rejected at load rather
|
||||
// than metered per-write at runtime: `maxLogBytes` must stay within one
|
||||
// eighth of the `addressSpaceMb` byte count. 50 MB against a 64 MiB address
|
||||
// space is far over that bound; the default 64 KiB against 512 MiB is not.
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin(PythonCodeRuntime, { maxLogBytes: 50_000_000, addressSpaceMb: 64 }))
|
||||
.rejects.toThrow(/maxLogBytes must not exceed .* of the .*addressSpaceMb/)
|
||||
// A compatible pair loads.
|
||||
it('rejects an output budget that could breach addressSpaceMb during encode at load', async () => {
|
||||
// The child builds, charges, and encodes a `maxLogBytes` log entry or a
|
||||
// `maxValueBytes` completion value under RLIMIT_AS, and both trigger on
|
||||
// character count against a serialized-byte budget — an astral character is
|
||||
// one character but ~4 bytes stored and ~4 encoded, so a budget approaching
|
||||
// the address space lets a legitimate near-budget output breach it and die as
|
||||
// worker-exit. The incompatible pair is rejected at load: each budget times
|
||||
// the worst-case multiple (8) must fit the addressSpaceMb byte count. 50 MB
|
||||
// against a 64 MiB address space is far over; the default caps against 512 MiB
|
||||
// are not. Both budgets are gated symmetrically.
|
||||
const ctxLog = new Context()
|
||||
await expect(ctxLog.plugin(PythonCodeRuntime, { maxLogBytes: 50_000_000, addressSpaceMb: 64 }))
|
||||
.rejects.toThrow(/maxLogBytes times the 8x worst-case Unicode expansion must fit/)
|
||||
const ctxValue = new Context()
|
||||
await expect(ctxValue.plugin(PythonCodeRuntime, { maxValueBytes: 50_000_000, addressSpaceMb: 64 }))
|
||||
.rejects.toThrow(/maxValueBytes times the 8x worst-case Unicode expansion must fit/)
|
||||
// The default caps against the default 512 MiB address space load.
|
||||
const ok = new Context()
|
||||
const fiber = await ok.plugin(PythonCodeRuntime, { maxLogBytes: 65536, addressSpaceMb: 512 })
|
||||
const fiber = await ok.plugin(PythonCodeRuntime, { maxLogBytes: 65536, maxValueBytes: 32768, addressSpaceMb: 512 })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -3571,6 +3579,29 @@ describe('PythonCodeRuntime — hostile peer', () => {
|
||||
expect(result.logs.every(line => !line.includes(String.fromCharCode(0)))).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('bounds a huge unterminated tail after an early newline without copying it whole', async () => {
|
||||
// The newline branch of _LogStream.write buffered the whole unterminated
|
||||
// tail after the last newline into `_pending` before the flush trigger could
|
||||
// bound it, so an early newline followed by a huge tail made a second full
|
||||
// copy of the model's own string — a MemoryError the config gate cannot
|
||||
// catch (the tail far exceeds maxLogBytes). The tail is now sliced to a
|
||||
// budget-sized prefix, so the run truncates and completes. Linux-only RLIMIT_AS
|
||||
// repro (Darwin skips the limit); on macOS this asserts the happy path.
|
||||
const { runtime } = await setup({ maxLogBytes: 256, addressSpaceMb: 384, maxWallMs: 20_000 })
|
||||
const result = await runtime.run({
|
||||
program: [
|
||||
'import sys',
|
||||
// A short first line, then a 200 MiB unterminated tail on the same write.
|
||||
'sys.stdout.write("first\\n" + "A" * (200 * 1024 * 1024))',
|
||||
'return "done"',
|
||||
].join('\n'),
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('done')
|
||||
expect(result.logs.some(line => line.includes('log capture truncated'))).toBe(true)
|
||||
}, 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