perf(terminal): serialize checkpoints with one payload walk (#11422)
* perf(terminal): serialize checkpoints with one payload walk Co-authored-by: Orca <help@stably.ai> * fix(terminal): bound checkpoint serialization Co-authored-by: Orca <help@stably.ai> * test(terminal): correct bounded serialization proof Co-authored-by: Orca <help@stably.ai> * test(terminal): cover over-limit multibyte checkpoints Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
37af457752
commit
ab2b517cf9
|
|
@ -1,33 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { jsonUtf8ByteLength } from './json-utf8-byte-length'
|
||||
|
||||
describe('jsonUtf8ByteLength', () => {
|
||||
it('matches JSON.stringify for escapes, Unicode, surrogates, and nested values', () => {
|
||||
const values: unknown[] = [
|
||||
'',
|
||||
'"\\\b\t\n\f\r\u0000\u001f',
|
||||
'plain ASCII',
|
||||
'é漢😀',
|
||||
'\ud800 lone high \udc00 lone low',
|
||||
{
|
||||
omitted: undefined,
|
||||
finite: -1.25e100,
|
||||
nonFinite: Number.POSITIVE_INFINITY,
|
||||
nested: ['😀', undefined, null, { control: '\u0001' }]
|
||||
}
|
||||
]
|
||||
|
||||
for (const value of values) {
|
||||
const json = JSON.stringify(value)
|
||||
expect(jsonUtf8ByteLength(value)).toBe(Buffer.byteLength(json, 'utf8'))
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects the same unsupported structural values as JSON.stringify', () => {
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
|
||||
expect(() => jsonUtf8ByteLength(circular)).toThrow('circular')
|
||||
expect(() => jsonUtf8ByteLength(1n)).toThrow('BigInt')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
function jsonStringUtf8Bytes(value: string): number {
|
||||
let bytes = 2
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index)
|
||||
if (codeUnit === 0x22 || codeUnit === 0x5c || codeUnit === 0x08 || codeUnit === 0x09) {
|
||||
bytes += 2
|
||||
} else if (codeUnit === 0x0a || codeUnit === 0x0c || codeUnit === 0x0d) {
|
||||
bytes += 2
|
||||
} else if (codeUnit < 0x20) {
|
||||
bytes += 6
|
||||
} else if (codeUnit < 0x80) {
|
||||
bytes += 1
|
||||
} else if (codeUnit < 0x800) {
|
||||
bytes += 2
|
||||
} else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4
|
||||
index += 1
|
||||
} else {
|
||||
bytes += 6
|
||||
}
|
||||
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
bytes += 6
|
||||
} else {
|
||||
bytes += 3
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function jsonUtf8ByteLength(value: unknown): number {
|
||||
const activeObjects = new Set<object>()
|
||||
|
||||
const measure = (current: unknown, arrayElement: boolean): number | null => {
|
||||
if (current === null) {
|
||||
return 4
|
||||
}
|
||||
switch (typeof current) {
|
||||
case 'string':
|
||||
return jsonStringUtf8Bytes(current)
|
||||
case 'boolean':
|
||||
return current ? 4 : 5
|
||||
case 'number':
|
||||
return Number.isFinite(current) ? JSON.stringify(current).length : 4
|
||||
case 'undefined':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
return arrayElement ? 4 : null
|
||||
case 'bigint':
|
||||
throw new TypeError('Do not know how to serialize a BigInt')
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
|
||||
const object = current as object
|
||||
if (activeObjects.has(object)) {
|
||||
throw new TypeError('Converting circular structure to JSON')
|
||||
}
|
||||
activeObjects.add(object)
|
||||
try {
|
||||
if (Array.isArray(object)) {
|
||||
let bytes = 2
|
||||
for (let index = 0; index < object.length; index += 1) {
|
||||
if (index > 0) {
|
||||
bytes += 1
|
||||
}
|
||||
bytes += measure(object[index], true) ?? 4
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
let bytes = 2
|
||||
let entries = 0
|
||||
for (const key of Object.keys(object)) {
|
||||
const propertyBytes = measure((object as Record<string, unknown>)[key], false)
|
||||
if (propertyBytes === null) {
|
||||
continue
|
||||
}
|
||||
bytes += (entries > 0 ? 1 : 0) + jsonStringUtf8Bytes(key) + 1 + propertyBytes
|
||||
entries += 1
|
||||
}
|
||||
return bytes
|
||||
} finally {
|
||||
activeObjects.delete(object)
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = measure(value, false)
|
||||
if (bytes === null) {
|
||||
throw new TypeError('Value is not JSON serializable')
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSnapshot } from './types'
|
||||
import { serializeTerminalCheckpointWithinLimit } from './terminal-checkpoint-serializer'
|
||||
|
||||
function snapshot(overrides: Partial<TerminalSnapshot> = {}): TerminalSnapshot {
|
||||
return {
|
||||
snapshotAnsi: 'visible',
|
||||
scrollbackAnsi: '',
|
||||
rehydrateSequences: '',
|
||||
cwd: '/workspace',
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
},
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
scrollbackLines: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
cwd: '/workspace',
|
||||
generation: 1,
|
||||
checkpointedAt: '2026-07-29T00:00:00.000Z'
|
||||
}
|
||||
|
||||
describe('terminal checkpoint serializer', () => {
|
||||
it('matches JSON.stringify exactly at the UTF-8 byte limit', async () => {
|
||||
const input = snapshot({
|
||||
snapshotAnsi: `é漢😀${String.fromCharCode(0xd800, 0xdc00)}"\\${String.fromCharCode(
|
||||
0x00,
|
||||
0x08,
|
||||
0x09,
|
||||
0x0a,
|
||||
0x0c,
|
||||
0x0d,
|
||||
0x1f,
|
||||
0xd800,
|
||||
0xdc00
|
||||
)}`,
|
||||
oscLinks: [{ row: 0, startCol: 0, endCol: 1, uri: 'https://example.com/😀\n' }]
|
||||
})
|
||||
const expected = JSON.stringify({
|
||||
snapshotAnsi: input.snapshotAnsi,
|
||||
scrollbackAnsi: input.scrollbackAnsi,
|
||||
oscLinks: input.oscLinks,
|
||||
rehydrateSequences: input.rehydrateSequences,
|
||||
cwd: metadata.cwd,
|
||||
cols: input.cols,
|
||||
rows: input.rows,
|
||||
modes: input.modes,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
generation: metadata.generation,
|
||||
checkpointedAt: metadata.checkpointedAt
|
||||
})
|
||||
const exactBytes = Buffer.byteLength(expected, 'utf8')
|
||||
|
||||
await expect(serializeTerminalCheckpointWithinLimit(input, metadata, exactBytes)).resolves.toBe(
|
||||
expected
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects multibyte input whose code-unit length fits under the byte cap', async () => {
|
||||
const input = snapshot({
|
||||
scrollbackAnsi: 'é\r\n'.repeat(100),
|
||||
scrollbackLines: 100
|
||||
})
|
||||
const expected = JSON.stringify({
|
||||
snapshotAnsi: input.snapshotAnsi,
|
||||
scrollbackAnsi: input.scrollbackAnsi,
|
||||
oscLinks: input.oscLinks,
|
||||
rehydrateSequences: input.rehydrateSequences,
|
||||
cwd: metadata.cwd,
|
||||
cols: input.cols,
|
||||
rows: input.rows,
|
||||
modes: input.modes,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
generation: metadata.generation,
|
||||
checkpointedAt: metadata.checkpointedAt
|
||||
})
|
||||
const maxBytes = expected.length + 1
|
||||
|
||||
expect(expected.length).toBeLessThan(maxBytes)
|
||||
expect(Buffer.byteLength(expected, 'utf8')).toBeGreaterThan(maxBytes)
|
||||
|
||||
const serialized = await serializeTerminalCheckpointWithinLimit(input, metadata, maxBytes)
|
||||
|
||||
expect(serialized).not.toBe(expected)
|
||||
expect(Buffer.byteLength(serialized, 'utf8')).toBeLessThanOrEqual(maxBytes)
|
||||
})
|
||||
|
||||
it('does not materialize and rescan a passing candidate', async () => {
|
||||
let reads = 0
|
||||
const input = snapshot()
|
||||
Object.defineProperty(input, 'snapshotAnsi', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads += 1
|
||||
return 'visible'
|
||||
}
|
||||
})
|
||||
const stringify = vi.spyOn(JSON, 'stringify')
|
||||
const byteLength = vi.spyOn(Buffer, 'byteLength')
|
||||
|
||||
try {
|
||||
await serializeTerminalCheckpointWithinLimit(input, metadata, 20 * 1024)
|
||||
|
||||
expect({
|
||||
fullCandidateStringifies: stringify.mock.calls.filter(
|
||||
([value]) => typeof value === 'object' && value !== null
|
||||
).length,
|
||||
byteLengthCalls: byteLength.mock.calls.length,
|
||||
snapshotReads: reads
|
||||
}).toEqual({ fullCandidateStringifies: 0, byteLengthCalls: 0, snapshotReads: 1 })
|
||||
} finally {
|
||||
stringify.mockRestore()
|
||||
byteLength.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an oversized escaped candidate without materializing it', async () => {
|
||||
const oversized = String.fromCharCode(0).repeat(100_000)
|
||||
const stringify = vi.spyOn(JSON, 'stringify')
|
||||
|
||||
try {
|
||||
await serializeTerminalCheckpointWithinLimit(
|
||||
snapshot({ snapshotAnsi: oversized }),
|
||||
metadata,
|
||||
512
|
||||
)
|
||||
|
||||
const materializedOversizedCandidate = stringify.mock.calls.some(([value]) => {
|
||||
return (value as { snapshotAnsi?: unknown })?.snapshotAnsi === oversized
|
||||
})
|
||||
expect(materializedOversizedCandidate).toBe(false)
|
||||
} finally {
|
||||
stringify.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import type { TerminalCheckpointFile, TerminalSnapshot } from './types'
|
||||
import { ColdRestoreReplayWriter } from './cold-restore-replay-writer'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { jsonUtf8ByteLength } from './json-utf8-byte-length'
|
||||
|
||||
type CheckpointMetadata = {
|
||||
cwd: string | null
|
||||
|
|
@ -32,15 +31,187 @@ function checkpointFile(
|
|||
}
|
||||
}
|
||||
|
||||
class BoundedJsonWriter {
|
||||
private output = ''
|
||||
private chunk = ''
|
||||
private bytes = 0
|
||||
private exceeded = false
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
append(value: string, bytes: number): boolean {
|
||||
if (this.bytes + bytes > this.maxBytes) {
|
||||
this.exceeded = true
|
||||
this.output = ''
|
||||
this.chunk = ''
|
||||
return false
|
||||
}
|
||||
this.bytes += bytes
|
||||
this.chunk += value
|
||||
if (this.chunk.length >= 16 * 1024) {
|
||||
this.output += this.chunk
|
||||
this.chunk = ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
result(): string | null {
|
||||
return this.exceeded ? null : this.output + this.chunk
|
||||
}
|
||||
}
|
||||
|
||||
function escapedCodeUnit(codeUnit: number): string | null {
|
||||
switch (codeUnit) {
|
||||
case 0x08:
|
||||
return '\\b'
|
||||
case 0x09:
|
||||
return '\\t'
|
||||
case 0x0a:
|
||||
return '\\n'
|
||||
case 0x0c:
|
||||
return '\\f'
|
||||
case 0x0d:
|
||||
return '\\r'
|
||||
case 0x22:
|
||||
return '\\"'
|
||||
case 0x5c:
|
||||
return '\\\\'
|
||||
default:
|
||||
return codeUnit < 0x20 ? `\\u${codeUnit.toString(16).padStart(4, '0')}` : null
|
||||
}
|
||||
}
|
||||
|
||||
function appendJsonString(writer: BoundedJsonWriter, value: string): boolean {
|
||||
if (!writer.append('"', 1)) {
|
||||
return false
|
||||
}
|
||||
let spanStart = 0
|
||||
let spanBytes = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index)
|
||||
let escaped = escapedCodeUnit(codeUnit)
|
||||
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
spanBytes += 4
|
||||
index += 1
|
||||
} else {
|
||||
escaped = `\\u${codeUnit.toString(16)}`
|
||||
}
|
||||
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
escaped = `\\u${codeUnit.toString(16)}`
|
||||
} else if (escaped === null) {
|
||||
spanBytes += codeUnit < 0x80 ? 1 : codeUnit < 0x800 ? 2 : 3
|
||||
}
|
||||
|
||||
if (escaped !== null) {
|
||||
if (
|
||||
(index > spanStart && !writer.append(value.slice(spanStart, index), spanBytes)) ||
|
||||
!writer.append(escaped, escaped.length)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
spanStart = index + 1
|
||||
spanBytes = 0
|
||||
} else if (index + 1 - spanStart >= 16 * 1024) {
|
||||
if (!writer.append(value.slice(spanStart, index + 1), spanBytes)) {
|
||||
return false
|
||||
}
|
||||
spanStart = index + 1
|
||||
spanBytes = 0
|
||||
}
|
||||
}
|
||||
if (spanStart < value.length && !writer.append(value.slice(spanStart), spanBytes)) {
|
||||
return false
|
||||
}
|
||||
return writer.append('"', 1)
|
||||
}
|
||||
|
||||
function omittedByJson(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'function' || typeof value === 'symbol'
|
||||
}
|
||||
|
||||
function appendJsonValue(
|
||||
writer: BoundedJsonWriter,
|
||||
value: unknown,
|
||||
activeObjects: Set<object>
|
||||
): boolean {
|
||||
if (value === null) {
|
||||
return writer.append('null', 4)
|
||||
}
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
return appendJsonString(writer, value)
|
||||
case 'boolean':
|
||||
return writer.append(value ? 'true' : 'false', value ? 4 : 5)
|
||||
case 'number': {
|
||||
const json = Number.isFinite(value) ? JSON.stringify(value) : 'null'
|
||||
return writer.append(json, json.length)
|
||||
}
|
||||
case 'bigint':
|
||||
throw new TypeError('Do not know how to serialize a BigInt')
|
||||
case 'undefined':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
return false
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
|
||||
if (activeObjects.has(value)) {
|
||||
throw new TypeError('Converting circular structure to JSON')
|
||||
}
|
||||
activeObjects.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (!writer.append('[', 1)) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (index > 0 && !writer.append(',', 1)) {
|
||||
return false
|
||||
}
|
||||
const entry = value[index]
|
||||
if (omittedByJson(entry)) {
|
||||
if (!writer.append('null', 4)) {
|
||||
return false
|
||||
}
|
||||
} else if (!appendJsonValue(writer, entry, activeObjects)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return writer.append(']', 1)
|
||||
}
|
||||
|
||||
if (!writer.append('{', 1)) {
|
||||
return false
|
||||
}
|
||||
let entries = 0
|
||||
for (const key of Object.keys(value)) {
|
||||
const entry = (value as Record<string, unknown>)[key]
|
||||
if (omittedByJson(entry)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
(entries > 0 && !writer.append(',', 1)) ||
|
||||
!appendJsonString(writer, key) ||
|
||||
!writer.append(':', 1) ||
|
||||
!appendJsonValue(writer, entry, activeObjects)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
entries += 1
|
||||
}
|
||||
return writer.append('}', 1)
|
||||
} finally {
|
||||
activeObjects.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyWithinLimit(checkpoint: TerminalCheckpointFile, maxBytes: number): string | null {
|
||||
if (jsonUtf8ByteLength(checkpoint) > maxBytes) {
|
||||
return null
|
||||
}
|
||||
const json = JSON.stringify(checkpoint)
|
||||
if (Buffer.byteLength(json, 'utf8') > maxBytes) {
|
||||
throw new Error('Terminal checkpoint size estimator mismatch')
|
||||
}
|
||||
return json
|
||||
const writer = new BoundedJsonWriter(maxBytes)
|
||||
appendJsonValue(writer, checkpoint, new Set())
|
||||
return writer.result()
|
||||
}
|
||||
|
||||
async function replaySnapshot(snapshot: TerminalSnapshot): Promise<HeadlessEmulator> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue