Files
workinf_Blender_Wasm/web/protocol/oom-recovery.ts
2026-08-14 22:32:09 -04:00

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;
}