mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-30 04:40:37 +00:00
fix(code-runtime-python): bound checkDoneValue object metering in O(cap)
The object branch counted every own key before applying the size bound, so a forged done.value with millions of keys and a small cap forced an O(frame) walk — contradicting the O(cap) guarantee the comment promised and able to block the host event loop. Bail mid-count the instant the running minimum encoding (braces + 4 bytes/entry + commas) crosses maxBytes, and drop the now -redundant post-count check the loop subsumes. Add a Proxy-based test proving a 2M-key object enumerates fewer than 1000 keys under a 64-byte cap. Also correct the checkDoneValue JSDoc: per-scalar byte length is measured via scalarJson (exact BigInt digits for beyond-safe integers), not JSON.stringify.
This commit is contained in:
@@ -202,7 +202,10 @@ function scalarJson(current: unknown): string {
|
||||
* number (non-finite, negative zero) is caught only when the value fits the
|
||||
* budget — an over-budget value is rejected regardless, so the distinction is
|
||||
* moot. Same JSON-plain precondition and traversal shape as
|
||||
* {@link encodeJsonPlain}; per-scalar encoding delegates to `JSON.stringify`.
|
||||
* {@link encodeJsonPlain}; per-scalar byte length is measured through
|
||||
* {@link scalarJson} (matching the encoder, so a beyond-safe-range integer
|
||||
* meters its exact BigInt digits, not `JSON.stringify`'s rounded spelling) and
|
||||
* `JSON.stringify` for strings.
|
||||
* @param value - a JSON-plain value (e.g. straight from `JSON.parse`).
|
||||
* @param maxBytes - the completion-value budget in bytes.
|
||||
* @returns `{ ok: true, bytes }` with the exact serialized size, or
|
||||
@@ -235,15 +238,22 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by
|
||||
for (const item of current) stack.push(item)
|
||||
} else if (typeof current === 'object' && current !== null) {
|
||||
const record = current as Record<string, unknown>
|
||||
// Count own keys WITHOUT Object.entries/Object.keys: either would
|
||||
// allocate one slot (entries: one pair array) per member before the
|
||||
// bound below could run, recreating the spike the bound exists to stop.
|
||||
// Count own keys WITHOUT Object.entries/Object.keys (either allocates one
|
||||
// slot per member up front), AND bail mid-count the instant the minimum
|
||||
// encoding exceeds the budget: braces (+2), each entry a quoted key
|
||||
// (>= 2 bytes) + colon + >= 1-byte value (>= 4 bytes), and a comma per
|
||||
// gap. A forged wide object with millions of keys and a small cap must
|
||||
// fail in O(cap), not walk its whole breadth first. `bytes` still holds
|
||||
// the pre-object total throughout this loop.
|
||||
let count = 0
|
||||
for (const key in record) if (Object.hasOwn(record, key)) count += 1
|
||||
for (const key in record) {
|
||||
if (!Object.hasOwn(record, key)) continue
|
||||
count += 1
|
||||
if (bytes + 2 + count * 4 + (count - 1) > maxBytes) return { ok: false, reason: 'over-budget' }
|
||||
}
|
||||
// The loop's final iteration already proved the whole object's lower
|
||||
// bound fits, so no separate post-count check is needed here.
|
||||
bytes += 2 + (count > 1 ? count - 1 : 0)
|
||||
// Same pre-enqueue bound: each entry contributes its quoted key (>= 2
|
||||
// bytes), the colon, and a >= 1-byte value.
|
||||
if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' }
|
||||
for (const key in record) {
|
||||
if (!Object.hasOwn(record, key)) continue
|
||||
// The same string lower bound, before escaping the key.
|
||||
|
||||
Reference in New Issue
Block a user