fix(managed): bind row budget charges to metadata identities (#1897)

Charge fixed row bookkeeping once per table and row within an admitted
metadata layout. Repeated owner lookups no longer permanently consume
capacity for temporary cursors; distinct rows and failed admissions retain
the existing representation limit and typed failure behavior.

Verify repeated local attribute-constructor lookups through public CLI
metadata/member inspection and preserve the remaining capacity guards.

Closes #1878

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 00:33:51 +08:00
1 parent 7c207c7951
commit 23d50ea3be
5 files changed
+142 -25

No files matched your search

+12
View File
@@ -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;
+13
View File
@@ -15,6 +15,7 @@ interface CachedString {
export class ManagedDecodeBudget {
#remaining = MAX_METADATA_REPRESENTATION_BYTES;
#transient = 0;
readonly #rows = new Map<number, Set<number>>();
readonly #strings = new Map<number, CachedString>();
readonly #digests = new WeakMap<ArrayBufferLike, Map<string, string>>();
@@ -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<number>();
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);
+3 -2
View File
@@ -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,
+48 -23
View File
@@ -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<number, number>([
[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,
+66
View File
@@ -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 }) => {