refactor(domain): charge resource allowances through one Budget owner (#11293)

This commit is contained in:
morluto authored and GitHub committed 2026-11-15 23:47:20 +08:00
1 parent c931ba43ab
commit 3d67404132
11 files changed
+215 -96

No files matched your search

@@ -500,5 +500,24 @@
"src/signals/SigrokRecordingProvider.ts": 1,
"src/signals/parseDecoderDiscovery.ts": 1,
"src/v8/V8CodeCache.errors.ts": 1
},
"budget-arithmetic": {
"src/application/investigation/EvidenceLedger.ts": 1,
"src/artifacts/UnityFsArtifactReader.ts": 1,
"src/artifacts/apple/InterfaceBuilderDecodeBudget.ts": 2,
"src/artifacts/bun/BunModuleGraphLayout.ts": 2,
"src/browser/PlaywrightScenarioEvents.ts": 3,
"src/browser/WebBundleAnalysisWorker.ts": 2,
"src/browser/execution/CdpRuntimeSources.ts": 2,
"src/desktop/DesktopContentStore.ts": 1,
"src/domain/analysisView/WebNetworkProjectionAccumulator.ts": 1,
"src/domain/apiContract/parseApiContract.ts": 1,
"src/domain/firmware/hexRecords.ts": 1,
"src/domain/firmware/uf2.ts": 1,
"src/domain/javascript/javascriptSemanticWorkAllowance.ts": 1,
"src/domain/javascript/javascriptSymbolicBindings.ts": 4,
"src/dotnet/ManagedDecodeBudget.ts": 1,
"src/javascript/analysis/JavaScriptAnalysisWorker.ts": 4,
"src/python/PythonPycParser.ts": 2
}
}
+13
View File
@@ -340,6 +340,19 @@ checkCounted(
[],
);
// A resource allowance is charged and refunded through `Budget` in
// src/domain/budget, which refuses malformed or oversized charges without
// changing the remainder. Hand-written `remaining -=` / `retained +=`
// arithmetic repeats that admission and drifts (charge-then-check, no
// safe-integer check).
checkCounted(
"hand-written budget arithmetic (use Budget from src/domain/budget)",
"budget-arithmetic",
files,
/this\.#?\w*(?:[Rr]emaining|[Rr]etained|[Uu]sed)\w*\s*[-+]=/gu,
["src/domain/budget.ts"],
);
// A deadline is a monotonic budget owned by `Deadline` in src/domain/deadline;
// a wall-clock sum moves with clock changes and has no finalize phase. The
// remaining copy converts a monotonic budget for a browser page, which can
@@ -1,3 +1,4 @@
import { Budget } from "../../domain/budget.js";
import { setImmediate } from "node:timers/promises";
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
import type { Evidence } from "../../domain/evidence.js";
@@ -13,7 +14,7 @@ import {
export class NativeMatchingAdmission {
readonly #maximum: number;
readonly #source: NativeMatchingAllowance["source"];
#remaining: number;
readonly #bytes: Budget;
#work = 0;
readonly #seen = new WeakSet<object>();
constructor(
@@ -25,29 +26,10 @@ export class NativeMatchingAdmission {
// budget this request actually ran under.
this.#maximum = allowance.maximumBytes;
this.#source = allowance.source;
this.#remaining = this.#maximum;
this.#bytes = new Budget(this.#maximum);
}
readonly checkpoint = async (bytes = 0): Promise<void> => {
this.#remaining -= bytes;
if (this.#remaining < 0)
throw new AnalysisResourceConstraintError(
this.operation,
"capacity",
`Native function matching exceeds its ${nativeMatchingByteAllowance(this.#maximum)} representation allowance (native.matching_representation_bytes).`,
{ maximum_bytes: this.#maximum },
{
retryable: false,
remediationAction: nativeMatchingAllowanceRecovery({
maximumBytes: this.#maximum,
source: this.#source,
}),
limit: analysisSettingLimit(
"native.matching_representation_bytes",
this.#maximum,
this.#source,
),
},
);
this.#bytes.charge(bytes, () => this.#exhausted());
this.signal?.throwIfAborted();
if (++this.#work % 256 === 0)
await setImmediate(
@@ -55,6 +37,26 @@ export class NativeMatchingAdmission {
this.signal === undefined ? {} : { signal: this.signal },
);
};
#exhausted(): AnalysisResourceConstraintError {
return new AnalysisResourceConstraintError(
this.operation,
"capacity",
`Native function matching exceeds its ${nativeMatchingByteAllowance(this.#maximum)} representation allowance (native.matching_representation_bytes).`,
{ maximum_bytes: this.#maximum },
{
retryable: false,
remediationAction: nativeMatchingAllowanceRecovery({
maximumBytes: this.#maximum,
source: this.#source,
}),
limit: analysisSettingLimit(
"native.matching_representation_bytes",
this.#maximum,
this.#source,
),
},
);
}
/**
* Charge a borrowed record's envelope, which matching copies into its
* limitations, digests and links. Its result payloads stay resident: the
+9 -17
View File
@@ -1,3 +1,4 @@
import { Budget } from "../domain/budget.js";
import type { FileHandle } from "node:fs/promises";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { readArtifactFileBytes } from "./ArtifactFileRange.js";
@@ -39,15 +40,6 @@ const directoryLimit = (): ArtifactReaderFailure =>
{ boundary: "memory" },
);
/** Reserve buffers, JS rows and escaped path projections before constructing them. */
class UnityFsDirectoryBudget {
#remaining = DIRECTORY_BUDGET_BYTES;
reserve(bytes: number): void {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
throw directoryLimit();
this.#remaining -= bytes;
}
}
/** Signature, version, two bounded strings and the fixed tail; reads are bounded. */
const HEADER_PREFIX_MAX = 8 + 4 + UNITYFS_HEADER_STRING_MAX * 2 + 20;
const UTF8 = strictUtf8Decoder("preserve");
@@ -258,7 +250,7 @@ export const decodeUnityFsLz4 = async (
const parseBlocks = async (
table: Buffer,
budget: UnityFsDirectoryBudget,
budget: Budget,
signal?: AbortSignal,
): Promise<UnityFsBlock[]> => {
if (table.length < 24)
@@ -267,7 +259,7 @@ const parseBlocks = async (
if (20 + blockCount * 10 + 4 > table.length)
throw unityFsFormatFailure("declared block count exceeds the table");
// Includes array slots, numeric fields and the retained lookup representation.
budget.reserve(blockCount * 256);
budget.charge(blockCount * 256, directoryLimit);
const blocks: UnityFsBlock[] = [];
let cursor = 20;
let compressedStart = 0;
@@ -327,7 +319,7 @@ const parseBlocks = async (
const parseNodes = async (
table: Buffer,
blocks: readonly UnityFsBlock[],
budget: UnityFsDirectoryBudget,
budget: Budget,
signal?: AbortSignal,
): Promise<UnityFsNode[]> => {
let cursor = 20 + blocks.length * 10;
@@ -338,7 +330,7 @@ const parseNodes = async (
if (cursor + nodeCount * 21 > table.length)
throw unityFsFormatFailure("declared node count exceeds the table");
// Covers parsed rows plus the reader's owned entries/map; paths are charged separately.
budget.reserve(nodeCount * 512);
budget.charge(nodeCount * 512, directoryLimit);
const streamSize =
blocks.length === 0
? 0
@@ -357,7 +349,7 @@ const parseNodes = async (
const nameEnd = table.indexOf(0, cursor);
if (nameEnd === -1) throw unityFsFormatFailure("node path is unterminated");
// UTF-16 plus repeated JSON-escaped source/canonical path projections.
budget.reserve((nameEnd - cursor) * 24);
budget.charge((nameEnd - cursor) * 24, directoryLimit);
let path: string;
try {
path = UTF8.decode(table.subarray(cursor, nameEnd));
@@ -428,10 +420,10 @@ export const readUnityFsBlocksInfo = async (
header.uncompressedBlocksInfoSize > MAX_TABLE_BYTES
)
throw directoryLimit();
const budget = new UnityFsDirectoryBudget();
budget.reserve(header.compressedBlocksInfoSize);
const budget = new Budget(DIRECTORY_BUDGET_BYTES);
budget.charge(header.compressedBlocksInfoSize, directoryLimit);
if (header.compression !== UNITYFS_COMPRESSION_NONE)
budget.reserve(header.uncompressedBlocksInfoSize);
budget.charge(header.uncompressedBlocksInfoSize, directoryLimit);
const compressed = await readArtifactFileBytes(
handle,
layout.blocksInfoOffset,
+11 -16
View File
@@ -1,3 +1,4 @@
import { Budget } from "../domain/budget.js";
import type { FileHandle } from "node:fs/promises";
import {
ArtifactReaderFailure,
@@ -31,19 +32,13 @@ const UTF8 = strictUtf8Decoder("preserve");
export const wxapkgFormatFailure = (detail: string): ArtifactReaderFailure =>
new ArtifactReaderFailure("format", `wxapkg is malformed: ${detail}`);
/** Reserve buffers, JS rows and escaped name projections before allocating. */
class WxapkgDirectoryBudget {
#remaining = DIRECTORY_BUDGET_BYTES;
reserve(bytes: number): void {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
throw artifactFixedLimitFailure(
"wxapkg directory representation",
DIRECTORY_BUDGET_BYTES,
"bytes",
);
this.#remaining -= bytes;
}
}
/** Buffers, JS rows and escaped name projections, reserved before allocating. */
const directoryLimit = (): Error =>
artifactFixedLimitFailure(
"wxapkg directory representation",
DIRECTORY_BUDGET_BYTES,
"bytes",
);
export interface WxapkgHeader {
readonly info: number;
@@ -138,8 +133,8 @@ export const readWxapkgFileTable = async (
MAX_TABLE_BYTES,
"bytes",
);
const budget = new WxapkgDirectoryBudget();
budget.reserve(readBytes);
const budget = new Budget(DIRECTORY_BUDGET_BYTES);
budget.charge(readBytes, directoryLimit);
const table = await readArtifactFileBytes(
handle,
tableStart,
@@ -166,7 +161,7 @@ export const readWxapkgFileTable = async (
)
throw wxapkgFormatFailure("member row exceeds the table");
// Row storage plus the reader's owned entries/map; charge before decode.
budget.reserve(512 + nameLength * 24);
budget.charge(512 + nameLength * 24, directoryLimit);
let declaredPath: string;
try {
declaredPath = UTF8.decode(table.subarray(cursor, cursor + nameLength));
+3 -6
View File
@@ -1,3 +1,4 @@
import { Budget } from "../domain/budget.js";
import {
noRuntimeOverrideSentence,
selectedCdpCommandTimeoutMs,
@@ -19,7 +20,7 @@ export const cdpScriptSourceDeadlineOmissionReason = (
export class CdpScriptSourceBudget {
readonly #deadline: ProviderStartupDeadline;
readonly commandTimeoutMs: number;
#retainedBytes = 0;
readonly #retained = new Budget(WEB_RUNTIME_LIMITS.sourceBytes);
constructor(
signal?: AbortSignal,
@@ -39,11 +40,7 @@ export class CdpScriptSourceBudget {
/** Admit exact UTF-8 source bytes before building or retaining its artifact. */
admit(text: string): boolean {
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > WEB_RUNTIME_LIMITS.sourceBytes - this.#retainedBytes)
return false;
this.#retainedBytes += bytes;
return true;
return this.#retained.tryCharge(Buffer.byteLength(text, "utf8"));
}
dispose(): void {
+10 -10
View File
@@ -1,3 +1,4 @@
import { Budget } from "../../../domain/budget.js";
import { strictUtf8Decoder } from "../../../domain/strictUtf8.js";
import { captureBudgets } from "../CaptureBudgets.js";
import { readLeb128, ULEB64 } from "../../../domain/leb128.js";
@@ -8,19 +9,18 @@ import { CaptureFormatError } from "../CaptureFormatError.js";
/** One charge covers all selected records, including temporary interpretation strings. */
export class ProtobufBudget {
#remaining = captureBudgets().outputBytes;
readonly #bytes = new Budget(captureBudgets().outputBytes);
#work = 0;
constructor(readonly signal?: AbortSignal) {}
charge(bytes: number): void {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
throw new CaptureFormatError(
{
name: "protobuf-decoding",
maximum: captureBudgets().outputBytes,
},
`Protobuf decoding exceeds max_reply_bytes (${String(captureBudgets().outputBytes)} bytes); raise that per-call allowance and retry.`,
);
this.#remaining -= bytes;
this.#bytes.charge(
bytes,
() =>
new CaptureFormatError(
{ name: "protobuf-decoding", maximum: this.#bytes.maximum },
`Protobuf decoding exceeds max_reply_bytes (${String(this.#bytes.maximum)} bytes); raise that per-call allowance and retry.`,
),
);
}
async checkpoint(): Promise<void> {
this.signal?.throwIfAborted();
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { Budget } from "./budget.js";
describe("Budget", () => {
const exhausted = () => new Error("exhausted");
it("charges until the maximum and refuses without charging", () => {
const budget = new Budget(10);
budget.charge(6, exhausted);
expect(budget.tryCharge(5)).toBe(false);
expect(() => {
budget.charge(5, exhausted);
}).toThrow("exhausted");
expect(budget.used).toBe(6);
budget.charge(4, exhausted);
expect(budget.remaining).toBe(0);
});
it.each([-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
"refuses a malformed charge %s without changing the remainder",
(amount) => {
const budget = new Budget(Number.MAX_SAFE_INTEGER);
expect(budget.tryCharge(amount)).toBe(false);
expect(budget.used).toBe(0);
},
);
it("refunds without rising above the maximum", () => {
const budget = new Budget(10);
budget.charge(4, exhausted);
budget.refund(3);
expect(budget.used).toBe(1);
budget.refund(5);
expect(budget.remaining).toBe(10);
expect(() => {
budget.refund(-1);
}).toThrow(/refund/u);
});
it("rejects a malformed maximum", () => {
expect(() => new Budget(-1)).toThrow(/maximum/u);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { invariantViolation } from "./internalInvariant.js";
const isAmount = (value: number): boolean =>
Number.isSafeInteger(value) && value >= 0;
/**
* One allowance of a counted resource (bytes, records, visits) that a producer
* charges before it allocates and refunds when it discards a reservation.
*
* The budget owns the arithmetic: a charge must be a safe non-negative integer
* that fits what remains, a refused charge changes nothing, and a refund never
* raises the remainder above the maximum. Each owner keeps its own limit
* failure, passed to `charge` as `exhausted`.
*/
export class Budget {
#used = 0;
constructor(readonly maximum: number) {
if (!isAmount(maximum))
throw invariantViolation(
`Budget maximum must be a non-negative safe integer, got ${String(maximum)}`,
);
}
/** Amount charged and not refunded. */
get used(): number {
return this.#used;
}
/** Amount still available. */
get remaining(): number {
return this.maximum - this.#used;
}
/** Charge `amount` when it fits; otherwise charge nothing and return false. */
tryCharge(amount: number): boolean {
if (!isAmount(amount) || amount > this.remaining) return false;
this.#used += amount;
return true;
}
/** Charge `amount`, or throw the owner's limit failure without charging. */
charge(amount: number, exhausted: () => Error): void {
if (!this.tryCharge(amount)) throw exhausted();
}
/** Return a reservation that was not retained. */
refund(amount: number): void {
if (!isAmount(amount))
throw invariantViolation(
`Budget refund must be a non-negative safe integer, got ${String(amount)}`,
);
this.#used = Math.max(0, this.#used - amount);
}
}
+22 -20
View File
@@ -1,3 +1,4 @@
import { Budget } from "../budget.js";
import { AnalysisResourceConstraintError } from "../analysisErrorCore.js";
import {
DEFAULT_WASM_INTERFACE_REPRESENTATION_BYTES,
@@ -6,7 +7,7 @@ import {
/** One selected interface allowance, shared by nested producer reads and comparison. */
export class WasmInterfaceBudget {
#remaining: number;
readonly #bytes: Budget;
readonly maximumBytes: number;
readonly source: "default" | "request";
constructor(
@@ -16,26 +17,27 @@ export class WasmInterfaceBudget {
this.maximumBytes =
maximumBytes ?? DEFAULT_WASM_INTERFACE_REPRESENTATION_BYTES;
this.source = maximumBytes === undefined ? "default" : "request";
this.#remaining = this.maximumBytes;
this.#bytes = new Budget(this.maximumBytes);
}
reserve(bytes: number): void {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
throw new AnalysisResourceConstraintError(
this.operation,
"capacity",
`Typed WASM interface representation exceeds max_interface_bytes (${String(this.maximumBytes)} bytes)`,
{
maximum_bytes: this.maximumBytes,
default_bytes: DEFAULT_WASM_INTERFACE_REPRESENTATION_BYTES,
maximum_supported_bytes: MAX_WASM_INTERFACE_REPRESENTATION_BYTES,
source: this.source,
unit: "bytes",
},
{
retryable: false,
remediationAction: `${this.maximumBytes < MAX_WASM_INTERFACE_REPRESENTATION_BYTES ? `Raise max_interface_bytes above ${String(this.maximumBytes)} bytes (maximum ${String(MAX_WASM_INTERFACE_REPRESENTATION_BYTES)}) on the next ${this.operation} call.` : `max_interface_bytes is already at the runtime representation maximum of ${String(MAX_WASM_INTERFACE_REPRESENTATION_BYTES)} bytes; no higher interface allowance is supported. A comparison may select only imports or exports; otherwise report the unsupported complete representation.`} No restart is required. Other output, heap and deadline controls do not raise this allowance. A comparison cannot restore facts omitted from partial producer Evidence; inspect the original module with a larger producer budget first when that budget is below its maximum.`,
},
);
this.#remaining -= bytes;
this.#bytes.charge(bytes, () => this.#exhausted());
}
#exhausted(): AnalysisResourceConstraintError {
return new AnalysisResourceConstraintError(
this.operation,
"capacity",
`Typed WASM interface representation exceeds max_interface_bytes (${String(this.maximumBytes)} bytes)`,
{
maximum_bytes: this.maximumBytes,
default_bytes: DEFAULT_WASM_INTERFACE_REPRESENTATION_BYTES,
maximum_supported_bytes: MAX_WASM_INTERFACE_REPRESENTATION_BYTES,
source: this.source,
unit: "bytes",
},
{
retryable: false,
remediationAction: `${this.maximumBytes < MAX_WASM_INTERFACE_REPRESENTATION_BYTES ? `Raise max_interface_bytes above ${String(this.maximumBytes)} bytes (maximum ${String(MAX_WASM_INTERFACE_REPRESENTATION_BYTES)}) on the next ${this.operation} call.` : `max_interface_bytes is already at the runtime representation maximum of ${String(MAX_WASM_INTERFACE_REPRESENTATION_BYTES)} bytes; no higher interface allowance is supported. A comparison may select only imports or exports; otherwise report the unsupported complete representation.`} No restart is required. Other output, heap and deadline controls do not raise this allowance. A comparison cannot restore facts omitted from partial producer Evidence; inspect the original module with a larger producer budget first when that budget is below its maximum.`,
},
);
}
}
+6 -5
View File
@@ -1,3 +1,4 @@
import { Budget } from "../domain/budget.js";
import {
malformedInput,
ParseFailureError,
@@ -117,13 +118,13 @@ export const modelCapacityReportedLimits = (
/** Charge representation growth before retaining rows or decoding long strings. */
export class ModelReportBudget {
#remaining = MODEL_REPORT_BYTES;
readonly #bytes = new Budget(MODEL_REPORT_BYTES);
charge(bytes: number): void {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
throw modelCapacityFailure(
this.#bytes.charge(bytes, () =>
modelCapacityFailure(
"representation-bytes",
"Model facts exceed the 32 MiB retained representation budget. This release has no runtime override.",
);
this.#remaining -= bytes;
),
);
}
}