173 lines
7.8 KiB
TypeScript
173 lines
7.8 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
|
|
export const OOM_RECOVERY_REPORT_SCHEMA = 1 as const;
|
|
|
|
export const OOM_RECOVERY_SCENARIOS = [
|
|
"WASM_MAIN",
|
|
"OPFS_STAGING",
|
|
"GPU_RESOURCES",
|
|
"NANOVDB_RESIDENT",
|
|
] as const;
|
|
|
|
export type OOMRecoveryScenario = typeof OOM_RECOVERY_SCENARIOS[number];
|
|
|
|
export const OOM_FAULT_POINTS = [
|
|
"WASM_MAIN_OPEN_INPUT",
|
|
"WASM_MAIN_EDIT_COMMAND",
|
|
"WASM_MAIN_SAVE_RESULT",
|
|
"OPFS_STAGING_WRITE",
|
|
"GPU_GEOMETRY_UPLOAD",
|
|
"GPU_TEXTURE_UPLOAD",
|
|
"NANOVDB_RESIDENT_BUFFER",
|
|
"NANOVDB_PAGE_TABLE",
|
|
] as const;
|
|
|
|
export type OOMFaultPoint = typeof OOM_FAULT_POINTS[number];
|
|
|
|
export const OOM_FAULT_ERROR: Record<OOMFaultPoint, { code: ErrorCode; stage: string }> = {
|
|
WASM_MAIN_OPEN_INPUT: { code: "WASM_OUT_OF_MEMORY", stage: "WASM_OPEN_INPUT" },
|
|
WASM_MAIN_EDIT_COMMAND: { code: "WASM_OUT_OF_MEMORY", stage: "MAIN_EDIT_COMMAND" },
|
|
WASM_MAIN_SAVE_RESULT: { code: "WASM_OUT_OF_MEMORY", stage: "WASM_SAVE_RESULT" },
|
|
OPFS_STAGING_WRITE: { code: "STORAGE_QUOTA", stage: "OPFS_STAGING_WRITE" },
|
|
GPU_GEOMETRY_UPLOAD: { code: "GPU_GEOMETRY_BUDGET_EXCEEDED", stage: "GPU_GEOMETRY_UPLOAD" },
|
|
GPU_TEXTURE_UPLOAD: { code: "GPU_TEXTURE_BUDGET_EXCEEDED", stage: "GPU_TEXTURE_UPLOAD" },
|
|
NANOVDB_RESIDENT_BUFFER: { code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_RESIDENT_BUFFER" },
|
|
NANOVDB_PAGE_TABLE: { code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_PAGE_TABLE" },
|
|
};
|
|
|
|
export interface OOMFaultObservationIR {
|
|
point: OOMFaultPoint;
|
|
code: ErrorCode;
|
|
stage: string;
|
|
attemptedBytes: number;
|
|
allocationCount: number;
|
|
failAfterBytes?: number;
|
|
failAfterCount?: number;
|
|
}
|
|
|
|
export interface OOMMemoryReportIR {
|
|
beforeBytes: number;
|
|
peakBytes: number;
|
|
afterBytes: number;
|
|
releasedBytes: number;
|
|
}
|
|
|
|
export interface OOMStateReportIR {
|
|
revisionBefore: number;
|
|
revisionAfter: number;
|
|
hashBefore?: string;
|
|
hashAfter?: string;
|
|
temporaryResourcesBefore: number;
|
|
temporaryResourcesPeak: number;
|
|
temporaryResourcesAfter: number;
|
|
}
|
|
|
|
export interface OOMRecoveryReportIR {
|
|
schemaVersion: typeof OOM_RECOVERY_REPORT_SCHEMA;
|
|
scenario: OOMRecoveryScenario;
|
|
faults: OOMFaultObservationIR[];
|
|
memory: OOMMemoryReportIR;
|
|
state: OOMStateReportIR;
|
|
recovery: {
|
|
recovered: boolean;
|
|
sameSession: boolean;
|
|
restartedSession: boolean;
|
|
tokenIsolated: boolean;
|
|
};
|
|
checks: string[];
|
|
}
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
|
|
function record(value: unknown, label: string): Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`OOM_REPORT_INVALID: ${label}`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function integer(value: unknown, label: string): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`OOM_REPORT_INVALID: ${label}`);
|
|
return value as number;
|
|
}
|
|
|
|
export function parseOOMRecoveryReport(value: unknown): OOMRecoveryReportIR {
|
|
const source = record(value, "report must be an object");
|
|
if (source.schemaVersion !== OOM_RECOVERY_REPORT_SCHEMA || !OOM_RECOVERY_SCENARIOS.includes(source.scenario as OOMRecoveryScenario)) {
|
|
throw new Error("OOM_REPORT_INVALID: schema or scenario");
|
|
}
|
|
if (!Array.isArray(source.faults) || source.faults.length === 0) throw new Error("OOM_REPORT_INVALID: faults");
|
|
const faults = source.faults.map((candidate, index): OOMFaultObservationIR => {
|
|
const fault = record(candidate, `fault ${index}`);
|
|
const point = fault.point as OOMFaultPoint;
|
|
if (!OOM_FAULT_POINTS.includes(point)) throw new Error(`OOM_REPORT_INVALID: fault ${index} point`);
|
|
const expected = OOM_FAULT_ERROR[point];
|
|
if (fault.code !== expected.code || fault.stage !== expected.stage) throw new Error(`OOM_REPORT_INVALID: fault ${index} mapping`);
|
|
const parsed: OOMFaultObservationIR = {
|
|
point,
|
|
code: expected.code,
|
|
stage: expected.stage,
|
|
attemptedBytes: integer(fault.attemptedBytes, `fault ${index} attemptedBytes`),
|
|
allocationCount: integer(fault.allocationCount, `fault ${index} allocationCount`),
|
|
};
|
|
if (fault.failAfterBytes !== undefined) parsed.failAfterBytes = integer(fault.failAfterBytes, `fault ${index} failAfterBytes`);
|
|
if (fault.failAfterCount !== undefined) parsed.failAfterCount = integer(fault.failAfterCount, `fault ${index} failAfterCount`);
|
|
if (parsed.failAfterBytes === undefined && parsed.failAfterCount === undefined) throw new Error(`OOM_REPORT_INVALID: fault ${index} threshold`);
|
|
return parsed;
|
|
});
|
|
|
|
const rawMemory = record(source.memory, "memory");
|
|
const memory: OOMMemoryReportIR = {
|
|
beforeBytes: integer(rawMemory.beforeBytes, "memory.beforeBytes"),
|
|
peakBytes: integer(rawMemory.peakBytes, "memory.peakBytes"),
|
|
afterBytes: integer(rawMemory.afterBytes, "memory.afterBytes"),
|
|
releasedBytes: integer(rawMemory.releasedBytes, "memory.releasedBytes"),
|
|
};
|
|
if (memory.peakBytes < memory.beforeBytes || memory.peakBytes < memory.afterBytes) throw new Error("OOM_REPORT_INVALID: memory peak");
|
|
|
|
const rawState = record(source.state, "state");
|
|
const state: OOMStateReportIR = {
|
|
revisionBefore: integer(rawState.revisionBefore, "state.revisionBefore"),
|
|
revisionAfter: integer(rawState.revisionAfter, "state.revisionAfter"),
|
|
temporaryResourcesBefore: integer(rawState.temporaryResourcesBefore, "state.temporaryResourcesBefore"),
|
|
temporaryResourcesPeak: integer(rawState.temporaryResourcesPeak, "state.temporaryResourcesPeak"),
|
|
temporaryResourcesAfter: integer(rawState.temporaryResourcesAfter, "state.temporaryResourcesAfter"),
|
|
};
|
|
if (rawState.hashBefore !== undefined) {
|
|
if (typeof rawState.hashBefore !== "string" || !SHA256.test(rawState.hashBefore)) throw new Error("OOM_REPORT_INVALID: state.hashBefore");
|
|
state.hashBefore = rawState.hashBefore;
|
|
}
|
|
if (rawState.hashAfter !== undefined) {
|
|
if (typeof rawState.hashAfter !== "string" || !SHA256.test(rawState.hashAfter)) throw new Error("OOM_REPORT_INVALID: state.hashAfter");
|
|
state.hashAfter = rawState.hashAfter;
|
|
}
|
|
if ((state.hashBefore === undefined) !== (state.hashAfter === undefined)) throw new Error("OOM_REPORT_INVALID: state hashes must be paired");
|
|
if (state.temporaryResourcesPeak < state.temporaryResourcesBefore || state.temporaryResourcesPeak < state.temporaryResourcesAfter) {
|
|
throw new Error("OOM_REPORT_INVALID: temporary resource peak");
|
|
}
|
|
|
|
const rawRecovery = record(source.recovery, "recovery");
|
|
const recovery = {
|
|
recovered: rawRecovery.recovered,
|
|
sameSession: rawRecovery.sameSession,
|
|
restartedSession: rawRecovery.restartedSession,
|
|
tokenIsolated: rawRecovery.tokenIsolated,
|
|
};
|
|
if (Object.values(recovery).some((candidate) => typeof candidate !== "boolean") || !recovery.recovered || !recovery.tokenIsolated || (!recovery.sameSession && !recovery.restartedSession)) {
|
|
throw new Error("OOM_REPORT_INVALID: recovery");
|
|
}
|
|
if (!Array.isArray(source.checks) || source.checks.length === 0 || source.checks.some((check) => typeof check !== "string" || check.length === 0)) {
|
|
throw new Error("OOM_REPORT_INVALID: checks");
|
|
}
|
|
|
|
return { schemaVersion: OOM_RECOVERY_REPORT_SCHEMA, scenario: source.scenario as OOMRecoveryScenario, faults, memory, state, recovery: recovery as OOMRecoveryReportIR["recovery"], checks: [...new Set(source.checks as string[])] };
|
|
}
|
|
|
|
export function parseOOMRecoverySuite(value: unknown): OOMRecoveryReportIR[] {
|
|
if (!Array.isArray(value)) throw new Error("OOM_REPORT_INVALID: suite");
|
|
const reports = value.map(parseOOMRecoveryReport);
|
|
if (reports.length !== OOM_RECOVERY_SCENARIOS.length || new Set(reports.map((report) => report.scenario)).size !== OOM_RECOVERY_SCENARIOS.length) {
|
|
throw new Error("OOM_REPORT_INVALID: suite must contain each scenario exactly once");
|
|
}
|
|
for (const scenario of OOM_RECOVERY_SCENARIOS) if (!reports.some((report) => report.scenario === scenario)) throw new Error(`OOM_REPORT_INVALID: missing ${scenario}`);
|
|
return reports;
|
|
}
|