Files
workinf_Blender_Wasm/web/app/src/testing/editing-domain-recovery.ts
mes123456 0fe8d2bb56
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

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