mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
refactor(code-runtime-python): move the package into packages/experimental
The CPython code runtime's complete public contract is experimental, so it moves to packages/experimental per the experimental-packages rules: npm name @deepseek-ai/dsh-experimental-code-runtime-python, private: true, no publishConfig. All references updated (code-runtime READMEs, config-catalog and module-graph regenerated with zh alignment, tsconfig paths, doc-standard and workspace-constraints scripts, the fd-3 and settlement Agent Notes, and the package README links); md-links and translation pairing pass, and the suite still runs green.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
|
||||
/**
|
||||
* A synchronous `proto.write` throw on the fd-3 pipe is the one boot path a real
|
||||
* subprocess cannot be coerced into from a test: the pipe accepts queued bytes
|
||||
* until the kernel buffer fills, and a same-tick EPIPE needs fd 3 already closed
|
||||
* before the first write. `spawn` is mocked so fd 3 throws on the boot frame,
|
||||
* which is exactly the branch that regressed. The mock is confined to this file
|
||||
* so the real-subprocess suite in runtime.spec.ts is untouched.
|
||||
*/
|
||||
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }))
|
||||
vi.mock('node:child_process', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('node:child_process')>()),
|
||||
spawn: spawnMock,
|
||||
}))
|
||||
|
||||
const { PythonCodeRuntime } = await import('../src/index.ts')
|
||||
|
||||
/** A `child_process.ChildProcess` stand-in whose fd-3 pipe rejects every write. */
|
||||
function fakeChildWithThrowingFd3(): EventEmitter {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
pid?: number
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
stdio: unknown[]
|
||||
}
|
||||
// Leave `pid` absent: `finish()` still runs its `clearTimeout(wallTimer)` /
|
||||
// `removeEventListener(onAbort)` prologue (the TDZ site) before short-
|
||||
// circuiting on `child.pid === undefined` to `settle` instead of waiting on a
|
||||
// `close` this fake never emits, so the run resolves promptly.
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
// A duplex whose `write` throws synchronously, standing in for an fd-3 pipe
|
||||
// that fails the moment the boot frame is issued.
|
||||
const proto = new PassThrough()
|
||||
proto.write = () => { throw Object.assign(new Error('EPIPE: broken pipe, write'), { code: 'EPIPE' }) }
|
||||
child.stdio = [new PassThrough(), child.stdout, child.stderr, proto]
|
||||
return child
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
spawnMock.mockReset()
|
||||
})
|
||||
|
||||
/** A child whose fd-3 pipe accepts the boot write, then rejects the run write. */
|
||||
function fakeChildWithAckThenThrowingFd3(): EventEmitter {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
pid?: number
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
stdio: unknown[]
|
||||
}
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
const proto = new PassThrough()
|
||||
let writes = 0
|
||||
proto.write = () => {
|
||||
writes += 1
|
||||
if (writes === 1) return true // The boot frame goes out.
|
||||
throw Object.assign(new Error('EPIPE: broken pipe, write'), { code: 'EPIPE' })
|
||||
}
|
||||
child.stdio = [new PassThrough(), child.stdout, child.stderr, proto]
|
||||
// Emit the boot-ack after the boot write, so the run-frame write fires and
|
||||
// hits the throwing pipe.
|
||||
setImmediate(() => proto.emit('data', Buffer.from('{"type":"boot-ack"}\n')))
|
||||
return child
|
||||
}
|
||||
|
||||
describe('PythonCodeRuntime — boot-write failure', () => {
|
||||
it('resolves a worker-exit when the fd-3 boot write throws (no TDZ ReferenceError)', async () => {
|
||||
// Before the fix, the boot-write block ran BEFORE `wallTimer`, `onAbort`,
|
||||
// and `live` were initialized, so its `finish()` (which clears `wallTimer`,
|
||||
// removes `onAbort`, and — through `settle` — deletes `live`) hit the
|
||||
// temporal dead zone and threw a ReferenceError. That escaped the Promise
|
||||
// executor and REJECTED run() instead of resolving the worker-exit the catch
|
||||
// constructs. This test would see that rejection; the fix makes it resolve.
|
||||
spawnMock.mockImplementation(() => fakeChildWithThrowingFd3())
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime)
|
||||
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
|
||||
|
||||
const result = await runtime.run({ program: 'return 1', bindings: [] })
|
||||
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
expect(result.error?.message).toContain('failed to boot python subprocess')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('resolves a worker-exit and removes the staging dir when spawn throws synchronously', async () => {
|
||||
// `spawn` can throw same-tick — EMFILE on a descriptor-exhausted host, or a
|
||||
// libuv-level failure — before the Promise executor and its settlement path
|
||||
// exist. Left uncaught it rejected run() (the seam permits rejection only for
|
||||
// misuse) and stranded the staging directory materializePyScripts had just
|
||||
// written, which only settle() removes. The fix catches it, unlinks the
|
||||
// directory, and resolves the same `worker-exit` class as an async ENOENT.
|
||||
//
|
||||
// Capture THIS run's exact staging dir from the argv the mocked spawn
|
||||
// received (`['-I', <dir>/bootstrap.py]`) and assert only that path is gone.
|
||||
// A tmpdir scan — even a set difference against a pre-run snapshot — would
|
||||
// flake under vitest's forks pool: a sibling worker creating its own
|
||||
// `dsh-code-runtime-python-*` dir in the window reads as a leak here. Keying
|
||||
// off our own argv is fully isolated from concurrent staging.
|
||||
let stagedBootstrap: string | undefined
|
||||
spawnMock.mockImplementation((_bin: string, args: string[]) => {
|
||||
stagedBootstrap = args[args.length - 1]
|
||||
throw Object.assign(new Error('EMFILE: too many open files'), { code: 'EMFILE' })
|
||||
})
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime)
|
||||
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
|
||||
|
||||
const result = await runtime.run({ program: 'return 1', bindings: [] })
|
||||
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
expect(result.error?.message).toContain('python spawn error')
|
||||
expect(stagedBootstrap).toBeDefined()
|
||||
expect(existsSync(dirname(stagedBootstrap as string))).toBe(false)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('resolves a worker-exit when the run write after boot-ack throws', async () => {
|
||||
// The run frame goes out from the boot-ack handler; a pipe that accepts
|
||||
// the boot frame but rejects the run write must settle the run as a
|
||||
// worker-exit rather than reject run() or leave it hanging.
|
||||
spawnMock.mockImplementation(() => fakeChildWithAckThenThrowingFd3())
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime)
|
||||
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
|
||||
|
||||
const result = await runtime.run({ program: 'return 1', bindings: [] })
|
||||
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
expect(result.error?.message).toContain('failed to boot python subprocess')
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { logTruncationMarker, PROTOCOL_FD, WIRE_FRAME_FIELDS } from '../src/protocol.ts'
|
||||
|
||||
/**
|
||||
* Cross-language mirror check between `src/protocol.ts` and `py/protocol.py`,
|
||||
* spawning a real `python3` to read the Python side. Two things are asserted:
|
||||
* the runtime surfaces both sides EXECUTE against — `PROTOCOL_FD` and the log
|
||||
* truncation marker text, where a drift silently corrupts a live run — and the
|
||||
* per-frame wire field sets (required/optional keys of each `TypedDict`), which
|
||||
* turns the otherwise review-only shape mirror into an executable check that
|
||||
* catches the round-12 kind of drift (a renamed/dropped field, or one side
|
||||
* making a field optional the other requires). Self-skips when no `python3` is
|
||||
* on PATH — CI provides one; the pure-TS `protocol.spec.ts` covers the host
|
||||
* codec unconditionally.
|
||||
*/
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const pyDir = fileURLToPath(new URL('../py', import.meta.url))
|
||||
// `-B` blocks bytecode writes into the source tree (`py/__pycache__/*.pyc`);
|
||||
// `-I` isolates the interpreter but does not imply it.
|
||||
const python3Flags = ['-I', '-B']
|
||||
|
||||
async function hasPython3(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('python3', ['--version'])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const python3Available = await hasPython3()
|
||||
|
||||
describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', () => {
|
||||
it('agrees on PROTOCOL_FD and the log truncation marker across byte budgets', async () => {
|
||||
const budgets = [1, 65536, 1048576]
|
||||
const probe = [
|
||||
'import json, sys',
|
||||
`sys.path.insert(0, ${JSON.stringify(pyDir)})`,
|
||||
'from protocol import PROTOCOL_FD, log_truncation_marker',
|
||||
`budgets = ${JSON.stringify(budgets)}`,
|
||||
'print(json.dumps({',
|
||||
' "fd": PROTOCOL_FD,',
|
||||
' "markers": [log_truncation_marker(b) for b in budgets],',
|
||||
'}))',
|
||||
].join('\n')
|
||||
const { stdout } = await execFileAsync('python3', [...python3Flags, '-c', probe])
|
||||
const seen = JSON.parse(stdout) as { fd: number; markers: string[] }
|
||||
// Assert against the TS-side PROTOCOL_FD export (the value the host wires),
|
||||
// not a bare literal, so a drift on either side of the wire is caught here.
|
||||
expect(seen.fd).toBe(PROTOCOL_FD)
|
||||
expect(seen.markers).toEqual(budgets.map(budget => logTruncationMarker(budget)))
|
||||
})
|
||||
|
||||
it('agrees on every frame type\'s wire field set between the TS and Python declarations', async () => {
|
||||
// Turn the TypedDict mirror from a review-only obligation into an executable
|
||||
// check: enumerate EVERY TypedDict in py/protocol.py (public names carrying
|
||||
// __required_keys__) and assert both the frame roster and each frame's
|
||||
// required/optional key sets against WIRE_FRAME_FIELDS — projected from the
|
||||
// WIRE_FRAME_FIELD_ROLES map that `satisfies` binds exhaustively to the
|
||||
// frame interfaces in protocol.ts. Together this catches drift on EITHER
|
||||
// side of the wire: a TS-side field add, remove, rename, or optionality flip
|
||||
// fails typecheck at the roles map; a Python frame added, removed, or with a
|
||||
// changed field set fails this comparison. `global` is the reserved-keyword
|
||||
// wire key the Python side carries via a functional TypedDict.
|
||||
const probe = [
|
||||
'import json, sys',
|
||||
`sys.path.insert(0, ${JSON.stringify(pyDir)})`,
|
||||
'import protocol as p',
|
||||
'def keys(td): return {"required": sorted(td.__required_keys__), "optional": sorted(td.__optional_keys__)}',
|
||||
// Every public TypedDict in the module — not a name list from the TS side,
|
||||
// so a Python-only extra frame is visible here.
|
||||
'frames = {n: keys(v) for n, v in vars(p).items()'
|
||||
+ ' if not n.startswith("_") and hasattr(v, "__required_keys__")}',
|
||||
'print(json.dumps(frames))',
|
||||
].join('\n')
|
||||
const { stdout } = await execFileAsync('python3', [...python3Flags, '-c', probe])
|
||||
const seen = JSON.parse(stdout) as Record<string, { required: string[]; optional: string[] }>
|
||||
// Normalize the TS source of truth to the same sorted shape Python reports.
|
||||
const expected = Object.fromEntries(
|
||||
Object.entries(WIRE_FRAME_FIELDS).map(([name, sets]) => [
|
||||
name,
|
||||
{ required: [...sets.required].sort(), optional: [...sets.optional].sort() },
|
||||
]),
|
||||
)
|
||||
// Same frame roster on both sides (catches a frame present on only one),
|
||||
// then identical field sets per frame.
|
||||
expect(Object.keys(seen).sort()).toEqual(Object.keys(expected).sort())
|
||||
expect(seen).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
it('names the py/ directory that ships with the package', () => {
|
||||
// Resolves py/ relative to this test file; the same directory ships in the
|
||||
// package.json `files` whitelist (`py/**/*.py`). The tests/ directory itself
|
||||
// is not published — this asserts the source-tree layout the mirror test
|
||||
// depends on, so it holds even when python3 is absent from the runner.
|
||||
expect(existsSync(pyDir)).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { checkDoneValue, encodeJsonPlain, hasNonLosslessNumber, hasUnsafeIntegerToken, logTruncationMarker, validateChildFrame } from '../src/index.ts'
|
||||
|
||||
describe('logTruncationMarker', () => {
|
||||
it('names the configured byte budget', () => {
|
||||
expect(logTruncationMarker(65536)).toBe('[dsh-code-runtime-python] log capture truncated at 65536 bytes')
|
||||
expect(logTruncationMarker(1)).toBe('[dsh-code-runtime-python] log capture truncated at 1 bytes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateChildFrame', () => {
|
||||
it('rebuilds boot-ack frames without extra fields', () => {
|
||||
expect(validateChildFrame({ type: 'boot-ack' })).toEqual({ type: 'boot-ack' })
|
||||
// Forged extras never ride along.
|
||||
expect(validateChildFrame({ type: 'boot-ack', extra: 'x' })).toEqual({ type: 'boot-ack' })
|
||||
})
|
||||
|
||||
it('rebuilds log frames when the text field is a string', () => {
|
||||
expect(validateChildFrame({ type: 'log', text: 'hi' })).toEqual({ type: 'log', text: 'hi' })
|
||||
// Non-string text drops.
|
||||
expect(validateChildFrame({ type: 'log', text: 42 })).toBeUndefined()
|
||||
expect(validateChildFrame({ type: 'log' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries a log frame truncation flag only for the literal true', () => {
|
||||
// The child's own ledger marker sets `truncated: true`; the host rebuilds
|
||||
// it so it stops capturing at the same point.
|
||||
expect(validateChildFrame({ type: 'log', text: 'x', truncated: true }))
|
||||
.toEqual({ type: 'log', text: 'x', truncated: true })
|
||||
// Any other truthy or non-boolean value is a forgery and is dropped from
|
||||
// the rebuild — otherwise it would silence capture for the rest of the run.
|
||||
expect(validateChildFrame({ type: 'log', text: 'x', truncated: 1 })).toEqual({ type: 'log', text: 'x' })
|
||||
expect(validateChildFrame({ type: 'log', text: 'x', truncated: 'yes' })).toEqual({ type: 'log', text: 'x' })
|
||||
expect(validateChildFrame({ type: 'log', text: 'x', truncated: false })).toEqual({ type: 'log', text: 'x' })
|
||||
})
|
||||
|
||||
it('rebuilds call frames with a numeric id, string global, and string name', () => {
|
||||
expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } }))
|
||||
.toEqual({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } })
|
||||
// A frame with NO args key drops whole: rebuilding it as `undefined`
|
||||
// would invoke the binding with a non-JSON value, bypassing the
|
||||
// lossless-JSON argument boundary. Any present value is JSON-plain by
|
||||
// construction (frames arrive via JSON.parse), so null passes.
|
||||
expect(validateChildFrame({ type: 'call', id: 2, global: 'tools', name: 'echo' })).toBeUndefined()
|
||||
expect(validateChildFrame({ type: 'call', id: 2, global: 'tools', name: 'echo', args: null }))
|
||||
.toEqual({ type: 'call', id: 2, global: 'tools', name: 'echo', args: null })
|
||||
// A missing/mistyped required field drops.
|
||||
expect(validateChildFrame({ type: 'call', id: '1', global: 'tools', name: 'echo' })).toBeUndefined()
|
||||
expect(validateChildFrame({ type: 'call', id: 1, global: 7, name: 'echo' })).toBeUndefined()
|
||||
expect(validateChildFrame({ type: 'call', id: 1, global: 'tools' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rebuilds done frames with optional value/error', () => {
|
||||
expect(validateChildFrame({ type: 'done' })).toEqual({ type: 'done' })
|
||||
expect(validateChildFrame({ type: 'done', value: 42 })).toEqual({ type: 'done', value: 42 })
|
||||
expect(validateChildFrame({ type: 'done', error: { kind: 'exception', message: 'boom' } }))
|
||||
.toEqual({ type: 'done', error: { kind: 'exception', message: 'boom' } })
|
||||
expect(validateChildFrame({ type: 'done', error: { kind: 'invalid-output', message: 'lossy' } }))
|
||||
.toEqual({ type: 'done', error: { kind: 'invalid-output', message: 'lossy' } })
|
||||
expect(validateChildFrame({ type: 'done', error: { kind: 'output-limit', message: 'big' } }))
|
||||
.toEqual({ type: 'done', error: { kind: 'output-limit', message: 'big' } })
|
||||
expect(validateChildFrame({ type: 'done', value: 1, error: { kind: 'exception', message: 'boom' } }))
|
||||
.toEqual({ type: 'done', value: 1, error: { kind: 'exception', message: 'boom' } })
|
||||
// A `value: undefined` field is dropped (JSON never carries it, but a forged
|
||||
// shape might; the rebuild coalesces to the absent case).
|
||||
expect(validateChildFrame({ type: 'done', value: undefined })).toEqual({ type: 'done' })
|
||||
// A missing or unrecognized kind drops the frame: the child always sends
|
||||
// one of the three, so anything else is a forgery.
|
||||
expect(validateChildFrame({ type: 'done', error: { message: 'boom' } })).toBeUndefined()
|
||||
expect(validateChildFrame({ type: 'done', error: { kind: 'timeout', message: 'x' } })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects malformed done frames', () => {
|
||||
// error must be an object.
|
||||
expect(validateChildFrame({ type: 'done', error: 'boom' })).toBeUndefined()
|
||||
expect(validateChildFrame({ type: 'done', error: null })).toBeUndefined()
|
||||
// error.message must be a string.
|
||||
expect(validateChildFrame({ type: 'done', error: {} })).toBeUndefined()
|
||||
expect(validateChildFrame({ type: 'done', error: { message: 42 } })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops non-object inputs and unknown types silently', () => {
|
||||
expect(validateChildFrame(null)).toBeUndefined()
|
||||
expect(validateChildFrame(undefined)).toBeUndefined()
|
||||
expect(validateChildFrame(42)).toBeUndefined()
|
||||
expect(validateChildFrame('str')).toBeUndefined()
|
||||
expect(validateChildFrame({})).toBeUndefined()
|
||||
expect(validateChildFrame({ type: 'unknown' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops CALL frames whose args are non-finite or negative zero', () => {
|
||||
// JSON.parse turns 1e400 into Infinity and preserves -0; the honest child
|
||||
// rejects both before sending, so a call frame carrying one is forged.
|
||||
expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'x', args: { n: Infinity } })).toBeUndefined()
|
||||
expect(validateChildFrame({ type: 'call', id: Infinity, global: 'tools', name: 'x', args: null })).toBeUndefined()
|
||||
// Plain zero and ordinary floats pass.
|
||||
expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'x', args: [0, 1.5] }))
|
||||
.toEqual({ type: 'call', id: 1, global: 'tools', name: 'x', args: [0, 1.5] })
|
||||
})
|
||||
|
||||
it('drops a CALL frame whose id is negative zero', () => {
|
||||
// `-0` passes Number.isFinite, but the reply re-serializes it as `0`
|
||||
// (JSON.stringify({id:-0}) === '{"id":0}'), so a forged `-0` id would
|
||||
// collide with a real call whose id is `0`. The honest child never sends it.
|
||||
expect(validateChildFrame({ type: 'call', id: -0, global: 'tools', name: 'x', args: null })).toBeUndefined()
|
||||
// Plain positive zero is a legitimate id and passes.
|
||||
expect(validateChildFrame({ type: 'call', id: 0, global: 'tools', name: 'x', args: null }))
|
||||
.toEqual({ type: 'call', id: 0, global: 'tools', name: 'x', args: null })
|
||||
})
|
||||
|
||||
it('passes DONE values through untouched — losslessness is metered later', () => {
|
||||
// validateChildFrame no longer scans done.value: an unbounded scan would
|
||||
// push every member of a wide forged payload before any byte cap ran. The
|
||||
// done handler's checkDoneValue folds losslessness into the metered walk.
|
||||
expect(validateChildFrame({ type: 'done', value: Infinity })).toEqual({ type: 'done', value: Infinity })
|
||||
expect(validateChildFrame({ type: 'done', value: [{ x: -0 }] })).toEqual({ type: 'done', value: [{ x: -0 }] })
|
||||
expect(validateChildFrame({ type: 'done', value: [0, 1.5] })).toEqual({ type: 'done', value: [0, 1.5] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('lossless-number scan', () => {
|
||||
it('finds non-finite and negative-zero numbers at any depth, iteratively', () => {
|
||||
expect(hasNonLosslessNumber(Infinity)).toBe(true)
|
||||
expect(hasNonLosslessNumber(-Infinity)).toBe(true)
|
||||
expect(hasNonLosslessNumber(NaN)).toBe(true)
|
||||
expect(hasNonLosslessNumber(-0)).toBe(true)
|
||||
expect(hasNonLosslessNumber({ a: [1, { b: -0 }] })).toBe(true)
|
||||
expect(hasNonLosslessNumber({ a: [0, 1.5, 'x', null, true] })).toBe(false)
|
||||
// Deep nesting must not overflow the stack.
|
||||
let deep: unknown = 0
|
||||
for (let i = 0; i < 100000; i++) deep = [deep]
|
||||
expect(hasNonLosslessNumber(deep)).toBe(false)
|
||||
})
|
||||
|
||||
it('walks wide arrays and objects one member at a time', () => {
|
||||
// `call.args` carries no seam byte cap, so a wide forged payload has no
|
||||
// budget to be rejected against — the walk must hold one cursor per
|
||||
// NESTING LEVEL, not one entry per member, or a flat payload at the top of
|
||||
// the host's inbound frame-size cap would allocate tens of millions of stack
|
||||
// entries (and `Object.values` a second full-breadth copy). Observable
|
||||
// through the boundary: a wide payload whose per-member cost the old shape
|
||||
// would have paid still scans, and a violation ANYWHERE in it is found
|
||||
// wherever it sits.
|
||||
const wideArray = new Array(2_000_000).fill(0) as unknown[]
|
||||
expect(hasNonLosslessNumber(wideArray)).toBe(false)
|
||||
// Last element, so the cursor must run the whole breadth lazily.
|
||||
wideArray[wideArray.length - 1] = -0
|
||||
expect(hasNonLosslessNumber(wideArray)).toBe(true)
|
||||
const wideObject: Record<string, unknown> = {}
|
||||
for (let i = 0; i < 200_000; i++) wideObject[`k${i}`] = i
|
||||
expect(hasNonLosslessNumber(wideObject)).toBe(false)
|
||||
wideObject.last = Infinity
|
||||
expect(hasNonLosslessNumber(wideObject)).toBe(true)
|
||||
// Interleaved nesting: a per-level cursor must resume its parent after a
|
||||
// child level ends, so a violation after a nested container is still seen.
|
||||
expect(hasNonLosslessNumber([[1], { a: 2 }, NaN])).toBe(true)
|
||||
})
|
||||
|
||||
it('scans only own enumerable properties', () => {
|
||||
// The per-level cursor filters own keys (a prototype-carrying frame is
|
||||
// impossible off JSON.parse, but the filter is what keeps the walk equal
|
||||
// to what the encoder would serialize).
|
||||
const withProto = Object.create({ inherited: -0 }) as Record<string, unknown>
|
||||
withProto.own = 1
|
||||
expect(hasNonLosslessNumber(withProto)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsafe-integer token scan', () => {
|
||||
it('flags integer tokens outside the safe range, skipping strings and float forms', () => {
|
||||
expect(hasUnsafeIntegerToken('{"v":9007199254740993}')).toBe(true)
|
||||
// Exact beyond-safe-range tokens are lossless and pass (2**53, 2**64).
|
||||
expect(hasUnsafeIntegerToken('{"v":9007199254740992}')).toBe(false)
|
||||
expect(hasUnsafeIntegerToken('{"v":18446744073709551616}')).toBe(false)
|
||||
// A token that parses to Infinity is trivially lossy.
|
||||
expect(hasUnsafeIntegerToken(`{"v":${'9'.repeat(400)}}`)).toBe(true)
|
||||
expect(hasUnsafeIntegerToken('{"v":-9007199254740993}')).toBe(true)
|
||||
expect(hasUnsafeIntegerToken('{"v":9007199254740991}')).toBe(false)
|
||||
expect(hasUnsafeIntegerToken('{"v":"9007199254740993"}')).toBe(false)
|
||||
expect(hasUnsafeIntegerToken(String.raw`{"v":"esc\"9007199254740993"}`)).toBe(false)
|
||||
expect(hasUnsafeIntegerToken('{"v":9007199254740993.0}')).toBe(false)
|
||||
expect(hasUnsafeIntegerToken('{"v":9e99}')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkDoneValue', () => {
|
||||
it('matches the exact encoded size and rejects one byte over', () => {
|
||||
const cases: unknown[] = [null, true, false, 0, -1.5, 'a"b\\', [], {}, [1, 'x', null], { a: [1, 2], b: { c: 'd' } }]
|
||||
for (const value of cases) {
|
||||
const exact = Buffer.byteLength(JSON.stringify(value), 'utf8')
|
||||
expect(checkDoneValue(value, exact), JSON.stringify(value)).toEqual({ ok: true, bytes: exact })
|
||||
expect(checkDoneValue(value, exact - 1), JSON.stringify(value)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
expect(encodeJsonPlain(value)).toBe(JSON.stringify(value))
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an over-budget value before its secondary allocations', () => {
|
||||
// A huge string is refused on the cheap length lower bound, before its
|
||||
// escaped copy is built.
|
||||
const huge = { data: 'x'.repeat(1_000_000), tail: 'y' }
|
||||
expect(checkDoneValue(huge, 1024)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
// A flat array far above the budget fails on the brackets+length bound,
|
||||
// before its elements are pushed onto the traversal stack. (The array is
|
||||
// already materialized by the upstream parse; this only avoids the extra
|
||||
// per-element stack growth.)
|
||||
const flat = new Array(10_000_000).fill(0)
|
||||
expect(checkDoneValue(flat, 1024)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
// A wide object: braces+commas fit the cap, but the per-entry lower bound
|
||||
// (quoted key + colon + value = count*4) does not, so it fails before any
|
||||
// key is escaped or any value enqueued.
|
||||
const wide: Record<string, number> = {}
|
||||
for (let i = 0; i < 10; i++) wide[`k${i}`] = i
|
||||
expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
})
|
||||
|
||||
it('meters a string\'s exact escaped size without allocating it', () => {
|
||||
// A control-heavy string that fits by DECODED length but not once escaped
|
||||
// must still reject: 200 NULs are 200 UTF-16 units (would pass a naive
|
||||
// length bound against cap 1024) but escape to 200*6 + 2 = 1202 bytes.
|
||||
// jsonStringBytesUpTo scans and bails before the escaped copy is built.
|
||||
expect(checkDoneValue('\0'.repeat(200), 1024)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
// Exact-size acceptance, no false rejection: one NUL serializes to a
|
||||
// 6-char \\uXXXX escape, so with the two quotes = 8 bytes.
|
||||
expect(checkDoneValue('\0', 8)).toEqual({ ok: true, bytes: 8 })
|
||||
expect(checkDoneValue('\0', 7)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
// Multi-byte and astral characters meter at their raw UTF-8 width (a valid
|
||||
// surrogate pair is 4 bytes, matching JSON.stringify), not a 6-byte escape.
|
||||
expect(checkDoneValue('\u00e9', 4)).toEqual({ ok: true, bytes: 4 }) // 2 quotes + 2-byte UTF-8
|
||||
expect(checkDoneValue('\u{1f600}', 6)).toEqual({ ok: true, bytes: 6 }) // 2 quotes + 4-byte UTF-8
|
||||
expect(checkDoneValue('\u{1f600}', 5)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
// A lone surrogate escapes to \\uXXXX = 6, so with quotes = 8.
|
||||
expect(checkDoneValue('\ud800', 8)).toEqual({ ok: true, bytes: 8 })
|
||||
// A high surrogate followed by a NON-low character is a lone surrogate (6-byte
|
||||
// escape) plus that character: `\ud800` + `a` = 2 quotes + 6 + 1 = 9.
|
||||
expect(checkDoneValue('\ud800a', 9)).toEqual({ ok: true, bytes: 9 })
|
||||
// A BMP 3-byte code point (CJK) meters at its raw UTF-8 width: 2 quotes + 3.
|
||||
expect(checkDoneValue('中', 5)).toEqual({ ok: true, bytes: 5 })
|
||||
// Same non-allocating meter for object keys, before the value is enqueued.
|
||||
expect(checkDoneValue({ ['\0'.repeat(200)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
// A string reached with less than the two quotes' worth of budget is refused
|
||||
// immediately (even the empty escaped form does not fit).
|
||||
expect(checkDoneValue('x', 1)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
})
|
||||
|
||||
it('meters only own enumerable keys', () => {
|
||||
// The walk counts keys with a `for...in` + hasOwn pass rather than
|
||||
// Object.keys/entries (which allocate per member before the bound). A
|
||||
// prototype-carrying forgery is impossible off JSON.parse, but the own-key
|
||||
// filter is what keeps the count equal to the encoder's.
|
||||
const withProto = Object.create({ inherited: 'x' }) as Record<string, unknown>
|
||||
withProto.own = 1
|
||||
expect(checkDoneValue(withProto, 1024)).toEqual({ ok: true, bytes: Buffer.byteLength('{"own":1}', 'utf8') })
|
||||
})
|
||||
|
||||
it('rejects non-finite and negative-zero numbers at any depth as non-lossless', () => {
|
||||
expect(checkDoneValue(Infinity, 1024)).toEqual({ ok: false, reason: 'non-lossless' })
|
||||
expect(checkDoneValue(-Infinity, 1024)).toEqual({ ok: false, reason: 'non-lossless' })
|
||||
expect(checkDoneValue(NaN, 1024)).toEqual({ ok: false, reason: 'non-lossless' })
|
||||
expect(checkDoneValue(-0, 1024)).toEqual({ ok: false, reason: 'non-lossless' })
|
||||
expect(checkDoneValue({ a: [1, { b: -0 }] }, 1024)).toEqual({ ok: false, reason: 'non-lossless' })
|
||||
// An ordinary finite value within budget passes with its exact byte count.
|
||||
const clean = { a: [0, 1.5, 'x', null, true] }
|
||||
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' })
|
||||
// The non-lossless number's OWN encoded bytes still count toward the budget,
|
||||
// so a value whose only over-budget contribution is the non-lossless number
|
||||
// itself is classified over-budget, not non-lossless. `[Infinity]` encodes
|
||||
// as the 10-byte `[Infinity]`; at cap 3 the byte check wins.
|
||||
expect(checkDoneValue([Infinity], 3)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
expect(checkDoneValue(Infinity, 3)).toEqual({ ok: false, reason: 'over-budget' })
|
||||
})
|
||||
|
||||
it('meters and encodes deep nesting iteratively without overflowing the stack', () => {
|
||||
let deep: unknown = 0
|
||||
for (let i = 0; i < 100_000; i++) deep = [deep]
|
||||
// 100000 '[' + '0' + 100000 ']' = 200001 bytes.
|
||||
expect(checkDoneValue(deep, 1_000_000)).toEqual({ ok: true, bytes: 200_001 })
|
||||
// encodeJsonPlain's headline contract is the same stack-safety (JSON.stringify
|
||||
// recurses per level and throws RangeError a few thousand deep), so exercise
|
||||
// it on the same 100k-deep value — JSON.stringify would throw here.
|
||||
expect(encodeJsonPlain(deep)).toBe(`${'['.repeat(100_000)}0${']'.repeat(100_000)}`)
|
||||
})
|
||||
|
||||
it('emits exact digits for beyond-safe integral doubles', () => {
|
||||
// String(2**60) prints the ROUNDED ...847000; echoing that to the child
|
||||
// would change the integer. BigInt digits give the exact ...846976.
|
||||
const v = JSON.parse('[1152921504606846976]') as unknown
|
||||
expect(encodeJsonPlain(v)).toBe('[1152921504606846976]')
|
||||
expect(checkDoneValue(v, 100)).toEqual({ ok: true, bytes: Buffer.byteLength('[1152921504606846976]', 'utf8') })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detachResidual } from '../src/index.ts'
|
||||
|
||||
describe('detachResidual — fd-3 residual detachment', () => {
|
||||
it('returns a copy that does NOT share the source frame allocation', () => {
|
||||
// Simulate the data handler's state: one large joined frame from
|
||||
// Buffer.concat, sliced past its newline to leave a small residual VIEW.
|
||||
// The fixture MUST stay larger than Node's Buffer pool threshold
|
||||
// (`Buffer.poolSize / 2`, 4 KiB): above it `Buffer.from` allocates a
|
||||
// dedicated backing store whose `byteLength` equals the copy's length,
|
||||
// which is what the byteLength assertion below pins. A smaller residual
|
||||
// would be pooled into an 8 KiB shared ArrayBuffer, making `byteLength`
|
||||
// report 8192 and the assertion false-fail even though the fix is intact.
|
||||
const joined = Buffer.alloc(1024 * 1024, 0x61) // 1 MiB backing allocation
|
||||
joined[512] = 0x0a // a newline partway through
|
||||
const residual = joined.subarray(513) // a view onto `joined`'s backing store
|
||||
|
||||
// Before the fix the handler carried this view forward verbatim, pinning the
|
||||
// whole 1 MiB `joined` allocation behind a residual that reports far fewer
|
||||
// bytes. A right-sized copy must not point back into `joined`.
|
||||
const [carried] = detachResidual(residual)
|
||||
|
||||
expect(carried).toBeDefined()
|
||||
expect(carried!.length).toBe(residual.length)
|
||||
expect(carried!.equals(residual)).toBe(true)
|
||||
// The core invariant: the copy does NOT share the source frame's backing
|
||||
// store, so retaining it cannot pin the 1 MiB allocation.
|
||||
expect(carried!.buffer).not.toBe(joined.buffer)
|
||||
// And the copy's own backing store is sized to its content — not the whole
|
||||
// frame. Holds because the fixture exceeds the pool threshold (see above);
|
||||
// a subarray view would report the source's full byteLength here.
|
||||
expect(carried!.buffer.byteLength).toBe(carried!.length)
|
||||
})
|
||||
|
||||
it('carries nothing forward for an empty residual', () => {
|
||||
expect(detachResidual(Buffer.alloc(0))).toEqual([])
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user