From 5a61853a697a742153c9c3f797e7d58dc7e4ee5f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 20 Oct 2026 08:55:51 -0700 Subject: [PATCH] fix(environment): release batch replies as they are encoded; judge exact numbers by value answerBatch kept every settled reply in its pending array until the batch ended, so a batch alternating data/add and data/get held each loaded state alive; each reply now leaves the queue as it is encoded. The exact-number policy judged a number by its recorded spelling, so a state loaded with 1e20 came back from data/get as 100000000000000000000, which data/add refused, breaking the promise that data/get output loads back unchanged. A number is exact only when both its recorded spelling and the one serialization writes are safe, so integers from 2^53 below 1e21 are refused in any spelling; replay keeps such bodies as recorded text. A domain test checks that every accepted number is accepted again once written back. Co-Authored-By: Claude Opus 5.5 --- docs/agent-environments.md | 4 ++- .../environmentReplayNumbers.test.ts | 1 + src/domain/exactJsonNumber.test.ts | 33 +++++++++++++++++++ src/domain/exactJsonNumber.ts | 11 +++++-- src/server/environment/serveEnvironment.ts | 3 +- .../environment/serveEnvironmentState.test.ts | 2 ++ 6 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/domain/exactJsonNumber.test.ts diff --git a/docs/agent-environments.md b/docs/agent-environments.md index d65e2c40de..f674cd7e26 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -478,7 +478,9 @@ calls find entities under the checked line's recorded path. Each document names a `document` view once, at its initial document's `path`. The `version`, and the `state_evidence_id` and `types` that `data/get` reports, must match the served state, so a `data/get` result loads back unchanged. A number without -an exact native value or a `__proto__` member is refused rather than altered. +an exact native value, or whose serialized spelling would not be read as exact +(an integer from 2^53 below 1e21, such as `1e20`, in any spelling), or a +`__proto__` member is refused rather than altered. An invalid document is `-32602` naming the first problem by JSON pointer, as in `/rea_state/entities/3/collection` (also in `error.data.path`); a problem inside caller data (`fields`, `value`, `types`) is located at that member, so diff --git a/src/application/environment/environmentReplayNumbers.test.ts b/src/application/environment/environmentReplayNumbers.test.ts index 4e21219b4a..11b34ca391 100644 --- a/src/application/environment/environmentReplayNumbers.test.ts +++ b/src/application/environment/environmentReplayNumbers.test.ts @@ -18,6 +18,7 @@ describe("replay JSON value fidelity", () => { '{"id":9007199254740993}', '{"id":9007199254740993.0}', '{"id":9.007199254740993e15}', + '{"id":1e20}', '[{"id":18446744073709551615}]', '{"value":0.100000000000000005}', '{"value":1e-1000}', diff --git a/src/domain/exactJsonNumber.test.ts b/src/domain/exactJsonNumber.test.ts new file mode 100644 index 0000000000..d41b1dd564 --- /dev/null +++ b/src/domain/exactJsonNumber.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "vitest"; + +import { isExactJsonNumber } from "./exactJsonNumber.js"; + +const spellings = [ + "0", + "42", + "-7", + "1.5", + "0.1", + "1e3", + "1e-7", + "9007199254740991", + "9007199254740992", + "9.007199254740992e15", + "1e20", + "100000000000000000000", + "1e21", + "1.7976931348623157e308", +]; + +it("judges a number alike in every spelling, so an accepted number is accepted again once written back", () => { + for (const spelling of spellings) { + const value = Number(spelling); + if (isExactJsonNumber(value, spelling)) + expect(isExactJsonNumber(value, String(value)), spelling).toBe(true); + } + expect(isExactJsonNumber(1e20, "1e20")).toBe(false); + expect(isExactJsonNumber(1e20, "100000000000000000000")).toBe(false); + expect(isExactJsonNumber(1e21, "1e21")).toBe(true); + expect(isExactJsonNumber(9007199254740991, "9007199254740991")).toBe(true); + expect(isExactJsonNumber(-0, "-0")).toBe(false); +}); diff --git a/src/domain/exactJsonNumber.ts b/src/domain/exactJsonNumber.ts index 19f828023e..77191f1e25 100644 --- a/src/domain/exactJsonNumber.ts +++ b/src/domain/exactJsonNumber.ts @@ -2,12 +2,19 @@ import { isSafeNumber } from "lossless-json"; import { jsonValueSchema, type JsonValue } from "./jsonValue.js"; import { safeParseJson } from "./safeJson.js"; -/** Whether native JSON conversion and serialization preserve the recorded number. */ +/** + * Whether native JSON conversion and serialization preserve the recorded number. Both the recorded + * spelling and the one serialization writes must be safe, so `1e20` and `100000000000000000000` + * are judged alike and anything accepted is accepted again once written back. + */ export const isExactJsonNumber = ( value: number, source: string | undefined, ): boolean => - source !== undefined && isSafeNumber(source) && !Object.is(value, -0); + source !== undefined && + isSafeNumber(source) && + isSafeNumber(String(value)) && + !Object.is(value, -0); /** Parse JSON text whose every number survives native conversion exactly; undefined otherwise. */ export const parseExactJson = (text: string): JsonValue | undefined => { diff --git a/src/server/environment/serveEnvironment.ts b/src/server/environment/serveEnvironment.ts index a5ae88e0b9..81fe260cf6 100644 --- a/src/server/environment/serveEnvironment.ts +++ b/src/server/environment/serveEnvironment.ts @@ -248,7 +248,8 @@ const answerBatch = async ( for (const reply of pending) void reply.catch(() => undefined); const encode = replyEncoder(); const texts: string[] = []; - for (const next of pending) { + // Taking each settled reply off the queue releases it, and any state it holds, once encoded. + for (let next = pending.shift(); next !== undefined; next = pending.shift()) { const reply = await next; if (reply !== undefined) texts.push(encode(reply)); } diff --git a/src/server/environment/serveEnvironmentState.test.ts b/src/server/environment/serveEnvironmentState.test.ts index 6e9fbac658..15f6325128 100644 --- a/src/server/environment/serveEnvironmentState.test.ts +++ b/src/server/environment/serveEnvironmentState.test.ts @@ -202,6 +202,8 @@ describe("state data plane", () => { }); for (const altered of [ '"login":9007199254740993', + // Exact in this spelling, but data/get would write it as digits it could not load again. + '"login":1e20', '"__proto__":{"polluted":true},"login":"renamed"', ]) { const reply = await plane.post(