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 = { 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 { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`OOM_REPORT_INVALID: ${label}`); return value as Record; } 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; }