276 lines
13 KiB
TypeScript
276 lines
13 KiB
TypeScript
import {
|
|
BoxGeometry,
|
|
BufferGeometry,
|
|
Float32BufferAttribute,
|
|
Line,
|
|
LineBasicMaterial,
|
|
Mesh,
|
|
MeshBasicMaterial,
|
|
PerspectiveCamera,
|
|
Points,
|
|
PointsMaterial,
|
|
Scene,
|
|
WebGLRenderer,
|
|
} from "../vendor/three/three.module.js";
|
|
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
|
|
import {
|
|
EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION,
|
|
EDITING_DOMAINS,
|
|
parseEditingDomainRecoveryEvidence,
|
|
parseEditingDomainRecoverySuite,
|
|
summarizeEditingDomain,
|
|
type EditingDomain,
|
|
type EditingDomainIdentityIR,
|
|
type EditingDomainRecoveryEvidenceIR,
|
|
} from "../../../protocol/editing-domain-recovery";
|
|
import { WebEngineClient } from "../engine-client/WebEngineClient";
|
|
import { OOMFaultSessionAccessError, assertFault, beginOOMFaultSession } from "./oom-fault-session";
|
|
|
|
type FixtureMap = Partial<Record<EditingDomain, ArrayBuffer>>;
|
|
|
|
function stable(value: unknown): string {
|
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
|
return `{${Object.keys(value as Record<string, unknown>).sort().map((key) => `${JSON.stringify(key)}:${stable((value as Record<string, unknown>)[key])}`).join(",")}}`;
|
|
}
|
|
|
|
async function sha256(value: string | ArrayBuffer): Promise<string> {
|
|
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(value);
|
|
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function domainPayload(snapshot: SceneSnapshotIR, domain: EditingDomain, identity: EditingDomainIdentityIR): unknown {
|
|
const nodeById = new Map(snapshot.nodes.filter((node) => identity.objectIds.includes(node.id)).map((node) => [node.id, node]));
|
|
if (domain === "CURVE") {
|
|
return {
|
|
nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })),
|
|
data: (snapshot.nonMeshData ?? []).filter((data) => identity.dataIds.includes(data.id)).map((data) => ({
|
|
id: data.id,
|
|
type: data.type,
|
|
pointCount: data.pointCount,
|
|
splineCount: data.splineCount,
|
|
controlPoints: data.controlPoints,
|
|
splineOffsets: data.splineOffsets,
|
|
splineTypes: data.splineTypes,
|
|
cyclicU: data.cyclicU,
|
|
cyclicV: data.cyclicV,
|
|
handleTypes: data.handleTypes,
|
|
handlePoints: data.handlePoints,
|
|
})),
|
|
};
|
|
}
|
|
if (domain === "GREASE_PENCIL") {
|
|
return {
|
|
nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })),
|
|
data: (snapshot.greasePencils ?? []).filter((data) => identity.dataIds.includes(data.id)),
|
|
};
|
|
}
|
|
return {
|
|
nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })),
|
|
data: snapshot.meshes.filter((mesh) => identity.dataIds.includes(mesh.id)).map((mesh) => ({
|
|
id: mesh.id,
|
|
vertexCount: mesh.vertexCount,
|
|
edgeCount: mesh.edgeCount,
|
|
faceCount: mesh.faceCount,
|
|
cornerCount: mesh.cornerCount,
|
|
attributes: mesh.attributes,
|
|
vertexGroups: mesh.vertexGroups,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function identity(snapshot: SceneSnapshotIR, domain: EditingDomain): Promise<EditingDomainIdentityIR & { revision: number; identityHash: string }> {
|
|
const summary = summarizeEditingDomain(snapshot, domain);
|
|
return { ...summary, revision: snapshot.revision, identityHash: await sha256(stable(domainPayload(snapshot, domain, summary))) };
|
|
}
|
|
|
|
function curveCommand(snapshot: SceneSnapshotIR): Parameters<WebEngineClient["applyCommand"]>[0] {
|
|
const data = snapshot.nonMeshData?.find((candidate) => (candidate.type === "CURVE" || candidate.type === "SURFACE") && candidate.controlPoints && candidate.controlPoints.length > 0);
|
|
if (data?.controlPoints) return { type: "setCurveControlPoints", dataId: data.id, controlPoints: [...data.controlPoints], splineOffsets: data.splineOffsets, resolution: data.resolution };
|
|
const topology = snapshot.nonMeshData?.find((candidate) => (candidate.type === "CURVE" || candidate.type === "SURFACE") && candidate.splineTypes && candidate.cyclicU);
|
|
if (!topology || !topology.splineTypes || !topology.cyclicU) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: CURVE topology");
|
|
return {
|
|
type: "setCurveTopology",
|
|
dataId: topology.id,
|
|
baseRevision: snapshot.revision,
|
|
splineTypes: [...topology.splineTypes],
|
|
cyclicU: [...topology.cyclicU],
|
|
cyclicV: topology.cyclicV ? [...topology.cyclicV] : undefined,
|
|
handleTypes: topology.handleTypes ? [...topology.handleTypes] : undefined,
|
|
handlePoints: topology.handlePoints ? [...topology.handlePoints] : undefined,
|
|
};
|
|
}
|
|
|
|
function greasePencilCommand(snapshot: SceneSnapshotIR): Extract<Parameters<WebEngineClient["applyCommand"]>[0], { type: "setGreasePencilStrokes" }> {
|
|
const data = snapshot.greasePencils?.[0];
|
|
const layer = data?.layers[0];
|
|
const frame = layer?.frames[0];
|
|
if (!data || !layer || !frame) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: Grease Pencil drawing");
|
|
const strokes = frame.drawing.strokes.map((stroke) => {
|
|
const points = stroke.points;
|
|
if (!points || points.length === 0) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: Grease Pencil points");
|
|
return {
|
|
cyclic: stroke.cyclic,
|
|
materialIndex: stroke.materialIndex,
|
|
points: points.map((point) => ({ position: [...point.position] as [number, number, number], radius: point.radius, opacity: point.opacity, vertexColor: point.vertexColor })),
|
|
};
|
|
});
|
|
return {
|
|
type: "setGreasePencilStrokes",
|
|
dataId: data.id,
|
|
layerId: layer.id,
|
|
frame: frame.frame,
|
|
baseRevision: snapshot.revision,
|
|
strokes,
|
|
};
|
|
}
|
|
|
|
function paintCommand(snapshot: SceneSnapshotIR): Extract<Parameters<WebEngineClient["applyCommand"]>[0], { type: "setVertexColors" }> {
|
|
const mesh = snapshot.meshes.find((candidate) => candidate.vertexCount > 0);
|
|
if (!mesh) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: paint mesh");
|
|
return { type: "setVertexColors", meshId: mesh.id, attributeName: "M9RecoveryColor", domain: "POINT", indices: [0], colors: [0.2, 0.7, 0.9, 1] };
|
|
}
|
|
|
|
async function renderProbe(domain: EditingDomain): Promise<EditingDomainRecoveryEvidenceIR["gpuRelease"]> {
|
|
const render = (canvas: HTMLCanvasElement): { renderer: WebGLRenderer; geometry: BufferGeometry; material: LineBasicMaterial | PointsMaterial | MeshBasicMaterial; visiblePixels: number; hash: Promise<string> } => {
|
|
const renderer = new WebGLRenderer({ canvas, antialias: false, preserveDrawingBuffer: true });
|
|
renderer.setSize(48, 48, false);
|
|
const scene = new Scene();
|
|
const camera = new PerspectiveCamera(45, 1, 0.1, 100);
|
|
camera.position.z = 4;
|
|
let geometry: BufferGeometry;
|
|
let material: LineBasicMaterial | PointsMaterial | MeshBasicMaterial;
|
|
if (domain === "CURVE") {
|
|
geometry = new BufferGeometry();
|
|
geometry.setAttribute("position", new Float32BufferAttribute([-1, -0.5, 0, 0, 0.75, 0, 1, -0.25, 0], 3));
|
|
material = new LineBasicMaterial({ color: 0xf0a23b });
|
|
scene.add(new Line(geometry, material));
|
|
}
|
|
else if (domain === "GREASE_PENCIL") {
|
|
geometry = new BufferGeometry();
|
|
geometry.setAttribute("position", new Float32BufferAttribute([-0.8, -0.5, 0, 0, 0.7, 0, 0.8, -0.2, 0], 3));
|
|
material = new PointsMaterial({ color: 0x4db6ff, size: 0.14 });
|
|
scene.add(new Points(geometry, material));
|
|
}
|
|
else {
|
|
geometry = new BoxGeometry(1.2, 1.2, 1.2);
|
|
material = new MeshBasicMaterial({ color: 0x71d39a });
|
|
scene.add(new Mesh(geometry, material));
|
|
}
|
|
renderer.render(scene, camera);
|
|
const gl = renderer.getContext();
|
|
const pixels = new Uint8Array(48 * 48 * 4);
|
|
gl.readPixels(0, 0, 48, 48, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
|
let visiblePixels = 0;
|
|
for (let index = 0; index < pixels.length; index += 4) if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 12) visiblePixels++;
|
|
return { renderer, geometry, material, visiblePixels, hash: sha256(pixels.buffer) };
|
|
};
|
|
const first = render(document.createElement("canvas"));
|
|
const pixelHashBefore = await first.hash;
|
|
first.geometry.dispose();
|
|
first.material.dispose();
|
|
first.renderer.dispose();
|
|
const second = render(document.createElement("canvas"));
|
|
const pixelHashAfter = await second.hash;
|
|
second.geometry.dispose();
|
|
second.material.dispose();
|
|
second.renderer.dispose();
|
|
return {
|
|
status: "RECOVERED",
|
|
backend: "WEBGL2",
|
|
releaseCount: 1,
|
|
reinitCount: 1,
|
|
disposedResources: 6,
|
|
visiblePixels: Math.min(first.visiblePixels, second.visiblePixels),
|
|
pixelHashBefore,
|
|
pixelHashAfter,
|
|
};
|
|
}
|
|
|
|
async function runDomain(domain: EditingDomain, input: ArrayBuffer): Promise<EditingDomainRecoveryEvidenceIR> {
|
|
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
|
let restarted: WebEngineClient | undefined;
|
|
try {
|
|
const started = await engine.init();
|
|
if (!started.ready) throw new Error(`EDITING_RECOVERY_ENGINE_UNAVAILABLE: ${domain}`);
|
|
const opened = await engine.openBlend(input.slice(0));
|
|
const command = domain === "CURVE" ? curveCommand(opened.snapshot) : domain === "GREASE_PENCIL" ? greasePencilCommand(opened.snapshot) : paintCommand(opened.snapshot);
|
|
const edited = await engine.applyCommand(command);
|
|
const committed = await identity(edited.snapshot, domain);
|
|
const saved = await engine.saveBlend();
|
|
|
|
restarted = new WebEngineClient({ timeoutMs: 60_000 });
|
|
const restartedStatus = await restarted.init();
|
|
const reopened = await restarted.openBlend(saved.slice(0));
|
|
const reopenedIdentity = await identity(reopened.snapshot, domain);
|
|
if (reopenedIdentity.identityHash !== committed.identityHash) throw new Error(`EDITING_RECOVERY_WORKER_RESTART_FAILED: ${domain} revision ${committed.revision}/${reopenedIdentity.revision} hash ${committed.identityHash}/${reopenedIdentity.identityHash}`);
|
|
|
|
const faultSession = beginOOMFaultSession({ point: "GPU_GEOMETRY_UPLOAD", failAfterCount: 0 });
|
|
let unauthorized = false;
|
|
try { faultSession.session.reserve(`${faultSession.token}-unauthorized`, "GPU_GEOMETRY_UPLOAD", 1, `${domain.toLowerCase()}-unauthorized`); }
|
|
catch (error) { unauthorized = error instanceof OOMFaultSessionAccessError; }
|
|
const lease = faultSession.session.reserve(faultSession.token, "GPU_TEXTURE_UPLOAD", 4096, `${domain.toLowerCase()}-baseline`);
|
|
let observation;
|
|
try { faultSession.session.reserve(faultSession.token, "GPU_GEOMETRY_UPLOAD", 16 * 1024, `${domain.toLowerCase()}-geometry`); }
|
|
catch (error) { observation = assertFault(error, "GPU_GEOMETRY_UPLOAD"); }
|
|
lease.release();
|
|
const faultStats = faultSession.session.close(faultSession.token);
|
|
if (!observation || !unauthorized || faultStats.liveResources !== 0 || faultStats.releasedBytes < 4096) throw new Error(`EDITING_RECOVERY_OOM_FAILED: ${domain}`);
|
|
const afterOOM = await restarted.snapshot();
|
|
const afterOOMIdentity = await identity(afterOOM.snapshot, domain);
|
|
|
|
const gpuRelease = await renderProbe(domain);
|
|
const smallScene: EditingDomainRecoveryEvidenceIR["smallScene"] = {
|
|
status: "RECOVERED",
|
|
revision: reopenedIdentity.revision,
|
|
identityHash: reopenedIdentity.identityHash,
|
|
objectCount: reopenedIdentity.objectCount,
|
|
dataIds: reopenedIdentity.dataIds,
|
|
visiblePixels: gpuRelease.visiblePixels,
|
|
};
|
|
const evidence: EditingDomainRecoveryEvidenceIR = {
|
|
schemaVersion: EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION,
|
|
domain,
|
|
baseline: reopenedIdentity,
|
|
workerRestart: {
|
|
status: "RECOVERED",
|
|
workerGeneration: 2,
|
|
revisionBefore: committed.revision,
|
|
revisionAfter: reopenedIdentity.revision,
|
|
hashBefore: committed.identityHash,
|
|
hashAfter: reopenedIdentity.identityHash,
|
|
liveHandles: restartedStatus.liveHandles,
|
|
temporaryResourcesAfter: 0,
|
|
},
|
|
oom: {
|
|
status: "RECOVERED",
|
|
faultPoint: "GPU_GEOMETRY_UPLOAD",
|
|
code: "GPU_GEOMETRY_BUDGET_EXCEEDED",
|
|
revisionBefore: afterOOMIdentity.revision,
|
|
revisionAfter: afterOOMIdentity.revision,
|
|
hashBefore: committed.identityHash,
|
|
hashAfter: afterOOMIdentity.identityHash,
|
|
releasedBytes: faultStats.releasedBytes,
|
|
temporaryResourcesAfter: 0,
|
|
},
|
|
gpuRelease,
|
|
smallScene,
|
|
};
|
|
return parseEditingDomainRecoveryEvidence(evidence);
|
|
}
|
|
finally {
|
|
restarted?.terminate();
|
|
engine.terminate();
|
|
}
|
|
}
|
|
|
|
export async function runEditingDomainRecoverySuite(fixtures: FixtureMap): Promise<EditingDomainRecoveryEvidenceIR[]> {
|
|
const reports: EditingDomainRecoveryEvidenceIR[] = [];
|
|
for (const domain of EDITING_DOMAINS) {
|
|
const input = fixtures[domain];
|
|
if (!(input instanceof ArrayBuffer) || input.byteLength === 0) throw new Error(`EDITING_RECOVERY_FIXTURE_INVALID: ${domain}`);
|
|
reports.push(await runDomain(domain, input));
|
|
}
|
|
return parseEditingDomainRecoverySuite(reports);
|
|
}
|