From 889849b243af9b4e0fc5f950c9151b9c92364e25 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 26 Oct 2026 21:50:02 +0900 Subject: [PATCH 1/3] feat(rpc): stage bounded structured value storage (part of #4272) Co-authored-by: sisyphus-dev-ai --- changes/unreleased/s03b1b-Ia.md | 20 ++ .../src/modes/rpc/compact-content-store.ts | 98 ++++++-- .../src/modes/rpc/compact-spool-storage.ts | 6 +- .../src/modes/rpc/compact-storage-io.ts | 1 + .../src/modes/rpc/compact-text-roots.ts | 26 ++- .../src/modes/rpc/compact-values.ts | 219 ++++++++++++++++++ .../4272-bounded-text-roots.test.ts | 18 ++ .../4272-compact-exact-membership.test.ts | 53 +++++ ...2-structured-value-codec-test-only.test.ts | 23 ++ .../4272-structured-value-codec.test.ts | 68 ++++++ .../regressions/4272-text-root-height.test.ts | 30 +++ 11 files changed, 536 insertions(+), 26 deletions(-) create mode 100644 changes/unreleased/s03b1b-Ia.md create mode 100644 packages/coding-agent/src/modes/rpc/compact-values.ts create mode 100644 packages/coding-agent/test/suite/regressions/4272-compact-exact-membership.test.ts create mode 100644 packages/coding-agent/test/suite/regressions/4272-structured-value-codec-test-only.test.ts create mode 100644 packages/coding-agent/test/suite/regressions/4272-structured-value-codec.test.ts create mode 100644 packages/coding-agent/test/suite/regressions/4272-text-root-height.test.ts diff --git a/changes/unreleased/s03b1b-Ia.md b/changes/unreleased/s03b1b-Ia.md new file mode 100644 index 0000000000..d5d14e724f --- /dev/null +++ b/changes/unreleased/s03b1b-Ia.md @@ -0,0 +1,20 @@ + +## 2026-10-26 - Stage the bounded structured-value codec (S03b-1b-I-a, part of #4272) + +### What changed + +- `packages/coding-agent/src/modes/rpc/compact-values.ts` owns structured values in bounded field, array, and string pages, with shared immutable dependencies and cleanup of failed descriptor merges. It has no production caller in this increment; S03b-1b-I-b wires the settled index and removes the staging guard. +- `packages/coding-agent/src/modes/rpc/compact-content-store.ts`, `compact-spool-storage.ts`, and `compact-storage-io.ts` account for structural payloads separately, coalesce their spill scheduling, and expose typed write-side limit failures. +- `packages/coding-agent/src/modes/rpc/compact-text-roots.ts` owns shared dependency roots and grows the append tree only when the subtree is full. + +### Why + +- Bounded index descriptors need spillable values and keys before the index can depend on them. Failed storage must retain accepted content, and write failures must not be classified as reads. + +### Why an extension could not handle it + +- These modules own storage references, spool completion, and recovery reads below extension delivery. + +### Expected merge conflict zones + +- The compact store's leaf accounting, text-root construction, and spool operation types. All paths are fork-owned. diff --git a/packages/coding-agent/src/modes/rpc/compact-content-store.ts b/packages/coding-agent/src/modes/rpc/compact-content-store.ts index c282f6fa34..f89de19529 100644 --- a/packages/coding-agent/src/modes/rpc/compact-content-store.ts +++ b/packages/coding-agent/src/modes/rpc/compact-content-store.ts @@ -17,7 +17,18 @@ export const COMPACT_LEAF_CODE_UNITS = 32_768; export type LeafId = number; +export class CompactWriteError extends Error { + readonly reason: "size_limit" | "storage_unavailable"; + + constructor(reason: CompactWriteError["reason"]) { + super(`compact write refused (${reason})`); + this.name = "CompactWriteError"; + this.reason = reason; + } +} + export interface CompactStorageStatus { + readonly structuredBytes: { readonly resident: number; readonly spilled: number; readonly pinned: number }; readonly residentPayloadBytes: number; readonly pendingIoCopyBytes: number; readonly pinnedOnlyBytes: number; @@ -40,6 +51,7 @@ export interface CompactContentStoreOptions { } interface Leaf { + readonly value: boolean; text: string | undefined; readonly units: number; live: number; @@ -54,6 +66,9 @@ export class CompactContentStore extends CompactSpoolStorage { private readonly leaves = new Map(); /** Resident leaves in creation order, so each spill takes the oldest without rescanning committed ones. */ private readonly ramLeaves = new Set(); + private readonly ramValues = new Set(); + private valueQueued = false; + private readonly structuredBytes = { resident: 0, spilled: 0, pinned: 0 }; private nextLeafId = 1; private residentPayloadBytes = 0; private pinnedOnlyBytes = 0; @@ -82,13 +97,14 @@ export class CompactContentStore extends CompactSpoolStorage { } /** Takes ownership of an immutable value with one live reference. */ - createLeaf(text: string): LeafId { + createLeaf(text: string, value = false): LeafId { this.assertReadable(); - if (text.length > COMPACT_LEAF_CODE_UNITS) throw new CompactReadError("size_limit"); + if (text.length > COMPACT_LEAF_CODE_UNITS) throw new CompactWriteError("size_limit"); const id = this.nextLeafId++; - this.leaves.set(id, { text, units: text.length, live: 1, pins: 0, state: "ram" }); - this.ramLeaves.add(id); - this.residentPayloadBytes += text.length * 2; + this.leaves.set(id, { text, units: text.length, value, live: 1, pins: 0, state: "ram" }); + (value ? this.ramValues : this.ramLeaves).add(id); + if (value) this.structuredBytes.resident += text.length * 2; + else this.residentPayloadBytes += text.length * 2; this.scheduleSpill(); return id; } @@ -102,16 +118,22 @@ export class CompactContentStore extends CompactSpoolStorage { const pinnedOnly = leaf.live === 0 && leaf.pins > 0; leaf.live += live; leaf.pins += pins; - if (pinnedOnly !== (leaf.live === 0 && leaf.pins > 0)) this.pinnedOnlyBytes += (pinnedOnly ? -2 : 2) * leaf.units; + if (pinnedOnly !== (leaf.live === 0 && leaf.pins > 0)) { + const bytes = (pinnedOnly ? -2 : 2) * leaf.units; + if (leaf.value) this.structuredBytes.pinned += bytes; + else this.pinnedOnlyBytes += bytes; + } if (leaf.live > 0 || leaf.pins > 0) return; this.leaves.delete(id); - this.ramLeaves.delete(id); + (leaf.value ? this.ramValues : this.ramLeaves).delete(id); if (leaf.state === "committed" && leaf.path) { - this.spilledLiveBytes -= leaf.units * 2; + if (leaf.value) this.structuredBytes.spilled -= leaf.units * 2; + else this.spilledLiveBytes -= leaf.units * 2; this.discard(leaf.path, leaf.units * 2); return; } - this.residentPayloadBytes -= leaf.units * 2; + if (leaf.value) this.structuredBytes.resident -= leaf.units * 2; + else this.residentPayloadBytes -= leaf.units * 2; } isCommitted(id: LeafId): boolean { @@ -135,6 +157,7 @@ export class CompactContentStore extends CompactSpoolStorage { status(): CompactStorageStatus { return { + structuredBytes: { ...this.structuredBytes }, residentPayloadBytes: this.residentPayloadBytes, pendingIoCopyBytes: this.pendingIoCopyBytes, pinnedOnlyBytes: this.pinnedOnlyBytes, @@ -150,6 +173,23 @@ export class CompactContentStore extends CompactSpoolStorage { } protected scheduleSpill(): void { + if ( + !this.valueQueued && + this.spillAllowed && + this.ramValues.size && + this.reclaimDebtBytes <= RECLAIM_WORK_ALLOWANCE_BYTES + ) { + this.valueQueued = true; + this.enqueue("value_write", async () => { + const [id] = this.ramValues; + try { + if (id !== undefined) await this.flushLeaf(id); + } finally { + this.valueQueued = false; + } + this.scheduleSpill(); + }); + } if (this.spillQueued || !this.spillAllowed || this.residentPayloadBytes <= this.spillAboveBytes) return; if (this.reclaimDebtBytes > RECLAIM_WORK_ALLOWANCE_BYTES) return; this.spillQueued = true; @@ -159,22 +199,34 @@ export class CompactContentStore extends CompactSpoolStorage { /** Spills one oldest resident leaf per job, so other storage work interleaves fairly. */ private async spillNext(): Promise { const [id] = this.ramLeaves; - const leaf = id === undefined ? undefined : this.leaves.get(id); - if (id === undefined || !leaf || leaf.text === undefined || !this.spillAllowed) { - this.spillQueued = false; - return; + if (id !== undefined) await this.flushLeaf(id); + this.spillQueued = false; + if (this.residentPayloadBytes > this.spillTargetBytes) { + this.spillQueued = this.spillAllowed && this.ramLeaves.size > 0; + if (this.spillQueued) this.enqueue("spill_write", () => this.spillNext()); } + } + + /** Serial storage jobs can flush one newly built structural page without waiting on their own queue. */ + async flushLeaf(id: LeafId): Promise { + const leaf = this.leaves.get(id); + if (!leaf || leaf.state !== "ram" || leaf.text === undefined || !this.spillAllowed) return; const bytes = leaf.units * 2; leaf.state = "pending"; - this.ramLeaves.delete(id); + const ram = leaf.value ? this.ramValues : this.ramLeaves; + ram.delete(id); let path: string | undefined; try { - path = await this.writeFile(Buffer.from(leaf.text, "utf16le"), "spill_write", bytes); + path = await this.writeFile( + Buffer.from(leaf.text, "utf16le"), + leaf.value ? "value_write" : "spill_write", + bytes, + ); } finally { // A failed write, or a failure report that threw, keeps the leaf resident and spillable. if (path === undefined) { leaf.state = "ram"; - if (this.leaves.get(id) === leaf) this.ramLeaves.add(id); + if (this.leaves.get(id) === leaf) ram.add(id); } } const owned = this.leaves.get(id) === leaf; @@ -184,14 +236,14 @@ export class CompactContentStore extends CompactSpoolStorage { leaf.state = "committed"; leaf.path = path; leaf.text = undefined; - this.residentPayloadBytes -= bytes; - this.spilledLiveBytes += bytes; + if (leaf.value) { + this.structuredBytes.resident -= bytes; + this.structuredBytes.spilled += bytes; + } else { + this.residentPayloadBytes -= bytes; + this.spilledLiveBytes += bytes; + } this.committed(id); } - this.spillQueued = false; - if (this.residentPayloadBytes > this.spillTargetBytes) { - this.spillQueued = this.spillAllowed; - if (this.spillQueued) this.enqueue("spill_write", () => this.spillNext()); - } } } diff --git a/packages/coding-agent/src/modes/rpc/compact-spool-storage.ts b/packages/coding-agent/src/modes/rpc/compact-spool-storage.ts index cc6776f97c..37c30aa9bd 100644 --- a/packages/coding-agent/src/modes/rpc/compact-spool-storage.ts +++ b/packages/coding-agent/src/modes/rpc/compact-spool-storage.ts @@ -112,7 +112,8 @@ export abstract class CompactSpoolStorage { ): Promise { const path = await this.ensureDir(retainedInMemoryBytes); if (path === undefined) return undefined; - const file = join(path, `${operation === "spill_write" ? "leaf" : "page"}-${this.nextFile++}.bin`); + const prefix = operation === "spill_write" ? "leaf" : operation === "value_write" ? "value" : "page"; + const file = join(path, `${prefix}-${this.nextFile++}.bin`); this.pendingIoCopyBytes += data.byteLength; try { await this.io.writeFile(file, data); @@ -206,7 +207,8 @@ export abstract class CompactSpoolStorage { } private fail(operation: CompactStorageOperation, path: string, cause: unknown, retained: number): void { - if (operation === "spill_write" || operation === "spool_create") this.halted = true; + if (operation === "spill_write" || operation === "value_write" || operation === "spool_create") + this.halted = true; this.record({ operation, path, cause: describeCause(cause), retainedInMemoryBytes: retained }); } diff --git a/packages/coding-agent/src/modes/rpc/compact-storage-io.ts b/packages/coding-agent/src/modes/rpc/compact-storage-io.ts index 3178e2e020..9469c9b59b 100644 --- a/packages/coding-agent/src/modes/rpc/compact-storage-io.ts +++ b/packages/coding-agent/src/modes/rpc/compact-storage-io.ts @@ -8,6 +8,7 @@ import { readFile, unlink, writeFile } from "node:fs/promises"; export type CompactStorageOperation = | "spool_create" | "spill_write" + | "value_write" | "page_write" | "leaf_read" | "page_read" diff --git a/packages/coding-agent/src/modes/rpc/compact-text-roots.ts b/packages/coding-agent/src/modes/rpc/compact-text-roots.ts index 540934b308..fbe2044f0d 100644 --- a/packages/coding-agent/src/modes/rpc/compact-text-roots.ts +++ b/packages/coding-agent/src/modes/rpc/compact-text-roots.ts @@ -7,6 +7,7 @@ interface Node { readonly right: LeafId; readonly units: number; readonly height: number; + readonly full: boolean; live: number; pins: number; } @@ -27,6 +28,25 @@ export class CompactTextRoots { return this.nodes.size; } + /** One owned dependency root; callers retain their existing references. */ + own(refs: readonly LeafId[]): LeafId { + if (refs.length === 0) return 0; + if (refs.length === 1) { + const [ref] = refs; + this.store.adjust(ref, 1, 0); + return ref; + } + const middle = Math.floor(refs.length / 2); + const left = this.own(refs.slice(0, middle)); + const right = this.own(refs.slice(middle)); + if (left === 0) return right; + if (right === 0) return left; + const root = this.join(left, right); + this.store.adjust(left, -1, 0); + this.store.adjust(right, -1, 0); + return root; + } + units(id: LeafId): number { return id === 0 ? 0 : id > 0 ? this.store.leafUnits(id) : this.node(id).units; } @@ -129,7 +149,7 @@ export class CompactTextRoots { private appendLeaf(root: LeafId, leaf: LeafId): LeafId { const node = this.nodes.get(root); - if (!node || this.height(node.left) === this.height(node.right)) return this.join(root, leaf); + if (!node || node.full) return this.join(root, leaf); const right = this.appendLeaf(node.right, leaf); const result = this.join(node.left, right); this.store.adjust(right, -1, 0); @@ -147,6 +167,10 @@ export class CompactTextRoots { right, units: this.units(left) + this.units(right), height: 1 + Math.max(this.height(left), this.height(right)), + full: + this.height(left) === this.height(right) && + (this.nodes.get(left)?.full ?? true) && + (this.nodes.get(right)?.full ?? true), live: 1, pins: 0, }); diff --git a/packages/coding-agent/src/modes/rpc/compact-values.ts b/packages/coding-agent/src/modes/rpc/compact-values.ts new file mode 100644 index 0000000000..b174d2abb8 --- /dev/null +++ b/packages/coding-agent/src/modes/rpc/compact-values.ts @@ -0,0 +1,219 @@ +import { type CompactContentStore, type LeafId, CompactWriteError } from "./compact-content-store.ts"; +import { CompactReadError } from "./compact-read-lease.ts"; + +export interface ValueRef { + readonly node: LeafId; + readonly owner: LeafId; +} +type Field = readonly [ValueRef, ValueRef]; +export type ValuePage = + | { readonly kind: "scalar"; readonly value: number | boolean | null } + | { readonly kind: "string"; readonly text: string; readonly next?: ValueRef } + | { readonly kind: "array"; readonly items: readonly ValueRef[]; readonly next?: ValueRef } + | { readonly kind: "object"; readonly items: readonly Field[]; readonly next?: ValueRef }; + +/** Field/array descriptors and string chunks live in bounded leaves; owners share immutable subtrees. */ +export class CompactValues { + private readonly store: CompactContentStore; + residentPages = 0; + peakResidentPages = 0; + + constructor(store: CompactContentStore) { + this.store = store; + } + + record(fields: ReadonlyMap): ValueRef { + const entries = [...fields]; + let next: ValueRef | undefined; + for (let end = entries.length; end > 0 || !next; end = Math.max(0, end - 128)) { + const items: Field[] = entries + .slice(Math.max(0, end - 128), end) + .map(([key, value]) => [this.own(key), value]); + const ref = this.make({ kind: "object", items, next }); + for (const [key] of items) this.store.adjust(key.owner, -1, 0); + if (next) this.store.adjust(next.owner, -1, 0); + next = ref; + if (end <= 128) break; + } + return next; + } + + own(value: unknown): ValueRef { + if (typeof value === "string") { + let next: ValueRef | undefined; + for (let end = value.length; end > 0 || next === undefined; end = Math.max(0, end - 4096)) { + const ref = this.make({ kind: "string", text: value.slice(Math.max(0, end - 4096), end), next }); + if (next) this.store.adjust(next.owner, -1, 0); + next = ref; + if (end <= 4096) break; + } + return next; + } + if (value === null || typeof value !== "object") { + return this.make({ + kind: "scalar", + value: typeof value === "boolean" || typeof value === "number" ? value : null, + }); + } + const array = Array.isArray(value); + const entries = array ? value : Object.entries(value); + let next: ValueRef | undefined; + for (let end = entries.length; end > 0 || next === undefined; end = Math.max(0, end - 128)) { + const slice = entries.slice(Math.max(0, end - 128), end); + const items = array ? slice.map((item: unknown) => this.own(item)) : []; + const fields: Field[] = array + ? [] + : slice.map(([key, item]: [string, unknown]) => [this.own(key), this.own(item)]); + const ref = this.make(array ? { kind: "array", items, next } : { kind: "object", items: fields, next }); + for (const child of [...items, ...fields.flat(), ...(next ? [next] : [])]) + this.store.adjust(child.owner, -1, 0); + next = ref; + if (end <= 128) break; + } + return next; + } + + async page(ref: ValueRef) { + this.residentPages++; + this.peakResidentPages = Math.max(this.peakResidentPages, this.residentPages); + try { + const text = await this.store.readLeaf(ref.node); + if (Buffer.byteLength(text) > 65_536) throw new CompactReadError("size_limit"); + const page: ValuePage = JSON.parse(text); + if ((page.kind === "object" || page.kind === "array") && page.items.length > 128) + throw new CompactReadError("count_limit"); + return { + value: page, + [Symbol.dispose]: () => { + this.residentPages--; + }, + }; + } catch (error) { + this.residentPages--; + throw error; + } + } + + async read(ref: ValueRef): Promise { + using held = await this.page(ref); + const page = held.value; + switch (page.kind) { + case "scalar": + return page.value; + case "string": { + let result = page.text; + let next = page.next; + while (next) { + using held = await this.page(next); + const part = held.value; + if (part.kind !== "string") throw new CompactReadError("ownership"); + result += part.text; + next = part.next; + } + return result; + } + case "array": { + const result: unknown[] = []; + let current: ValueRef | undefined = ref; + while (current) { + using held = await this.page(current); + const part = held.value; + if (part.kind !== "array") throw new CompactReadError("ownership"); + for (const child of part.items) result.push(await this.read(child)); + current = part.next; + } + return result; + } + case "object": { + const result: Record = {}; + for await (const [key, value] of this.fields(ref)) { + Object.defineProperty(result, key, { + value: await this.read(value), + enumerable: true, + configurable: true, + }); + } + return result; + } + } + } + + async *fields(ref: ValueRef): AsyncGenerator { + let next: ValueRef | undefined = ref; + while (next) { + using held = await this.page(next); + const page = held.value; + if (page.kind !== "object") throw new CompactReadError("ownership"); + for (const [key, value] of page.items) { + const name = await this.read(key); + if (typeof name !== "string") throw new CompactReadError("ownership"); + yield [name, value, key]; + } + next = page.next; + } + } + + /** Patches are coalesced before this call; copy descriptors, never the fields' payloads. */ + async merge(base: ValueRef, patch: ReadonlyMap): Promise { + let reversed: ValueRef | undefined; + let result: ValueRef | undefined; + let buffer: Field[] = []; + const seen = new Set(); + const flush = async () => { + const ref = this.make({ kind: "object", items: buffer, next: reversed }); + if (reversed) this.store.adjust(reversed.owner, -1, 0); + reversed = ref; + buffer = []; + await this.store.flushLeaf(ref.node); + if (!this.store.isCommitted(ref.node)) throw new CompactWriteError("storage_unavailable"); + }; + try { + for await (const [name, value, key] of this.fields(base)) { + if (patch.has(name)) seen.add(name); + buffer.push([key, patch.get(name) ?? value]); + if (buffer.length === 128) await flush(); + } + for (const [name, value] of patch) { + if (seen.has(name)) continue; + const key = this.own(name); + buffer.push([key, value]); + // The key's initial reference is transferred to the completed page below. + try { + await flush(); + } finally { + this.store.adjust(key.owner, -1, 0); + } + } + if (buffer.length || !reversed) await flush(); + while (reversed) { + using held = await this.page(reversed); + const page = held.value; + if (page.kind !== "object") throw new CompactReadError("ownership"); + const ref = this.make({ kind: "object", items: page.items, next: result }); + if (result) this.store.adjust(result.owner, -1, 0); + result = ref; + await this.store.flushLeaf(ref.node); + if (!this.store.isCommitted(ref.node)) throw new CompactWriteError("storage_unavailable"); + const next = page.next; + if (next) this.store.adjust(next.owner, 1, 0); + this.store.adjust(reversed.owner, -1, 0); + reversed = next; + } + if (!result) throw new CompactReadError("ownership"); + return result; + } catch (error) { + if (reversed) this.store.adjust(reversed.owner, -1, 0); + if (result) this.store.adjust(result.owner, -1, 0); + throw error; + } + } + + private make(page: ValuePage): ValueRef { + const children = page.kind === "object" ? page.items.flat() : page.kind === "array" ? [...page.items] : []; + if (page.kind !== "scalar" && page.next) children.push(page.next); + const node = this.store.createLeaf(JSON.stringify(page), true); + const owner = this.store.text.own([node, ...children.map((ref) => ref.owner)]); + this.store.adjust(node, -1, 0); + return { node, owner }; + } +} diff --git a/packages/coding-agent/test/suite/regressions/4272-bounded-text-roots.test.ts b/packages/coding-agent/test/suite/regressions/4272-bounded-text-roots.test.ts index 228de1a60d..de5a0936cb 100644 --- a/packages/coding-agent/test/suite/regressions/4272-bounded-text-roots.test.ts +++ b/packages/coding-agent/test/suite/regressions/4272-bounded-text-roots.test.ts @@ -9,6 +9,24 @@ afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); }); +it("rejects an oversized leaf with a write-side error before changing ownership or accounting", async () => { + const root = await spoolRoot(); + cleanups.push(root.cleanup); + const store = new CompactContentStore({ dir: root.dir }); + const ref = store.createLeaf("x".repeat(LEAF)); + try { + const before = store.status(); + expect(() => store.createLeaf("x".repeat(LEAF + 1))).toThrow( + expect.objectContaining({ name: "CompactWriteError", reason: "size_limit" }), + ); + expect(store.status()).toEqual(before); + expect(await store.readLeaf(ref)).toBe("x".repeat(LEAF)); + } finally { + store.adjust(ref, -1, 0); + await store.retire(); + } +}); + it("reads exactly the size/count boundary and rejects either excess without reading", async () => { const root = await spoolRoot(); cleanups.push(root.cleanup); diff --git a/packages/coding-agent/test/suite/regressions/4272-compact-exact-membership.test.ts b/packages/coding-agent/test/suite/regressions/4272-compact-exact-membership.test.ts new file mode 100644 index 0000000000..3de640e5b2 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/4272-compact-exact-membership.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "vitest"; +import { CompactSessionState } from "../../../src/modes/rpc/compact-session-state.ts"; +import { spoolRoot } from "../compact-storage-support.ts"; + +// senpi#4272 / RULING-34: resident membership remains exact at both independently shippable heads. +it("rejects duplicate settled starts and unknown operations without changing the accepted root", async () => { + const root = await spoolRoot(); + const state = new CompactSessionState({ + dir: root.dir, + spillAboveBytes: 0, + spillTargetBytes: 0, + compactAboveRows: 1, + }); + try { + state.accept({ + type: "block_start", + messageId: "old", + contentIndex: 0, + kind: "text", + seed: "body", + coveredCodeUnits: 0, + }); + state.accept({ type: "message_status", messageId: "old", persistence: "pending" }); + await state.idle(); + const sequence = state.acceptedSequence; + const status = state.storageStatus(); + for (const [transition, reason] of [ + [ + { type: "block_start", messageId: "old", contentIndex: 1, kind: "text", seed: "", coveredCodeUnits: 0 }, + "duplicate_block", + ], + [{ type: "message_status", messageId: "missing", persistence: "failed" }, "unknown_message"], + [{ type: "message_remove", messageId: "missing" }, "unknown_message"], + ] as const) + expect(() => state.accept(transition)).toThrow(expect.objectContaining({ reason })); + expect(state.acceptedSequence).toBe(sequence); + expect(state.storageStatus()).toEqual(status); + state.accept({ type: "message_status", messageId: "old", persistence: "appended" }); + const cut = state.freeze(); + try { + expect((await state.read(cut)).messages[0]).toEqual({ + messageId: "old", + persistence: "appended", + blocks: [{ contentIndex: 0, kind: "text", text: "body" }], + }); + } finally { + state.release(cut); + } + } finally { + await state.retire(); + await root.cleanup(); + } +}); diff --git a/packages/coding-agent/test/suite/regressions/4272-structured-value-codec-test-only.test.ts b/packages/coding-agent/test/suite/regressions/4272-structured-value-codec-test-only.test.ts new file mode 100644 index 0000000000..8ea3e258e6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/4272-structured-value-codec-test-only.test.ts @@ -0,0 +1,23 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +// senpi#4272 / S03b-1b-I-a staging contract: I-b removes this guard when it wires the codec. +// Check references, not just calls: aliases, namespace imports, re-exports, and stored values count. +it("keeps every structured codec entry point out of production until the index increment", async () => { + const src = fileURLToPath(new URL("../../../src", import.meta.url)); + const owner = join("modes", "rpc", "compact-values.ts"); + const source = await readFile(join(src, owner), "utf8"); + const names = [...source.matchAll(/export\s+(?:class|interface|type|function|const)\s+(\w+)/g)].map( + (match) => match[1], + ); + expect(names.length).toBeGreaterThan(0); + const reference = new RegExp(`\\b(?:${names.join("|")})\\b|compact-values(?:\\.[cm]?ts|\\.[cm]?js)?`); + const offenders: string[] = []; + for (const file of await readdir(src, { recursive: true, encoding: "utf8" })) { + if (file === owner || !/\.[cm]?tsx?$/.test(file)) continue; + if (reference.test(await readFile(join(src, file), "utf8"))) offenders.push(file); + } + expect(offenders, "the staged codec gained a production reference").toEqual([]); +}); diff --git a/packages/coding-agent/test/suite/regressions/4272-structured-value-codec.test.ts b/packages/coding-agent/test/suite/regressions/4272-structured-value-codec.test.ts new file mode 100644 index 0000000000..08c35386bf --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/4272-structured-value-codec.test.ts @@ -0,0 +1,68 @@ +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { CompactContentStore } from "../../../src/modes/rpc/compact-content-store.ts"; +import { CompactValues } from "../../../src/modes/rpc/compact-values.ts"; +import { controlledIo, spoolRoot } from "../compact-storage-support.ts"; + +// senpi#4272: the first staged commit has no production caller; the index commit wires this codec. +it("owns nested input and oversized keys while every stored field or string page stays bounded", async () => { + const root = await spoolRoot(); + const control = controlledIo(); + const store = new CompactContentStore({ dir: root.dir, io: control.io }); + const codec = new CompactValues(store); + const key = "가".repeat(40_000); + const body = "\uD800" + "x".repeat(100_000); + const input = { [key]: body, nested: { n: 1 } }; + const write = control.hold("write"); + const ref = codec.own(input); + try { + await write.entered; + input[key] = "changed"; + input.nested.n = 99; + write.release(); + await store.idle(); + for (const file of await readdir(root.dir)) { + if (file.startsWith("value-")) expect((await stat(join(root.dir, file))).size).toBeLessThanOrEqual(65_536); + } + expect(await codec.read(ref)).toEqual({ [key]: body, nested: { n: 1 } }); + expect(store.status().structuredBytes.resident).toBe(0); + store.adjust(ref.owner, -1, 0); + await store.idle(); + expect(store.status().structuredBytes).toEqual({ resident: 0, spilled: 0, pinned: 0 }); + expect(store.text.residentNodes).toBe(0); + } finally { + write.release(); + await store.retire(); + await root.cleanup(); + } +}); + +it.each([128, 129])("traverses %i array entries through count-bounded pages without truncating", async (count) => { + const root = await spoolRoot(); + const store = new CompactContentStore({ dir: root.dir }); + const codec = new CompactValues(store); + const input = Array.from({ length: count }, (_, n) => n); + const ref = codec.own(input); + try { + await store.idle(); + let cursor: typeof ref | undefined = ref; + let seen = 0; + while (cursor) { + using page = await codec.page(cursor); + if (page.value.kind !== "array") throw new Error("expected array descriptors"); + expect(page.value.items.length).toBeLessThanOrEqual(128); + expect(Buffer.byteLength(JSON.stringify(page.value))).toBeLessThanOrEqual(65_536); + seen += page.value.items.length; + cursor = page.value.next; + } + expect(seen).toBe(count); + expect(await codec.read(ref)).toEqual(input); + store.adjust(ref.owner, -1, 0); + await store.idle(); + expect(codec.residentPages).toBe(0); + } finally { + await store.retire(); + await root.cleanup(); + } +}); diff --git a/packages/coding-agent/test/suite/regressions/4272-text-root-height.test.ts b/packages/coding-agent/test/suite/regressions/4272-text-root-height.test.ts new file mode 100644 index 0000000000..de88f62816 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/4272-text-root-height.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vitest"; +import { CompactContentStore } from "../../../src/modes/rpc/compact-content-store.ts"; +import { spoolRoot } from "../compact-storage-support.ts"; + +// senpi#4272 / #4378 LOW: equal heights do not imply a full right subtree. +// Inspect the existing private height rather than adding a production API just for this invariant. +it("keeps append-tree height logarithmic at 1,024 and 1,048,576 real leaves", async () => { + const root = await spoolRoot(); + const store = new CompactContentStore({ dir: root.dir, spillAboveBytes: Number.POSITIVE_INFINITY }); + let ref = 0; + try { + for (let count = 1; count <= 2 ** 20; count++) { + ref = store.text.append(ref, "x"); + if (count === 1024 || count === 2 ** 20) { + expect + .soft(store.text["height"](ref), `${count} leaves`) + .toBeLessThanOrEqual(Math.ceil(Math.log2(count)) + 1); + expect(store.text.units(ref)).toBe(count); + expect(store.text.residentNodes).toBe(count - 1); + } + } + expect(await store.text.read(ref, "", 2 ** 20 - 1, 1, 1)).toBe("x"); + } finally { + store.adjust(ref, -1, 0); + await store.retire(); + await root.cleanup(); + } + expect(store.text.residentNodes).toBe(0); + expect(store.status().residentPayloadBytes).toBe(0); +}, 300_000); From 4f13debae1fb98217e6347175e8573167ddac996 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 26 Oct 2026 22:45:07 +0900 Subject: [PATCH 2/3] docs: name the S03b-1b-I-a change fragment for its PR (part of #4272) --- changes/unreleased/{s03b1b-Ia.md => 4385.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changes/unreleased/{s03b1b-Ia.md => 4385.md} (100%) diff --git a/changes/unreleased/s03b1b-Ia.md b/changes/unreleased/4385.md similarity index 100% rename from changes/unreleased/s03b1b-Ia.md rename to changes/unreleased/4385.md From 7702d399cda7d45e89c0165658b585f61f41d143 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 26 Oct 2026 23:20:59 +0900 Subject: [PATCH 3/3] fix(rpc): bound and type the structured value codec writes and inline scalars (part of #4272) --- changes/unreleased/4385.md | 1 + .../src/modes/rpc/compact-content-store.ts | 8 +- .../src/modes/rpc/compact-values.ts | 165 +++++++++++------- .../4272-structured-page-io.test.ts | 43 +++++ .../4272-structured-write-limits.test.ts | 78 +++++++++ .../regressions/4272-text-root-height.test.ts | 14 +- 6 files changed, 233 insertions(+), 76 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/4272-structured-page-io.test.ts create mode 100644 packages/coding-agent/test/suite/regressions/4272-structured-write-limits.test.ts diff --git a/changes/unreleased/4385.md b/changes/unreleased/4385.md index d5d14e724f..e03aeacfe0 100644 --- a/changes/unreleased/4385.md +++ b/changes/unreleased/4385.md @@ -6,6 +6,7 @@ - `packages/coding-agent/src/modes/rpc/compact-values.ts` owns structured values in bounded field, array, and string pages, with shared immutable dependencies and cleanup of failed descriptor merges. It has no production caller in this increment; S03b-1b-I-b wires the settled index and removes the staging guard. - `packages/coding-agent/src/modes/rpc/compact-content-store.ts`, `compact-spool-storage.ts`, and `compact-storage-io.ts` account for structural payloads separately, coalesce their spill scheduling, and expose typed write-side limit failures. - `packages/coding-agent/src/modes/rpc/compact-text-roots.ts` owns shared dependency roots and grows the append tree only when the subtree is full. +- Codec writes reject unsupported JSON values, cycles, nesting beyond 128 levels, more than 100,000 visited values, and a 16 MiB encoding budget with typed errors, releasing partial owners on failure. Scalars and strings whose JSON encoding fits 64 code units are inline; page traversal reads each structural leaf once. ### Why diff --git a/packages/coding-agent/src/modes/rpc/compact-content-store.ts b/packages/coding-agent/src/modes/rpc/compact-content-store.ts index f89de19529..fb2b68747c 100644 --- a/packages/coding-agent/src/modes/rpc/compact-content-store.ts +++ b/packages/coding-agent/src/modes/rpc/compact-content-store.ts @@ -18,7 +18,13 @@ export const COMPACT_LEAF_CODE_UNITS = 32_768; export type LeafId = number; export class CompactWriteError extends Error { - readonly reason: "size_limit" | "storage_unavailable"; + readonly reason: + | "size_limit" + | "storage_unavailable" + | "depth_limit" + | "count_limit" + | "cycle" + | "unsupported_value"; constructor(reason: CompactWriteError["reason"]) { super(`compact write refused (${reason})`); diff --git a/packages/coding-agent/src/modes/rpc/compact-values.ts b/packages/coding-agent/src/modes/rpc/compact-values.ts index b174d2abb8..caef0b579f 100644 --- a/packages/coding-agent/src/modes/rpc/compact-values.ts +++ b/packages/coding-agent/src/modes/rpc/compact-values.ts @@ -1,10 +1,12 @@ import { type CompactContentStore, type LeafId, CompactWriteError } from "./compact-content-store.ts"; import { CompactReadError } from "./compact-read-lease.ts"; -export interface ValueRef { - readonly node: LeafId; - readonly owner: LeafId; -} +export type ValueRef = + | { + readonly node: LeafId; + readonly owner: LeafId; + } + | { readonly node: 0; readonly owner: 0; readonly value: string | number | boolean | null }; type Field = readonly [ValueRef, ValueRef]; export type ValuePage = | { readonly kind: "scalar"; readonly value: number | boolean | null } @@ -39,38 +41,81 @@ export class CompactValues { } own(value: unknown): ValueRef { - if (typeof value === "string") { + const owned = new Set(); + const ancestors = new Set(); + let nodes = 0, + bytes = 0; + const take = (page: ValuePage, children: readonly ValueRef[] = []): ValueRef => { + bytes += Buffer.byteLength(JSON.stringify(page)); + if (bytes > 16 * 1024 * 1024) throw new CompactWriteError("size_limit"); + const ref = this.make(page); + owned.add(ref.owner); + for (const child of children) { + this.store.adjust(child.owner, -1, 0); + owned.delete(child.owner); + } + return ref; + }; + const visit = (value: unknown, depth: number): ValueRef => { + if (depth > 128) throw new CompactWriteError("depth_limit"); + if (++nodes > 100_000) throw new CompactWriteError("count_limit"); + const scalar = + value === null || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value)); + if ( + !scalar && + typeof value !== "string" && + (typeof value !== "object" || + (!Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null)) + ) + throw new CompactWriteError("unsupported_value"); + if (typeof value === "string" && value.length * 2 > 16 * 1024 * 1024) + throw new CompactWriteError("size_limit"); + if (scalar || (typeof value === "string" && JSON.stringify(value).length <= 64)) { + bytes += Buffer.byteLength(JSON.stringify(value)); + if (bytes > 16 * 1024 * 1024) throw new CompactWriteError("size_limit"); + return { node: 0, owner: 0, value: value as string | number | boolean | null }; + } let next: ValueRef | undefined; - for (let end = value.length; end > 0 || next === undefined; end = Math.max(0, end - 4096)) { - const ref = this.make({ kind: "string", text: value.slice(Math.max(0, end - 4096), end), next }); - if (next) this.store.adjust(next.owner, -1, 0); - next = ref; - if (end <= 4096) break; + if (typeof value === "string") { + for (let end = value.length; end > 0; end = Math.max(0, end - 4096)) { + next = take( + { kind: "string", text: value.slice(Math.max(0, end - 4096), end), next }, + next ? [next] : [], + ); + } + } else { + const object = value as object; + if (ancestors.has(object)) throw new CompactWriteError("cycle"); + ancestors.add(object); + const array = Array.isArray(value); + const entries = array ? value : Object.entries(object); + if (nodes + entries.length > 100_000) throw new CompactWriteError("count_limit"); + for (let end = entries.length; end > 0 || !next; end = Math.max(0, end - 128)) { + const slice = entries.slice(Math.max(0, end - 128), end); + const items = array ? Array.from(slice, (item: unknown) => visit(item, depth + 1)) : []; + const fields: Field[] = array + ? [] + : slice.map(([key, item]: [string, unknown]) => [visit(key, depth + 1), visit(item, depth + 1)]); + next = take(array ? { kind: "array", items, next } : { kind: "object", items: fields, next }, [ + ...items, + ...fields.flat(), + ...(next ? [next] : []), + ]); + if (end <= 128) break; + } + ancestors.delete(object); } + if (!next) throw new CompactWriteError("unsupported_value"); return next; + }; + try { + return visit(value, 0); + } catch (error) { + for (const owner of owned) this.store.adjust(owner, -1, 0); + throw error; } - if (value === null || typeof value !== "object") { - return this.make({ - kind: "scalar", - value: typeof value === "boolean" || typeof value === "number" ? value : null, - }); - } - const array = Array.isArray(value); - const entries = array ? value : Object.entries(value); - let next: ValueRef | undefined; - for (let end = entries.length; end > 0 || next === undefined; end = Math.max(0, end - 128)) { - const slice = entries.slice(Math.max(0, end - 128), end); - const items = array ? slice.map((item: unknown) => this.own(item)) : []; - const fields: Field[] = array - ? [] - : slice.map(([key, item]: [string, unknown]) => [this.own(key), this.own(item)]); - const ref = this.make(array ? { kind: "array", items, next } : { kind: "object", items: fields, next }); - for (const child of [...items, ...fields.flat(), ...(next ? [next] : [])]) - this.store.adjust(child.owner, -1, 0); - next = ref; - if (end <= 128) break; - } - return next; } async page(ref: ValueRef) { @@ -95,47 +140,33 @@ export class CompactValues { } async read(ref: ValueRef): Promise { - using held = await this.page(ref); - const page = held.value; - switch (page.kind) { - case "scalar": - return page.value; - case "string": { - let result = page.text; - let next = page.next; - while (next) { - using held = await this.page(next); - const part = held.value; - if (part.kind !== "string") throw new CompactReadError("ownership"); - result += part.text; - next = part.next; - } - return result; - } - case "array": { - const result: unknown[] = []; - let current: ValueRef | undefined = ref; - while (current) { - using held = await this.page(current); - const part = held.value; - if (part.kind !== "array") throw new CompactReadError("ownership"); - for (const child of part.items) result.push(await this.read(child)); - current = part.next; - } - return result; - } - case "object": { - const result: Record = {}; - for await (const [key, value] of this.fields(ref)) { - Object.defineProperty(result, key, { + if ("value" in ref) return ref.value; + let next: ValueRef | undefined = ref; + let text = ""; + const array: unknown[] = [], + object: Record = {}; + let kind: ValuePage["kind"] | undefined; + while (next) { + using held = await this.page(next); + const page = held.value; + kind ??= page.kind; + if (kind !== page.kind) throw new CompactReadError("ownership"); + if (page.kind === "scalar") return page.value; + if (page.kind === "string") text += page.text; + if (page.kind === "array") for (const child of page.items) array.push(await this.read(child)); + if (page.kind === "object") + for (const [key, value] of page.items) { + const name = await this.read(key); + if (typeof name !== "string") throw new CompactReadError("ownership"); + Object.defineProperty(object, name, { value: await this.read(value), enumerable: true, configurable: true, }); } - return result; - } + next = page.next; } + return kind === "string" ? text : kind === "array" ? array : object; } async *fields(ref: ValueRef): AsyncGenerator { diff --git a/packages/coding-agent/test/suite/regressions/4272-structured-page-io.test.ts b/packages/coding-agent/test/suite/regressions/4272-structured-page-io.test.ts new file mode 100644 index 0000000000..9f82510608 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/4272-structured-page-io.test.ts @@ -0,0 +1,43 @@ +import { readdir } from "node:fs/promises"; +import { expect, it, vi } from "vitest"; +import { CompactContentStore } from "../../../src/modes/rpc/compact-content-store.ts"; +import { CompactValues } from "../../../src/modes/rpc/compact-values.ts"; +import { spoolRoot } from "../compact-storage-support.ts"; + +// senpi#4272: one array descriptor per 128 scalars, plus the spool ownership marker. +it("spools 2,000 numbers in at most ceil(2000/128) + 1 files", async () => { + const root = await spoolRoot(); + const store = new CompactContentStore({ dir: root.dir }); + const codec = new CompactValues(store); + const input = Array.from({ length: 2_000 }, (_, n) => n); + const ref = codec.own(input); + try { + await store.idle(); + expect((await readdir(root.dir)).length).toBeLessThanOrEqual(Math.ceil(input.length / 128) + 1); + expect(await codec.read(ref)).toEqual(input); + } finally { + store.adjust(ref.owner, -1, 0); + await store.retire(); + await root.cleanup(); + } +}, 60_000); + +it.each(["array", "object"])("reads every %s page exactly once", async (kind) => { + const root = await spoolRoot(); + const store = new CompactContentStore({ dir: root.dir }); + const codec = new CompactValues(store); + const entries = Array.from({ length: 129 }, (_, n) => [`key${n}`, "x".repeat(100)] as const); + const input = kind === "array" ? entries.map(([, value]) => value) : Object.fromEntries(entries); + const ref = codec.own(input); + const reads = vi.spyOn(store, "readLeaf"); + try { + expect(await codec.read(ref)).toEqual(input); + const ids = reads.mock.calls.map(([id]) => id); + expect(ids.length).toBe(new Set(ids).size); + expect(codec.residentPages).toBe(0); + } finally { + store.adjust(ref.owner, -1, 0); + await store.retire(); + await root.cleanup(); + } +}); diff --git a/packages/coding-agent/test/suite/regressions/4272-structured-write-limits.test.ts b/packages/coding-agent/test/suite/regressions/4272-structured-write-limits.test.ts new file mode 100644 index 0000000000..34717f19b6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/4272-structured-write-limits.test.ts @@ -0,0 +1,78 @@ +import { expect, it, vi } from "vitest"; +import { CompactContentStore } from "../../../src/modes/rpc/compact-content-store.ts"; +import { CompactValues } from "../../../src/modes/rpc/compact-values.ts"; +import { spoolRoot } from "../compact-storage-support.ts"; + +// senpi#4272: write failures are typed and cannot strand partially constructed owners. +it.each(["depth_limit", "cycle", "count_limit", "size_limit"])("refuses %s without leaking", async (reason) => { + const root = await spoolRoot(); + const store = new CompactContentStore({ dir: root.dir }); + const codec = new CompactValues(store); + let input: unknown = null; + if (reason === "depth_limit") for (let n = 0; n < 20_000; n++) input = [input]; + if (reason === "cycle") { + const cycle: unknown[] = []; + cycle.push(cycle); + input = cycle; + } + if (reason === "count_limit") input = Array(100_001).fill(0); + if (reason === "size_limit") input = "x".repeat(16 * 1024 * 1024 + 1); + try { + expect(() => codec.own(input)).toThrowError(expect.objectContaining({ name: "CompactWriteError", reason })); + await store.idle(); + expect(store.status().structuredBytes).toEqual({ resident: 0, spilled: 0, pinned: 0 }); + expect(store.text.residentNodes).toBe(0); + } finally { + await store.retire(); + await root.cleanup(); + } +}); + +it.each([6, 132])("releases completed siblings and prior pages after write %i throws", async (failAt) => { + const root = await spoolRoot(); + const store = new CompactContentStore({ dir: root.dir }); + const codec = new CompactValues(store); + const keep = codec.own({ stable: "keep".repeat(100) }); + await store.idle(); + const baseline = store.status().structuredBytes; + const nodes = store.text.residentNodes; + const create = store.createLeaf.bind(store); + let writes = 0; + const spy = vi.spyOn(store, "createLeaf").mockImplementation((text, value) => { + if (++writes === failAt) throw new Error("injected write"); + return create(text, value); + }); + try { + expect(() => + codec.own(Object.fromEntries(Array.from({ length: 140 }, (_, n) => [`key${n}`, "x".repeat(500)]))), + ).toThrow("injected write"); + spy.mockRestore(); + await store.idle(); + expect(store.status().structuredBytes).toEqual(baseline); + expect(store.text.residentNodes).toBe(nodes); + store.adjust(keep.owner, -1, 0); + await store.idle(); + expect(store.status().structuredBytes).toEqual({ resident: 0, spilled: 0, pinned: 0 }); + expect(store.text.residentNodes).toBe(0); + } finally { + spy.mockRestore(); + await store.retire(); + await root.cleanup(); + } +}); + +it.each([new Date(0), new Map(), new Set(), undefined, NaN, Infinity, -Infinity, 1n, () => 1, Symbol("x")])( + "rejects unsupported JSON input %s with a typed error", + async (input) => { + const root = await spoolRoot(); + const store = new CompactContentStore({ dir: root.dir }); + try { + expect(() => new CompactValues(store).own({ nested: input })).toThrowError( + expect.objectContaining({ name: "CompactWriteError", reason: "unsupported_value" }), + ); + } finally { + await store.retire(); + await root.cleanup(); + } + }, +); diff --git a/packages/coding-agent/test/suite/regressions/4272-text-root-height.test.ts b/packages/coding-agent/test/suite/regressions/4272-text-root-height.test.ts index de88f62816..2f8c58ef68 100644 --- a/packages/coding-agent/test/suite/regressions/4272-text-root-height.test.ts +++ b/packages/coding-agent/test/suite/regressions/4272-text-root-height.test.ts @@ -4,22 +4,20 @@ import { spoolRoot } from "../compact-storage-support.ts"; // senpi#4272 / #4378 LOW: equal heights do not imply a full right subtree. // Inspect the existing private height rather than adding a production API just for this invariant. -it("keeps append-tree height logarithmic at 1,024 and 1,048,576 real leaves", async () => { +it("keeps the tight ceil(log2 n) height across 4,097 real append boundaries", async () => { const root = await spoolRoot(); const store = new CompactContentStore({ dir: root.dir, spillAboveBytes: Number.POSITIVE_INFINITY }); let ref = 0; try { - for (let count = 1; count <= 2 ** 20; count++) { + for (let count = 1; count <= 4097; count++) { ref = store.text.append(ref, "x"); - if (count === 1024 || count === 2 ** 20) { - expect - .soft(store.text["height"](ref), `${count} leaves`) - .toBeLessThanOrEqual(Math.ceil(Math.log2(count)) + 1); + expect(store.text["height"](ref), `${count} leaves`).toBe(Math.ceil(Math.log2(count))); + if (count === 1024 || count === 4097) { expect(store.text.units(ref)).toBe(count); expect(store.text.residentNodes).toBe(count - 1); } } - expect(await store.text.read(ref, "", 2 ** 20 - 1, 1, 1)).toBe("x"); + expect(await store.text.read(ref, "", 4096, 1, 1)).toBe("x"); } finally { store.adjust(ref, -1, 0); await store.retire(); @@ -27,4 +25,4 @@ it("keeps append-tree height logarithmic at 1,024 and 1,048,576 real leaves", as } expect(store.text.residentNodes).toBe(0); expect(store.status().residentPayloadBytes).toBe(0); -}, 300_000); +});