diff --git a/src/responses/state.ts b/src/responses/state.ts index 3345e4626..8a7e4963c 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -27,7 +27,7 @@ interface StoredResponseState { /** v1 Cursor-only metadata, accepted only while loading old snapshots. */ conversationId?: string; cursorCheckpointUsable?: boolean; - /** Approximate in-memory size, computed locally at insert time (never trusted from disk). */ + /** Serialized UTF-8 entry size, computed locally at insert time (never trusted from disk). */ sizeBytes?: number; } @@ -49,21 +49,28 @@ export function getStoredResponseBytesForTests(): number { return storedResponseBytes; } -/** The ONLY size computation: approximate entry weight from its items payload. */ -function measuredEntry(entry: Omit): StoredResponseState { - let sizeBytes = 0; +/** The ONLY size computation: serialized UTF-8 bytes retained by the map entry. */ +function measuredEntry(id: string, entry: Omit): StoredResponseState | null { try { - sizeBytes = JSON.stringify(entry.items).length; + const serialized = JSON.stringify([id, entry]); + return { ...entry, sizeBytes: Buffer.byteLength(serialized, "utf8") }; } catch { - /* unserializable items: weightless rather than fatal */ + // A value whose retained size cannot be measured cannot safely enter a byte-bounded store. + return null; } - return { ...entry, sizeBytes }; } -/** The ONLY insertion point: keeps the byte counter consistent on replacement. */ +/** + * The ONLY insertion point: measure before mutating, reject an over-cap candidate without + * evicting unrelated valid chains, and remove a stale same-id value before rejecting it. + */ function setEntry(id: string, entry: Omit): void { + const measured = measuredEntry(id, entry); + if (!measured || (measured.sizeBytes ?? 0) > byteCap()) { + deleteEntry(id); + return; + } deleteEntry(id); - const measured = measuredEntry(entry); storedResponseBytes += measured.sizeBytes ?? 0; states.set(id, measured); } @@ -326,7 +333,7 @@ function pruneResponses(at = now()): void { deleteEntry(oldest); } // Byte high-water eviction, oldest-first (Map preserves insertion order). - while (storedResponseBytes > byteCap() && states.size > 1) { + while (storedResponseBytes > byteCap() && states.size > 0) { const oldest = states.keys().next().value; if (!oldest) break; deleteEntry(oldest); @@ -371,7 +378,7 @@ export function previousResponseProviderState(responseId: string | undefined): O export interface ResponseStateMetrics { /** Number of retained continuation entries currently in RAM. */ count: number; - /** Sum of serialized item bytes across all entries (the running in-memory byte accounting). + /** Sum of serialized map-entry bytes across all entries (the running in-memory byte accounting). * Because expanded previous_response_id chains share prefix item references, this is an UPPER * bound on true heap (it re-counts shared history), never an under-count — safe as a * memory-pressure signal. */ diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index af519117f..6bfd28cc4 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -57,6 +57,18 @@ are consumed incrementally and at most 512 stale files are attempted per process - 다른 대안 대신 이 방식을 선택한 이유: It repairs known remnants without broad authority over unrelated temp files or active writers. - 장점, 단점 및 영향: Old dead-PID files are reclaimed automatically; locked or conservatively classified files remain for a later retry. +In-memory continuation entries are measured in serialized UTF-8 bytes before admission. An entry +larger than the 64 MiB store budget is not retained, and replacing an existing response ID with an +oversized value removes the stale value without evicting unrelated valid chains. + +[Decision Log] +- 목적과 의도: Make the Responses continuation byte budget a hard invariant even for one image-heavy or malformed entry. +- 기존 구현 및 제약 조건: Oldest-first pruning stopped when one entry remained, so a single value could exceed the nominal 64 MiB cap; continuation misses are recoverable but unbounded retention is not. +- 검토한 주요 대안: Keep the newest entry regardless of size; evict all other entries before admitting it; reject the candidate atomically. +- 선택한 방식: Measure UTF-8 bytes before mutation, reject an over-budget candidate, and remove only a stale entry with the same response ID. +- 다른 대안 대신 이 방식을 선택한 이유: Evicting unrelated chains cannot make an individually oversized value fit, while retaining it breaks the advertised memory bound. +- 장점, 단점 및 영향: RAM accounting remains at or below the cap; an oversized continuation becomes a normal previous-response miss and may require the client to resend history. + ## Config surface `src/types.ts` is the shape and `src/config.ts` is the loader; neither is reproduced here. What diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 1e18efda2..f336b4862 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -554,6 +554,94 @@ describe("Responses previous_response_id state", () => { } }); + test("a single over-cap entry is rejected without evicting an unrelated valid chain", () => { + setResponseStateByteCapForTests(1_000); + try { + const small = buildResponseJSON([ + { type: "text_delta", text: "ok" }, + { type: "done" }, + ], "gpt-5.5"); + rememberResponseState({ model: "gpt-5.5", input: "small" }, small); + const smallBytes = getStoredResponseBytesForTests(); + + const oversized = buildResponseJSON([ + { type: "text_delta", text: "x".repeat(3_000) }, + { type: "done" }, + ], "gpt-5.5"); + rememberResponseState({ model: "gpt-5.5", input: "too large" }, oversized); + + expect(expandPreviousResponseInput({ + model: "gpt-5.5", previous_response_id: oversized.id, input: "next", + })).toEqual({ model: "gpt-5.5", previous_response_id: oversized.id, input: "next" }); + expect((expandPreviousResponseInput({ + model: "gpt-5.5", previous_response_id: small.id, input: "next", + }) as { input: unknown[] }).input).toHaveLength(3); + expect(getStoredResponseBytesForTests()).toBe(smallBytes); + expect(responseStateMetrics().totalBytes).toBeLessThanOrEqual(1_000); + } finally { + setResponseStateByteCapForTests(null); + } + }); + + test("an over-cap replacement removes stale state for the same response id", () => { + setResponseStateByteCapForTests(1_000); + try { + const responseId = "resp_replaced_over_cap"; + rememberResponseState( + { model: "gpt-5.5", input: "small" }, + { id: responseId, status: "completed", output: [{ type: "message", content: "ok" }] }, + ); + expect(responseStateMetrics().count).toBe(1); + + rememberResponseState( + { model: "gpt-5.5", input: "replacement" }, + { id: responseId, status: "completed", output: [{ type: "message", content: "x".repeat(3_000) }] }, + ); + + const next = { model: "gpt-5.5", previous_response_id: responseId, input: "next" }; + expect(expandPreviousResponseInput(next)).toEqual(next); + expect(responseStateMetrics()).toEqual({ count: 0, totalBytes: 0, largestBytes: 0, oldestAgeMs: 0 }); + } finally { + setResponseStateByteCapForTests(null); + } + }); + + test("byte admission counts UTF-8 bytes instead of JavaScript code units", () => { + setResponseStateByteCapForTests(1_000); + try { + const responseId = "resp_multibyte_over_cap"; + rememberResponseState( + { model: "gpt-5.5", input: "한".repeat(400) }, + { id: responseId, status: "completed", output: [] }, + ); + + const next = { model: "gpt-5.5", previous_response_id: responseId, input: "next" }; + expect(expandPreviousResponseInput(next)).toEqual(next); + expect(getStoredResponseBytesForTests()).toBe(0); + } finally { + setResponseStateByteCapForTests(null); + } + }); + + test("snapshot loading rejects a single entry above the active byte cap", () => { + setResponseStateByteCapForTests(1_000); + try { + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, "responses-state.json"), JSON.stringify({ + version: 2, + states: [["resp_snapshot_over_cap", { + createdAt: Date.now(), + items: [{ role: "user", content: "x".repeat(3_000) }], + }]], + })); + + expect(previousResponseProviderState("resp_snapshot_over_cap")).toBeUndefined(); + expect(responseStateMetrics()).toEqual({ count: 0, totalBytes: 0, largestBytes: 0, oldestAgeMs: 0 }); + } finally { + setResponseStateByteCapForTests(null); + } + }); + test("byte accounting survives restart (sizes recomputed on load)", async () => { setResponseStateByteCapForTests(4_000); try { @@ -609,7 +697,9 @@ describe("Responses previous_response_id state", () => { for (let i = 0; i < 1_050; i++) { const body = { model: "cursor/grok-4.5", input: `${bulk}-000${String(i % 10)}`, store: false }; const json = buildResponseJSON([{ type: "text_delta", text: "ok" }, { type: "done" }], "cursor/grok-4.5"); - rememberResponseState(body, json, { cursor: { conversationId: `conv_c${i}` } }, { force: true }); + rememberResponseState(body, json, { + cursor: { conversationId: `conv_c${String(i).padStart(4, "0")}` }, + }, { force: true }); lastId = json.id as string; if (perEntryBytes === 0) perEntryBytes = getStoredResponseBytesForTests(); }