diff --git a/src/dotnet/ManagedDecodeBudget.test.ts b/src/dotnet/ManagedDecodeBudget.test.ts index 48ef121231..ebab4a99e9 100644 --- a/src/dotnet/ManagedDecodeBudget.test.ts +++ b/src/dotnet/ManagedDecodeBudget.test.ts @@ -38,6 +38,18 @@ describe("managed projection admission", () => { }); describe("managed decoding bounds", () => { + it("bounds new row identities without charging lookup replay or merging tables", () => { + const budget = new ManagedDecodeBudget(); + budget.reserve(64 * 1024 * 1024 - 512, null); + budget.reserveRow(2, 1, 100); + budget.reserveRow(12, 1, 200); + + expect(() => budget.reserveRow(2, 1, 100)).not.toThrow(); + expect(() => budget.reserveRow(2, 2, 300)).toThrow(ManagedReaderFailure); + // A rejected row has no retained reservation; replay cannot bypass capacity. + expect(() => budget.reserveRow(2, 2, 300)).toThrow(ManagedReaderFailure); + }); + it("keeps concurrent decoding bounds within one budget until released", () => { const budget = new ManagedDecodeBudget(); const bound = 40 * 1024 * 1024; diff --git a/src/dotnet/ManagedDecodeBudget.ts b/src/dotnet/ManagedDecodeBudget.ts index 14b6915669..d02fce0bc7 100644 --- a/src/dotnet/ManagedDecodeBudget.ts +++ b/src/dotnet/ManagedDecodeBudget.ts @@ -15,6 +15,7 @@ interface CachedString { export class ManagedDecodeBudget { #remaining = MAX_METADATA_REPRESENTATION_BYTES; #transient = 0; + readonly #rows = new Map>(); readonly #strings = new Map(); readonly #digests = new WeakMap>(); @@ -23,6 +24,18 @@ export class ManagedDecodeBudget { this.#remaining -= bytes; } + /** Retain one bookkeeping reservation per physical row, independently of lookup replay. */ + reserveRow(table: number, row: number, offset: number): void { + let rows = this.#rows.get(table); + if (rows?.has(row) === true) return; + this.reserve(256, offset); + if (rows === undefined) { + rows = new Set(); + this.#rows.set(table, rows); + } + rows.add(row); + } + /** Bound a decoding allocation that stays live until `releaseTransient`; only retained output is reserved. */ admitTransient(bytes: number, offset: number | null): void { this.#admit(bytes, offset); diff --git a/src/dotnet/ManagedMetadataHeaps.ts b/src/dotnet/ManagedMetadataHeaps.ts index ca062b8684..51b85576c5 100644 --- a/src/dotnet/ManagedMetadataHeaps.ts +++ b/src/dotnet/ManagedMetadataHeaps.ts @@ -245,8 +245,9 @@ export const metadataRowCursor = ( "Metadata table is absent", start, ); - // Reserve per-row object/index bookkeeping before readers retain decoded facts. - managedDecodeBudget(layout).reserve(256, start); + // Temporary cursors may revisit a row while resolving owners or references. + // Bind fixed retained bookkeeping to that row's identity, not each lookup. + managedDecodeBudget(layout).reserveRow(table, row, start); return new MetadataRowCursor( bytes, start, diff --git a/src/dotnet/ManagedPe.fixture.ts b/src/dotnet/ManagedPe.fixture.ts index 9f1c4cdde5..f930e3391f 100644 --- a/src/dotnet/ManagedPe.fixture.ts +++ b/src/dotnet/ManagedPe.fixture.ts @@ -3,6 +3,8 @@ import { createHash } from "node:crypto"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; interface ManagedPeFixtureOptions { + readonly additionalEmptyTypes?: number; + readonly customAttributeRowCount?: number; readonly cliFlags?: number; readonly corruptMetadataSignature?: boolean; readonly customAttributeTypeRaw?: number; @@ -20,6 +22,7 @@ interface ManagedPeFixtureOptions { readonly malformedCustomAttributeRows?: readonly number[]; readonly metadataValidMaskExtra?: bigint; readonly methodName?: string; + readonly methodFlags?: number; readonly methodSignature?: Buffer; readonly moduleRowCount?: number; readonly assemblyRowCount?: number; @@ -328,22 +331,30 @@ const assemblyRefRow = ( metadataIndex(0, blobSize), ]); -const customAttributeRow = ( - value: number, - options: ManagedPeFixtureOptions, - parentIndexSize: 2 | 4, - typeIndexSize: MetadataIndexSize, - blobSize: MetadataIndexSize, -): Buffer => +const customAttributeRow = ({ + value, + options, + parentIndexSize, + typeIndexSize, + blobSize, + row, +}: { + readonly value: number; + readonly options: ManagedPeFixtureOptions; + readonly parentIndexSize: 2 | 4; + readonly typeIndexSize: MetadataIndexSize; + readonly blobSize: MetadataIndexSize; + readonly row: number; +}): Buffer => Buffer.concat([ parentIndexSize === 2 ? u16( - options.malformedCustomAttributeRows?.includes(1) + options.malformedCustomAttributeRows?.includes(row) ? 0xffff : (1 << 5) | 14, ) : u32( - options.malformedCustomAttributeRows?.includes(1) + options.malformedCustomAttributeRows?.includes(row) ? 0xffff_ffff : (1 << 5) | 14, ), @@ -484,6 +495,7 @@ interface FixtureMetadataRowContext { readonly attributeNamespace: number; readonly constructorName: number; readonly typeName: number; + readonly additionalTypeNames: readonly number[]; readonly typeNamespace: number; readonly fieldName: number; readonly methodName: number; @@ -536,15 +548,16 @@ const customAttributeRows = ( ): readonly Buffer[] => options.targetFramework === null ? [] - : [ - customAttributeRow( - indexes.attributeBlob, + : Array.from({ length: options.customAttributeRowCount ?? 1 }, (_, index) => + customAttributeRow({ + value: indexes.attributeBlob, options, - widths.hasCustomAttribute, - widths.customAttributeType, - heapSizes.blobs, - ), - ]; + parentIndexSize: widths.hasCustomAttribute, + typeIndexSize: widths.customAttributeType, + blobSize: heapSizes.blobs, + row: index + 1, + }), + ); const exportedTypeTable = ( options: ManagedPeFixtureOptions, @@ -577,12 +590,17 @@ const fixtureIndexWidths = ({ const rowCounts = new Map([ [0, options.moduleRowCount ?? 1], [1, 1], - [2, 1], + [2, indexes.additionalTypeNames.length + 1], [4, 1], [6, 1], [8, 0], [10, 1], - [12, options.targetFramework === null ? 0 : 1], + [ + 12, + options.targetFramework === null + ? 0 + : (options.customAttributeRowCount ?? 1), + ], ...(indexes.pinvokeModuleName === null ? [] : ([[26, 1]] as const)), ...(indexes.pinvokeImportName === null ? [] : ([[28, 1]] as const)), [32, options.assemblyRowCount ?? 1], @@ -648,9 +666,9 @@ const fixtureMetadataRows = ({ ], [ 2, - [ + [...indexes.additionalTypeNames, indexes.typeName].map((name) => typeDefRow({ - name: indexes.typeName, + name, namespace: indexes.typeNamespace, fieldList: 1, methodList: 1, @@ -662,7 +680,7 @@ const fixtureMetadataRows = ({ ? {} : { extendsRaw: options.extendsRaw }), }), - ], + ), ], [ 4, @@ -685,7 +703,9 @@ const fixtureMetadataRows = ({ blobSize: heapSizes.blobs, parameterSize: widths.table(8), rva: methodRva, - flags: options.pinvoke === undefined ? 0x0016 : 0x2016, + flags: + options.methodFlags ?? + (options.pinvoke === undefined ? 0x0016 : 0x2016), }), ], ], @@ -774,6 +794,10 @@ const buildManagedFixtureMetadata = ( const attributeNamespace = strings.add("System.Runtime.Versioning"); const constructorName = strings.add(".ctor"); const typeName = strings.add(options.typeName ?? "Program"); + const additionalTypeNames = Array.from( + { length: options.additionalEmptyTypes ?? 0 }, + (_, index) => strings.add(`Empty${String(index)}`), + ); const typeNamespace = strings.add(options.typeNamespace ?? "Fixture"); const fieldName = strings.add(options.fieldName ?? "counter"); const methodName = strings.add(options.methodName ?? "Main"); @@ -825,6 +849,7 @@ const buildManagedFixtureMetadata = ( attributeNamespace, constructorName, typeName, + additionalTypeNames, typeNamespace, fieldName, methodName, diff --git a/tests/acceptance/managedCliOutput.test.ts b/tests/acceptance/managedCliOutput.test.ts index a336a2a063..d8a4bb998f 100644 --- a/tests/acceptance/managedCliOutput.test.ts +++ b/tests/acceptance/managedCliOutput.test.ts @@ -7,6 +7,72 @@ import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; +cliTest( + "managed CLI retains metadata and CIL after repeated local constructor owner lookups", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-managed-row-lookup-"); + const path = join(root, "local-attributes.dll"); + await writeFile( + path, + buildManagedPeFixture({ + additionalEmptyTypes: 512, + customAttributeRowCount: 512, + customAttributeTypeRaw: (1 << 3) | 2, + typeName: "LookupAttribute", + extendsRaw: 0, + methodName: ".ctor", + methodFlags: 0x1886, + methodSignature: Buffer.from([0x20, 0x01, 0x01, 0x0e]), + ilBody: Buffer.from([0x06, 0x2a]), + }), + ); + const artifact = await cli.run({ + arguments: ["inspect-managed-artifact", path, "--json"], + }); + expect(artifact.exitCode).toBe(0); + expect(artifact.json).toMatchObject({ + normalized_result: { + coverage: { state: "complete", issues: [] }, + attributes: expect.arrayContaining([ + expect.objectContaining({ + type_name: "Fixture.LookupAttribute", + constructor_token: "0x06000001", + }), + ]), + }, + }); + expect(artifact.json).toHaveProperty( + "normalized_result.attributes.length", + 512, + ); + const members = await cli.run({ + arguments: ["inspect-managed-members", path, "--json"], + }); + expect(members.exitCode).toBe(0); + expect(members.json).toMatchObject({ + normalized_result: { + coverage: { state: "complete", issues: [] }, + types: expect.arrayContaining([ + expect.objectContaining({ full_name: "Fixture.Empty0" }), + expect.objectContaining({ full_name: "Fixture.Empty511" }), + expect.objectContaining({ full_name: "Fixture.LookupAttribute" }), + ]), + methods: [ + expect.objectContaining({ + name: ".ctor", + declaring_type: "Fixture.LookupAttribute", + body: expect.objectContaining({ + status: "present", + instruction_count: 1, + }), + }), + ], + }, + }); + expect(members.json).toHaveProperty("normalized_result.types.length", 513); + }, +); + cliTest( "managed CLI separates .NET Standard API targets from runtime evidence", async ({ cli }) => {