diff --git a/src/application/environment/environmentStateAnswer.test.ts b/src/application/environment/environmentStateAnswer.test.ts index 0c73ec0ab3..528a3d1225 100644 --- a/src/application/environment/environmentStateAnswer.test.ts +++ b/src/application/environment/environmentStateAnswer.test.ts @@ -419,6 +419,59 @@ describe("recorded and document answers", () => { }); }); + it("caps a state-backed document by its exact serialized UTF-8 body bytes", () => { + const capture = timedCaptureEvidence([ + ["GET", "/api/settings", { theme: "dark" }], + ["PUT", "/api/settings", { theme: "light" }], + ["GET", "/api/settings", { theme: "light" }], + ]); + const serving = servingOf( + environmentStateServeInputs(environmentStateServeFixture(capture)), + ); + const loadDocument = (value: string) => + load(serving, (entities) => entities, [ + { tool: "get_settings", path: "/api/settings", value }, + ]); + + const exactLimit = "x".repeat(REPLAY_BODY_LIMIT_BYTES - 2); + expect(Buffer.byteLength(JSON.stringify(exactLimit), "utf8")).toBe( + REPLAY_BODY_LIMIT_BYTES, + ); + loadDocument(exactLimit); + expect(answer(serving, "get_settings", {})).toMatchObject({ + body: exactLimit, + replay: { truncated: false }, + state: { source: "state", view: "document" }, + }); + + // The content is under the limit as text, but JSON escaping makes the + // actual reply body exceed it. The refusal reports that encoded extent. + const escaped = "\n".repeat(REPLAY_BODY_LIMIT_BYTES / 2 + 1); + const bodyBytes = Buffer.byteLength(JSON.stringify(escaped), "utf8"); + expect(escaped.length).toBeLessThan(REPLAY_BODY_LIMIT_BYTES); + expect(bodyBytes).toBeGreaterThan(REPLAY_BODY_LIMIT_BYTES); + loadDocument(escaped); + const before = serving.store.snapshot(); + const result = answerFromState( + serving, + toolOf(serving.source, "get_settings"), + {}, + ); + expect(result.ok).toBe(false); + if (result.ok) + throw new Error("Expected the oversized state answer to be refused"); + expect(result.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + operation: "environment-serve", + resource: "capacity", + reportedLimits: { + max_body_bytes: REPLAY_BODY_LIMIT_BYTES, + body_bytes: bodyBytes, + }, + }); + expect(serving.store.snapshot()).toEqual(before); + }); + it("answers a document's recorded line with the state's value for it", () => { const capture = timedCaptureEvidence([ ["GET", "/api/settings", { theme: "dark", density: "compact" }], diff --git a/src/application/environment/environmentStateAnswer.ts b/src/application/environment/environmentStateAnswer.ts index 408db4ff0a..d369c75e61 100644 --- a/src/application/environment/environmentStateAnswer.ts +++ b/src/application/environment/environmentStateAnswer.ts @@ -32,6 +32,7 @@ import { AnalysisInputError, AnalysisResourceConstraintError, } from "../../domain/analysisErrorCore.js"; +import { jsonByteLength } from "../../domain/jsonSerialization.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; import { @@ -171,7 +172,7 @@ export const stateAnswer = ( body: JsonValue, state: StateProvenance, ): Result => { - const bytes = Buffer.byteLength(JSON.stringify(body), "utf8"); + const bytes = jsonByteLength(body); if (bytes > REPLAY_BODY_LIMIT_BYTES) return err( new AnalysisResourceConstraintError( diff --git a/src/application/environment/environmentStateStore.ts b/src/application/environment/environmentStateStore.ts index b2c7b1e482..5f0a847922 100644 --- a/src/application/environment/environmentStateStore.ts +++ b/src/application/environment/environmentStateStore.ts @@ -8,7 +8,7 @@ import { AnalysisResourceConstraintError, } from "../../domain/analysisErrorCore.js"; import type { JsonValue } from "../../domain/jsonValue.js"; -import { jsonParts } from "../../domain/jsonSerialization.js"; +import { jsonByteLength } from "../../domain/jsonSerialization.js"; import { err, ok, type Result } from "../../domain/result.js"; import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js"; @@ -65,16 +65,9 @@ export interface StateSnapshot { }; } -/** The UTF-8 bytes a value's JSON takes, counted part by part so it is never one string. */ -const jsonBytes = (value: unknown): number => { - let bytes = 0; - for (const part of jsonParts(value)) bytes += Buffer.byteLength(part, "utf8"); - return bytes; -}; - /** The bytes JSON writes for these array items and the commas between them. */ const itemsBytes = (items: readonly unknown[]): number => - items.reduce((sum, item) => sum + jsonBytes(item), 0) + + items.reduce((sum, item) => sum + jsonByteLength(item), 0) + Math.max(0, items.length - 1); /** What `data/get` reports for some content of a state. */ @@ -94,7 +87,7 @@ const snapshotOf = ( /** What `data/get` writes for a state as the Evidence holds it, in UTF-8 bytes, summed item by item. */ export const initialStateBytes = (state: EnvironmentState): number => - jsonBytes(snapshotOf(state, [], [])) + + jsonByteLength(snapshotOf(state, [], [])) + itemsBytes(state.entities) + itemsBytes(state.documents); @@ -137,7 +130,7 @@ export class EnvironmentStateStore { ); this.#clockStart = state.writeClock === null ? 0 : Date.parse(state.writeClock); - this.#frameBytes = jsonBytes(snapshotOf(state, [], [])); + this.#frameBytes = jsonByteLength(snapshotOf(state, [], [])); this.#initialBytes = initialStateBytes(state); this.reset(); } @@ -250,7 +243,7 @@ export class EnvironmentStateStore { ): Result { const entities = this.#content.entities; const bytes = this.#grown( - jsonBytes(entity) + (entities.length > 0 ? 1 : 0), + jsonByteLength(entity) + (entities.length > 0 ? 1 : 0), ); if (!bytes.ok) return bytes; const known = this.#knownCollections.get(entity.type); @@ -309,7 +302,7 @@ export class EnvironmentStateStore { const key = compositeKey([entity.type, entity.collection, entity.id]); const held = this.#entities.get(key); if (held === undefined) return ok(undefined); - const bytes = this.#grown(jsonBytes(entity) - jsonBytes(held)); + const bytes = this.#grown(jsonByteLength(entity) - jsonByteLength(held)); if (!bytes.ok) return bytes; this.#install( { @@ -333,7 +326,7 @@ export class EnvironmentStateStore { ); this.#install( { ...this.#content, entities }, - this.#bytes - jsonBytes(held) - (entities.length > 0 ? 1 : 0), + this.#bytes - jsonByteLength(held) - (entities.length > 0 ? 1 : 0), ); } @@ -345,7 +338,7 @@ export class EnvironmentStateStore { const held = this.#documents.get(tool); if (held === undefined) return ok(undefined); const written = { tool: held.tool, path: held.path, value }; - const bytes = this.#grown(jsonBytes(written) - jsonBytes(held)); + const bytes = this.#grown(jsonByteLength(written) - jsonByteLength(held)); if (!bytes.ok) return bytes; this.#install( { diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 3e6dbe7e44..67cdef3a79 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -24,7 +24,7 @@ import { createWebTextArtifact } from "../domain/webContentArtifact.js"; import { safeParseJson } from "../domain/safeJson.js"; import { sourceMapJsonText } from "../domain/sourceMapEnvelope.js"; import { resolveJavaScriptSourceMapUrl } from "../domain/javascript/javascriptSourceMapPaths.js"; -import { jsonParts } from "../domain/jsonSerialization.js"; +import { jsonByteLength } from "../domain/jsonSerialization.js"; import { inspectSourceMapValue, SourceMapFormatFailure, @@ -287,7 +287,10 @@ const fetchOne = async ( const { response, fetchedUrl } = fetched; try { // Admit the resolution identity before reading or retaining its body. - consumeExpandedBytes(budget.decodedRecords, jsonBytes(fetchedUrl) - 4); + consumeExpandedBytes( + budget.decodedRecords, + jsonByteLength(fetchedUrl) - 4, + ); } catch (cause: unknown) { try { await response.body?.cancel(); @@ -587,7 +590,7 @@ const normalizeSourceMap = ( SOURCE_MAP_SUCCESS_ENVELOPE_BYTES; consumeExpandedBytes( context.budget, - jsonBytes(text) + + jsonByteLength(text) + artifactMetadataBytes("application/source-map+json", text) - 4, ); @@ -629,7 +632,7 @@ const normalizeSourceMap = ( (originalSources.length === 0 ? 0 : 1) + (content === null ? 0 - : jsonBytes(content) + + : jsonByteLength(content) + artifactMetadataBytes(mediaType, content) - 4), ); @@ -699,7 +702,7 @@ const normalizeSourceMap = ( if (modules.incomplete.length > 0) consumeExpandedBytes( context.budget, - jsonBytes( + jsonByteLength( `Module edges are incomplete: ${modules.incomplete.length} of ${originalSources.filter(({ artifact }) => artifact !== null).length} original sources could not be parsed in full (${modules.incomplete.join(", ")}).`, ) - 4, ); @@ -938,7 +941,7 @@ const preflightResolvedSourceBytes = ( resolvedBytes += bytes; sourcesForRoot.set(sourceKey, resolved); const sanitized = cachedSanitized(sanitizedByResolved, resolved); - estimate += jsonBytes(sanitized); + estimate += jsonByteLength(sanitized); if (estimate > WEB_SOURCE_MAP_LIMITS.outputBytes) throw expandedOutputFailure(); } @@ -979,12 +982,6 @@ const resolvedSourceLimitFailure = (): SourceMapFormatFailure => `Resolved source-map identities exceed the ${String(SOURCE_MAP_RESPONSE_BYTES / (1024 * 1024))} MiB decoder representation budget. ${noRuntimeOverrideSentence("That representation budget stays fixed.", "source_map_fetch_timeout_ms")}`, ); -const jsonBytes = (value: string): number => { - let bytes = 0; - for (const part of jsonParts(value)) bytes += Buffer.byteLength(part, "utf8"); - return bytes; -}; - const artifactMetadataBytes = (mediaType: string, text: string): number => Buffer.byteLength( JSON.stringify({ @@ -1024,17 +1021,17 @@ const sourceMapFailureReservation = ( >["status"] = "fetch_failed", ): number => SOURCE_MAP_FAILURE_ENVELOPE_BYTES + - jsonBytes(request.declaredUrl) + - jsonBytes(request.scriptKey) + - (effectiveUrl === null ? 0 : jsonBytes(effectiveUrl) - 4) + + jsonByteLength(request.declaredUrl) + + jsonByteLength(request.scriptKey) + + (effectiveUrl === null ? 0 : jsonByteLength(effectiveUrl) - 4) + Math.max( 0, - (limitation === undefined ? 0 : jsonBytes(limitation)) - + (limitation === undefined ? 0 : jsonByteLength(limitation)) - SOURCE_MAP_FAILURE_LIMITATION_RESERVE - 2, ) + - jsonBytes(status) - - jsonBytes("fetch_failed"); + jsonByteLength(status) - + jsonByteLength("fetch_failed"); const sourceEntryOverheadBytes = ( sourceIndex: number, @@ -1047,7 +1044,7 @@ const sourceEntryOverheadBytes = ( const cachedJsonBytes = (cache: Map, value: string): number => { const cached = cache.get(value); if (cached !== undefined) return cached; - const bytes = jsonBytes(value); + const bytes = jsonByteLength(value); cache.set(value, bytes); return bytes; }; diff --git a/src/domain/analysisView/EnvironmentStateExtraction.ts b/src/domain/analysisView/EnvironmentStateExtraction.ts index a05402a3e4..f774ecd81f 100644 --- a/src/domain/analysisView/EnvironmentStateExtraction.ts +++ b/src/domain/analysisView/EnvironmentStateExtraction.ts @@ -1,3 +1,4 @@ +import { jsonByteLength } from "../jsonSerialization.js"; import type { JsonValue } from "../jsonValue.js"; import { compareUnicodeCodePoints as compare, @@ -15,7 +16,7 @@ import { isParam, isQueryPost, isStateWrite, - jsonBytes, + stateValueRetentionBytes, lastSegmentValue, STATE_BODY_LIMIT_BYTES, listItems, @@ -536,7 +537,7 @@ export class EnvironmentStateExtraction { if (position < first || (position === first && index < at)) entity.order = [position, index]; } - this.#retain(ENTRY_BYTES + jsonBytes(object)); + this.#retain(ENTRY_BYTES + stateValueRetentionBytes(object)); entity.observations.push({ position, ordinal: record.ordinal, @@ -642,15 +643,14 @@ export class EnvironmentStateExtraction { // Parsing can lengthen a body that was admitted under the recorded-text limit: // `1e3` is four bytes once rendered as `1000`. Serving refuses that rendering, // so an oversized document stays the recorded replay instead of a failing read. - if ( - Buffer.byteLength(JSON.stringify(value), "utf8") > - STATE_BODY_LIMIT_BYTES - ) { + if (jsonByteLength(value) > STATE_BODY_LIMIT_BYTES) { this.#recorded(tool, "rendered-oversize"); continue; } this.#retain( - ENTRY_BYTES + jsonBytes(value) + textBytes(record.path.length), + ENTRY_BYTES + + stateValueRetentionBytes(value) + + textBytes(record.path.length), ); documents.push({ tool: tool.name, diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 1ece75e3e3..1c1d98d52a 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -383,16 +383,17 @@ export const rootOf = ( }; /** Value bytes as retained state, charged before a value is kept. */ -export const jsonBytes = (value: JsonValue): number => { +export const stateValueRetentionBytes = (value: JsonValue): number => { if (typeof value === "string") return textBytes(value.length); if (Array.isArray(value)) return value.reduce( - (sum, item) => sum + jsonBytes(item), + (sum, item) => sum + stateValueRetentionBytes(item), ENTRY_BYTES, ); if (isObject(value)) return Object.entries(value).reduce( - (sum, [name, item]) => sum + textBytes(name.length) + jsonBytes(item), + (sum, [name, item]) => + sum + textBytes(name.length) + stateValueRetentionBytes(item), ENTRY_BYTES, ); return ENTRY_BYTES; diff --git a/src/domain/analysisView/environmentStateWriteCreate.ts b/src/domain/analysisView/environmentStateWriteCreate.ts index 511e034474..affefd0636 100644 --- a/src/domain/analysisView/environmentStateWriteCreate.ts +++ b/src/domain/analysisView/environmentStateWriteCreate.ts @@ -1,3 +1,4 @@ +import { jsonTextCodeUnits } from "../jsonSerialization.js"; import type { JsonValue } from "../jsonValue.js"; import { ok } from "../result.js"; import { @@ -310,7 +311,7 @@ const insertCreated = ( const members = fieldsOf( context.state.members(entity.type, entity.collection), ); - context.io.retain(64 + JSON.stringify(entity.fields).length * 2); + context.io.retain(64 + jsonTextCodeUnits(entity.fields) * 2); context.state.put( entity, draft.plan.insert === null diff --git a/src/domain/analysisView/environmentStateWriteDocument.ts b/src/domain/analysisView/environmentStateWriteDocument.ts index 740a7dedad..b15130a252 100644 --- a/src/domain/analysisView/environmentStateWriteDocument.ts +++ b/src/domain/analysisView/environmentStateWriteDocument.ts @@ -1,3 +1,4 @@ +import { jsonTextCodeUnits } from "../jsonSerialization.js"; import type { JsonValue } from "../jsonValue.js"; import { at as valueAt, @@ -140,7 +141,7 @@ export const applyDocument = ( : "recorded", pointer: null, })); - context.io.retain(64 + JSON.stringify(written.value).length * 2); + context.io.retain(64 + jsonTextCodeUnits(written.value) * 2); context.state.setDocument(tool, written.value); const fields = writtenPointers(draft, body!, { before: value!, diff --git a/src/domain/analysisView/environmentStateWriteEffects.ts b/src/domain/analysisView/environmentStateWriteEffects.ts index 8d9b3ca942..218541e015 100644 --- a/src/domain/analysisView/environmentStateWriteEffects.ts +++ b/src/domain/analysisView/environmentStateWriteEffects.ts @@ -1,3 +1,4 @@ +import { jsonTextCodeUnits } from "../jsonSerialization.js"; import type { JsonValue } from "../jsonValue.js"; import { ok } from "../result.js"; import { @@ -306,8 +307,7 @@ export const retainFixed = ( context: EffectContext, fixed: readonly { readonly sent: JsonValue }[], ): void => { - if (fixed.length > 0) - context.io.retain(64 + JSON.stringify(fixed).length * 2); + if (fixed.length > 0) context.io.retain(64 + jsonTextCodeUnits(fixed) * 2); }; /** Build an update plan from its exemplar, or add what a later call showed. */ @@ -438,10 +438,10 @@ export const applyUpdate = ( if (!written.ok || disagrees || unknown) return recorded(); const facts = { shown: scalars(after.object), sent: scalars(body), kept }; context.io.retain( - 64 + JSON.stringify([...facts.shown, ...facts.sent, kept]).length * 2, + 64 + jsonTextCodeUnits([...facts.shown, ...facts.sent, kept]) * 2, ); draft.updates.push(facts); - context.io.retain(64 + JSON.stringify(written.value).length * 2); + context.io.retain(64 + jsonTextCodeUnits(written.value) * 2); context.state.put(updatedEntity(entity, written.value)); // A later read that also shows an unmodelled change checks only the members this call sent. const sent = Object.keys(body).filter( diff --git a/src/domain/jsonSerialization.test.ts b/src/domain/jsonSerialization.test.ts index 1187f51a0d..6be452049d 100644 --- a/src/domain/jsonSerialization.test.ts +++ b/src/domain/jsonSerialization.test.ts @@ -5,11 +5,16 @@ import { bufferedJsonParts, canonicalJsonParts, jsonParts, + jsonByteLength, + jsonTextCodeUnits, equalJsonText, } from "./jsonSerialization.js"; /** Compact and pretty streamed JSON must equal native JSON. */ const expectNativeJson = (value: unknown): void => { + const text = JSON.stringify(value); + expect(jsonByteLength(value)).toBe(Buffer.byteLength(text, "utf8")); + expect(jsonTextCodeUnits(value)).toBe(text.length); expect([...bufferedJsonParts(jsonParts(value))].join("")).toBe( JSON.stringify(value), ); @@ -30,6 +35,7 @@ describe("streamed JSON representation", () => { it("preserves surrogate pairs across string and buffer boundaries", () => { const value = { text: `${"x".repeat(8190)}πŸ˜€${'δΈ­ζ–‡\n"'.repeat(20000)}` }; + expectNativeJson(value); const parts = [...bufferedJsonParts(jsonParts(value))]; expect( Buffer.concat(parts.map((part) => Buffer.from(part))).toString(), diff --git a/src/domain/jsonSerialization.ts b/src/domain/jsonSerialization.ts index a965f609e8..03129a1b5d 100644 --- a/src/domain/jsonSerialization.ts +++ b/src/domain/jsonSerialization.ts @@ -23,6 +23,20 @@ export function* jsonParts(value: unknown, pretty = false): Generator { yield* encodeValue(value, pretty, false); } +/** Exact UTF-8 bytes of ordinary JSON without allocating the whole document. */ +export const jsonByteLength = (value: unknown): number => { + let bytes = 0; + for (const part of jsonParts(value)) bytes += Buffer.byteLength(part, "utf8"); + return bytes; +}; + +/** Exact UTF-16 code units of ordinary JSON without allocating the whole document. */ +export const jsonTextCodeUnits = (value: unknown): number => { + let length = 0; + for (const part of jsonParts(value)) length += part.length; + return length; +}; + /** Encode validated JSON with canonical key ordering without allocating the whole document. */ export function* canonicalJsonParts(value: unknown): Generator { yield* encodeValue(value, false, true);