From bddeae5108cbd22db6cf3c76d5f09bd1c9316c9b Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 17:19:33 -0700 Subject: [PATCH] fix(managed): charge admitted member facts once in the final projection (#3487) * fix(managed): charge admitted member facts once in the final projection inspect_managed_members admits method bodies, signatures, call edges, field accesses and user strings as they are produced, then admits the whole result again before returning it. The final pass re-counted every already-admitted record, so the 64 MiB representation budget was spent at roughly twice the result's JSON size. Ordinary assemblies such as MathNet.Numerics, MongoDB.Driver, Npgsql, ClosedXML, System.Reactive and Azure.Storage.Blobs returned resource-limit with no members. The budget now remembers each admitted projection. A containing projection spends that charge once instead of counting the subtree again; a value that serializes more than once still pays for every further occurrence. Results that already fit are byte-identical. Refs #3449 Co-Authored-By: Claude Opus 5.5 * test(managed): check a repeated occurrence pays with fresh credit Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/dotnet/ManagedDecodeBudget.test.ts | 29 ++++++++++++++++++++++++++ src/dotnet/ManagedDecodeBudget.ts | 25 +++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/dotnet/ManagedDecodeBudget.test.ts b/src/dotnet/ManagedDecodeBudget.test.ts index ebab4a99e9..b46f6a0a45 100644 --- a/src/dotnet/ManagedDecodeBudget.test.ts +++ b/src/dotnet/ManagedDecodeBudget.test.ts @@ -35,6 +35,35 @@ describe("managed projection admission", () => { ManagedReaderFailure, ); }); + + it("charges an admitted record once inside its containing projection", () => { + const budget = new ManagedDecodeBudget(); + const record = admitManagedProjection( + { name: "a".repeat(40 * 1024 * 1024) }, + budget, + ); + + expect(() => + admitManagedProjection({ records: [record] }, budget), + ).not.toThrow(); + // The containing projection spent the credit, so later reuse pays again. + expect(() => + admitManagedProjection({ records: [record, record] }, budget), + ).toThrow(ManagedReaderFailure); + }); + + it("charges a repeated occurrence of an admitted record again", () => { + const budget = new ManagedDecodeBudget(); + const record = admitManagedProjection( + { name: "a".repeat(40 * 1024 * 1024) }, + budget, + ); + + // The first occurrence spends the credit; the second serializes again. + expect(() => + admitManagedProjection({ records: [record, record] }, budget), + ).toThrow(ManagedReaderFailure); + }); }); describe("managed decoding bounds", () => { diff --git a/src/dotnet/ManagedDecodeBudget.ts b/src/dotnet/ManagedDecodeBudget.ts index d02fce0bc7..fba6229a27 100644 --- a/src/dotnet/ManagedDecodeBudget.ts +++ b/src/dotnet/ManagedDecodeBudget.ts @@ -18,6 +18,7 @@ export class ManagedDecodeBudget { readonly #rows = new Map>(); readonly #strings = new Map(); readonly #digests = new WeakMap>(); + readonly #prepaid = new WeakSet(); reserve(bytes: number, offset: number | null): void { this.#admit(bytes, offset); @@ -82,6 +83,16 @@ export class ManagedDecodeBudget { return digest; } + /** Record an admitted projection; its next serialized occurrence is already charged. */ + prepay(value: object): void { + this.#prepaid.add(value); + } + + /** Spend an admitted projection's charge once; repeated occurrences pay again. */ + consumePrepaid(value: object): boolean { + return this.#prepaid.delete(value); + } + cachedString(index: number): CachedString | undefined { return this.#strings.get(index); } @@ -141,12 +152,23 @@ const jsonBytes = (item: unknown): number => ? managedJsonStringBytes(item) : Buffer.byteLength(JSON.stringify(item) ?? "null"); -/** Admit repeated values too: shared heap strings still expand at the JSON boundary. */ +/** + * Admit repeated values too: shared heap strings still expand at the JSON + * boundary. An object admitted earlier was charged for one occurrence, so a + * containing projection spends that charge instead of counting it again. + * Admitted values must stay unmodified. + */ export const admitManagedProjection = ( value: Value, budget: ManagedDecodeBudget = new ManagedDecodeBudget(), ): Value => { const visit = (item: unknown): void => { + if ( + item !== null && + typeof item === "object" && + budget.consumePrepaid(item) + ) + return; if (Array.isArray(item)) { budget.reserve(item.length + 2, null); for (const child of item) visit(child); @@ -161,5 +183,6 @@ export const admitManagedProjection = ( } }; visit(value); + if (value !== null && typeof value === "object") budget.prepay(value); return value; };