Complete V1 performance and OOM release gates
This commit is contained in:
172
web/protocol/oom-recovery.ts
Normal file
172
web/protocol/oom-recovery.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user