fix(code-runtime-python): make checkDoneValue over-budget precedence order-independent

checkDoneValue returned non-lossless the instant it hit a non-finite/negative-
zero number, before finishing the budget metering. A value that is BOTH over-
budget and non-lossless then classified by member order: `["<huge>", 1e400]`
gave non-lossless while `[1e400, "<huge>"]` gave over-budget — the same value,
two verdicts — which would drive the consumer to emit invalid-output vs
output-limit non-deterministically, contradicting the JSDoc promise that an
over-budget value is rejected as over-budget regardless. Record the number
violation in a flag and let metering finish; return non-lossless only once the
whole value is confirmed within budget. Add a regression test asserting both
member orders classify as over-budget.
This commit is contained in:
Chinesezjc
2026-08-07 13:27:54 +08:00
parent 8cf253a470
commit 146a9d9f61
2 changed files with 25 additions and 2 deletions
@@ -217,12 +217,19 @@ function scalarJson(current: unknown): string {
*/
export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; bytes: number } | { ok: false; reason: 'over-budget' | 'non-lossless' } {
let bytes = 0
// A non-lossless number is recorded, not returned on sight: over-budget must
// win regardless of where in the value each violation sits, so the whole
// metering finishes first. Otherwise `["<huge>", 1e400]` and `[1e400,
// "<huge>"]` — the same over-budget value in two member orders — would
// classify differently (non-lossless vs over-budget), and the JSDoc promises
// an over-budget value is rejected as over-budget regardless.
let nonLossless = false
const stack: unknown[] = [value]
while (stack.length > 0) {
const current = stack.pop()
if (typeof current === 'number') {
if (!Number.isFinite(current) || Object.is(current, -0)) return { ok: false, reason: 'non-lossless' }
bytes += Buffer.byteLength(scalarJson(current), 'utf8')
if (!Number.isFinite(current) || Object.is(current, -0)) nonLossless = true
else bytes += Buffer.byteLength(scalarJson(current), 'utf8')
} else if (typeof current === 'string') {
// Lower-bound BEFORE materializing the escaped form: every UTF-16 code
// unit is at least one UTF-8 byte plus the two quotes, so a huge or
@@ -262,6 +269,8 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by
}
if (bytes > maxBytes) return { ok: false, reason: 'over-budget' }
}
// The whole value fit the budget; a recorded number violation is the verdict.
if (nonLossless) return { ok: false, reason: 'non-lossless' }
return { ok: true, bytes }
}
@@ -250,6 +250,20 @@ describe('checkDoneValue', () => {
expect(checkDoneValue(clean, 1024)).toEqual({ ok: true, bytes: Buffer.byteLength(JSON.stringify(clean), 'utf8') })
})
it('classifies an over-budget value as over-budget regardless of member order', () => {
// A value that is BOTH over-budget and non-lossless must reject as
// over-budget whichever member the walk reaches first — the non-lossless
// number is recorded and metering finishes, so the two orders below (the
// same value) cannot classify differently. Cap 100 with a 1000-char string.
const big = 'x'.repeat(1000)
expect(checkDoneValue([big, Infinity], 100)).toEqual({ ok: false, reason: 'over-budget' })
expect(checkDoneValue([Infinity, big], 100)).toEqual({ ok: false, reason: 'over-budget' })
// A non-lossless number that DOES fit the budget still rejects as
// non-lossless (the recorded violation is the verdict once the whole value
// is confirmed within budget).
expect(checkDoneValue([Infinity], 100)).toEqual({ ok: false, reason: 'non-lossless' })
})
it('meters deep nesting iteratively without overflowing the stack', () => {
let deep: unknown = 0
for (let i = 0; i < 100_000; i++) deep = [deep]