diff --git a/src/main/daemon/json-utf8-byte-length.test.ts b/src/main/daemon/json-utf8-byte-length.test.ts deleted file mode 100644 index fd0539890..000000000 --- a/src/main/daemon/json-utf8-byte-length.test.ts +++ /dev/null @@ -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 = {} - circular.self = circular - - expect(() => jsonUtf8ByteLength(circular)).toThrow('circular') - expect(() => jsonUtf8ByteLength(1n)).toThrow('BigInt') - }) -}) diff --git a/src/main/daemon/json-utf8-byte-length.ts b/src/main/daemon/json-utf8-byte-length.ts deleted file mode 100644 index dafc8b697..000000000 --- a/src/main/daemon/json-utf8-byte-length.ts +++ /dev/null @@ -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() - - 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)[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 -} diff --git a/src/main/daemon/terminal-checkpoint-serializer.test.ts b/src/main/daemon/terminal-checkpoint-serializer.test.ts new file mode 100644 index 000000000..00be8f39e --- /dev/null +++ b/src/main/daemon/terminal-checkpoint-serializer.test.ts @@ -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 { + 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() + } + }) +}) diff --git a/src/main/daemon/terminal-checkpoint-serializer.ts b/src/main/daemon/terminal-checkpoint-serializer.ts index 595d42fd6..afa852a1b 100644 --- a/src/main/daemon/terminal-checkpoint-serializer.ts +++ b/src/main/daemon/terminal-checkpoint-serializer.ts @@ -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 +): 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)[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 {