157 lines
5.8 KiB
TypeScript
157 lines
5.8 KiB
TypeScript
import { OOM_FAULT_ERROR, type OOMFaultObservationIR, type OOMFaultPoint } from "../../../protocol/oom-recovery";
|
|
|
|
export interface OOMFaultConfiguration {
|
|
point: OOMFaultPoint;
|
|
failAfterBytes?: number;
|
|
failAfterCount?: number;
|
|
}
|
|
|
|
export interface OOMFaultSessionStats {
|
|
active: boolean;
|
|
currentBytes: number;
|
|
peakBytes: number;
|
|
liveResources: number;
|
|
peakResources: number;
|
|
releasedBytes: number;
|
|
allocationCount: number;
|
|
matchingAllocationCount: number;
|
|
unauthorizedAttempts: number;
|
|
triggered: boolean;
|
|
}
|
|
|
|
export interface OOMAllocationLease {
|
|
readonly id: string;
|
|
readonly bytes: number;
|
|
readonly tracked: boolean;
|
|
release(): void;
|
|
}
|
|
|
|
export class OOMFaultInjectedError extends Error {
|
|
readonly name = "OOMFaultInjectedError";
|
|
readonly observation: OOMFaultObservationIR;
|
|
|
|
constructor(observation: OOMFaultObservationIR) {
|
|
super(`${observation.code}: deterministic allocation failure at ${observation.stage}`);
|
|
this.observation = observation;
|
|
}
|
|
}
|
|
|
|
export class OOMFaultSessionAccessError extends Error {
|
|
readonly name = "OOMFaultSessionAccessError";
|
|
}
|
|
|
|
function threshold(value: number | undefined, label: string): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`OOM_FAULT_CONFIG_INVALID: ${label}`);
|
|
return value;
|
|
}
|
|
|
|
export class OOMFaultSession {
|
|
private readonly token: string;
|
|
private readonly configuration: OOMFaultConfiguration;
|
|
private readonly resources = new Map<string, number>();
|
|
private active = true;
|
|
private currentBytes = 0;
|
|
private peakBytes = 0;
|
|
private peakResources = 0;
|
|
private releasedBytes = 0;
|
|
private allocationCount = 0;
|
|
private matchingAllocationCount = 0;
|
|
private matchingBytes = 0;
|
|
private unauthorizedAttempts = 0;
|
|
private triggered = false;
|
|
|
|
constructor(configuration: OOMFaultConfiguration, token: string) {
|
|
if (!OOM_FAULT_ERROR[configuration.point]) throw new Error("OOM_FAULT_CONFIG_INVALID: point");
|
|
const failAfterBytes = threshold(configuration.failAfterBytes, "failAfterBytes");
|
|
const failAfterCount = threshold(configuration.failAfterCount, "failAfterCount");
|
|
if (failAfterBytes === undefined && failAfterCount === undefined) throw new Error("OOM_FAULT_CONFIG_INVALID: threshold required");
|
|
if (!/^[a-f0-9-]{36,128}$/.test(token)) throw new Error("OOM_FAULT_CONFIG_INVALID: token");
|
|
this.configuration = { ...configuration, failAfterBytes, failAfterCount };
|
|
this.token = token;
|
|
}
|
|
|
|
reserve(token: string, point: OOMFaultPoint, bytes: number, resourceId: string = crypto.randomUUID()): OOMAllocationLease {
|
|
if (!this.active) return { id: resourceId, bytes, tracked: false, release: () => undefined };
|
|
if (token !== this.token) {
|
|
this.unauthorizedAttempts++;
|
|
throw new OOMFaultSessionAccessError("OOM_FAULT_TOKEN_MISMATCH: fault session token is isolated");
|
|
}
|
|
if (!Number.isSafeInteger(bytes) || bytes < 0 || !resourceId || this.resources.has(resourceId)) throw new Error("OOM_FAULT_ALLOCATION_INVALID");
|
|
this.allocationCount++;
|
|
if (point === this.configuration.point) {
|
|
const nextCount = this.matchingAllocationCount + 1;
|
|
const nextBytes = this.matchingBytes + bytes;
|
|
const countExceeded = this.configuration.failAfterCount !== undefined && this.matchingAllocationCount >= this.configuration.failAfterCount;
|
|
const bytesExceeded = this.configuration.failAfterBytes !== undefined && nextBytes > this.configuration.failAfterBytes;
|
|
this.matchingAllocationCount = nextCount;
|
|
this.matchingBytes = nextBytes;
|
|
if (countExceeded || bytesExceeded) {
|
|
this.triggered = true;
|
|
const mapping = OOM_FAULT_ERROR[point];
|
|
throw new OOMFaultInjectedError({
|
|
point,
|
|
...mapping,
|
|
attemptedBytes: bytes,
|
|
allocationCount: nextCount,
|
|
failAfterBytes: this.configuration.failAfterBytes,
|
|
failAfterCount: this.configuration.failAfterCount,
|
|
});
|
|
}
|
|
}
|
|
this.resources.set(resourceId, bytes);
|
|
this.currentBytes += bytes;
|
|
this.peakBytes = Math.max(this.peakBytes, this.currentBytes);
|
|
this.peakResources = Math.max(this.peakResources, this.resources.size);
|
|
let released = false;
|
|
return {
|
|
id: resourceId,
|
|
bytes,
|
|
tracked: true,
|
|
release: () => {
|
|
if (released) return;
|
|
released = true;
|
|
const allocated = this.resources.get(resourceId);
|
|
if (allocated === undefined) return;
|
|
this.resources.delete(resourceId);
|
|
this.currentBytes -= allocated;
|
|
this.releasedBytes += allocated;
|
|
},
|
|
};
|
|
}
|
|
|
|
close(token: string): OOMFaultSessionStats {
|
|
if (this.active && token !== this.token) {
|
|
this.unauthorizedAttempts++;
|
|
throw new OOMFaultSessionAccessError("OOM_FAULT_TOKEN_MISMATCH: fault session token is isolated");
|
|
}
|
|
this.active = false;
|
|
return this.stats();
|
|
}
|
|
|
|
stats(): OOMFaultSessionStats {
|
|
return {
|
|
active: this.active,
|
|
currentBytes: this.currentBytes,
|
|
peakBytes: this.peakBytes,
|
|
liveResources: this.resources.size,
|
|
peakResources: this.peakResources,
|
|
releasedBytes: this.releasedBytes,
|
|
allocationCount: this.allocationCount,
|
|
matchingAllocationCount: this.matchingAllocationCount,
|
|
unauthorizedAttempts: this.unauthorizedAttempts,
|
|
triggered: this.triggered,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function beginOOMFaultSession(configuration: OOMFaultConfiguration): { session: OOMFaultSession; token: string } {
|
|
const token = `${crypto.randomUUID()}-${crypto.randomUUID()}`;
|
|
return { session: new OOMFaultSession(configuration, token), token };
|
|
}
|
|
|
|
export function assertFault(error: unknown, point: OOMFaultPoint): OOMFaultObservationIR {
|
|
if (!(error instanceof OOMFaultInjectedError) || error.observation.point !== point) throw error;
|
|
return error.observation;
|
|
}
|