diff --git a/packages/domformat/conformance/viewer/mount.js b/packages/domformat/conformance/viewer/mount.js index f0e83f20..17f1db4d 100644 --- a/packages/domformat/conformance/viewer/mount.js +++ b/packages/domformat/conformance/viewer/mount.js @@ -539,6 +539,7 @@ export async function mountConformanceDom(result, host, options = {}) { pagedState = createPolycssPagedState(document, mounted, DEFAULT_LIMITS, loadStatePage, { boundTargets, onLateFailure: cleanup, + diagnostics: options.diagnostics, }); await pagedState?.prepareInitial(options.signal); compositorTiming = createPolycssCompositorTiming(document.state, document.bindings, materialized, mounted, { boundTargets }); @@ -549,6 +550,7 @@ export async function mountConformanceDom(result, host, options = {}) { pagedState, assertPagedFrameReady: (frame) => pagedState?.assertFrameReady(frame), compositorTiming, + diagnostics: options.diagnostics, }); effects = interpreters.has("polycss-effects@0") ? createPolycssEffects(materialized, document.bindings, mounted, { boundTargets }) diff --git a/packages/domformat/package.json b/packages/domformat/package.json index ed234b72..4490ecd3 100644 --- a/packages/domformat/package.json +++ b/packages/domformat/package.json @@ -32,7 +32,7 @@ "test": "npm run check && npm run build && node --import tsx --test test/*.test.js", "test:coverage": "npm run check && npm run build && node --import tsx --experimental-test-coverage --test-coverage-include=\"src/**/*.ts\" --test-coverage-lines=90 --test-coverage-branches=85 --test-coverage-functions=90 --test test/*.test.js", "test:browser": "npm run build && node scripts/run-browser-check.js", - "test:page-preparation": "node --import tsx scripts/check-page-preparation.js", + "test:page-preparation": "node --import tsx scripts/check-page-preparation.js && node --import tsx scripts/check-publication-performance.js", "check": "npm run typecheck && node --check conformance/viewer/*.js && node --check scripts/*.js && node --check viewer/*.js", "check:nversion": "node --check conformance/nversion/*.js && node --check test/nversion-viewer.js", "conformance": "python3 -B conformance/run_corpus.py && python3 -B conformance/check_canonical.py && python3 -B conformance/check_css.py", diff --git a/packages/domformat/scripts/check-publication-performance.js b/packages/domformat/scripts/check-publication-performance.js new file mode 100644 index 00000000..23cfe006 --- /dev/null +++ b/packages/domformat/scripts/check-publication-performance.js @@ -0,0 +1,712 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { createWriteStream } from "node:fs"; +import { createServer } from "node:http"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { encodeCanonicalJson } from "../src/canonical-json.js"; +import { invariant } from "../src/errors.js"; +import { buildDom } from "../src/writer.js"; +import { syntheticExecutableInteractionInput } from "../test/helpers.js"; +import { auditSequentialPagedPublicationSources } from "./publication-allocation-guard.js"; +import { + assertPublicationPagePreparationGate, + assertPublicationTraceComplete, + PUBLICATION_MAIN_TASK_EVENT, + PUBLICATION_PAGE_PREPARATION_ATTRIBUTION, + PUBLICATION_PAGE_PREPARATION_MAX_TASK_MS, + PUBLICATION_TRACE_START_CONFIG, +} from "./publication-trace-policy.js"; +import { assertSingleCycleTraceDuration, publicationFrameAdvances, publicationPageBoundariesCrossed } from "./publication-trace-window.js"; + +const execFileAsync = promisify(execFile); +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +const workspaceRoot = resolve(root, "../.."); +const configuredRuntimeRoot = resolve(process.env.DOMFORMAT_TRACE_RUNTIME_ROOT ?? root); +const configuredRuntimePackage = JSON.parse(await readFile(join(configuredRuntimeRoot, "package.json"), "utf8")); +const runtimeRoot = configuredRuntimePackage.name === "@layoutit/polycss-domformat" ? configuredRuntimeRoot : join(configuredRuntimeRoot, "packages/domformat"); +const label = process.env.DOMFORMAT_TRACE_LABEL ?? "after"; +const diagnosticsEnabled = process.env.DOMFORMAT_TRACE_DIAGNOSTICS === "1" || (process.env.DOMFORMAT_TRACE_DIAGNOSTICS !== "0" && runtimeRoot === root); +const guardedSourceFiles = Object.freeze({ pagedFile: "src/state/paged-state.ts", polycssFile: "src/state/polycss.ts", statePagesFile: "src/state-pages.ts" }); +const sequentialPagedSourceGuard = auditSequentialPagedPublicationSources({ + pagedSource: await readFile(join(runtimeRoot, guardedSourceFiles.pagedFile), "utf8"), + polycssSource: await readFile(join(runtimeRoot, guardedSourceFiles.polycssFile), "utf8"), + statePagesSource: await readFile(join(runtimeRoot, guardedSourceFiles.statePagesFile), "utf8"), + ...guardedSourceFiles, +}); +const allocationEvidence = Object.freeze({ + ...sequentialPagedSourceGuard, + enforced: runtimeRoot === root, + forbiddenSourceFormSites: sequentialPagedSourceGuard.violations.length, + forbiddenSourceFormSitesByScope: Object.freeze(Object.fromEntries(sequentialPagedSourceGuard.scopes.map((scope) => [scope, sequentialPagedSourceGuard.violations.filter((entry) => entry.scope === scope).length]))), +}); +if (allocationEvidence.enforced) invariant(allocationEvidence.pass, "PUBLICATION_ALLOCATION_GUARD", `Guarded publication scopes reintroduced forbidden source forms: ${JSON.stringify({ missingScopes: allocationEvidence.missingScopes, violations: allocationEvidence.violations })}.`); +const outputRoot = resolve(workspaceRoot, "bench/results/domformat-publication", label); +const temporary = await mkdtemp(join(tmpdir(), "domformat-publication-")); +const durationMs = Number(process.env.DOMFORMAT_PUBLICATION_DURATION_MS ?? 42_000); +const tracePostFlushSettleMs = 100; +const regressionThresholds = Object.freeze({ + mainThreadTaskMaxMs: null, + pagePreparationTaskMaxMs: PUBLICATION_PAGE_PREPARATION_MAX_TASK_MS, + basis: "The 50 ms ceiling remains enforced for page-preparation tasks. General renderer scheduler tasks, cadence, and relative-speed observations have no hard gate without attribution or a repeated-run noise distribution.", + cadenceGate: null, + relativeSpeedGate: null, +}); +const frameCount = 1_440; +const framesPerPage = 60; +const pageCount = frameCount / framesPerPage; +const tickRateHz = 30; +const workloads = Object.freeze([ + Object.freeze({ id: "cloth", leafCount: 251, denseTransformCount: 200, sparseTransformStart: 200, sparseTransformPool: 51, sparseTransformCount: 17, surfaceChangeCount: 40, visibilityChangeCount: 51, variantChangeCount: 0 }), + Object.freeze({ id: "solitaire", leafCount: 1_952, denseTransformCount: 0, sparseTransformStart: 0, sparseTransformPool: 32, sparseTransformCount: 32, surfaceChangeCount: 0, visibilityChangeCount: 32, variantChangeCount: 0 }), + Object.freeze({ id: "gravity-well", leafCount: 1_984, denseTransformCount: 0, sparseTransformStart: 0, sparseTransformPool: 40, sparseTransformCount: 40, surfaceChangeCount: 0, visibilityChangeCount: 0, variantChangeCount: 40 }), +]); +const cssgraphicsSourceManifest = Object.freeze({ + revision: "083532aa66599f1ff4618b987ccc5df462631996", + files: Object.freeze([ + Object.freeze({ path: "src/adapters/cloth/src/csscloth/client.mjs", blob: "5b0260a306e48b09fd927570683d500262ce9026" }), + Object.freeze({ path: "src/adapters/cloth/src/shared/csscloth/morphShadowPatch.mjs", blob: "2cfe9778159a0757eae8cbcffc99ba3afbecdff7" }), + Object.freeze({ path: "src/adapters/gravitywell/src/cssgravitywell/preparedPlayback.mjs", blob: "b97e8c638d5d24528084c2dbf4052129162c2596" }), + Object.freeze({ path: "src/adapters/solitaire/src/csssolitaire/preparedPlayback.mjs", blob: "8da516167305a1a653523ef3cad4e5c5ee11ac3b" }), + ]), +}); + +let server; +let browser; + +async function cssgraphicsProvenance() { + const configuredRoot = process.env.DOMFORMAT_CSSGRAPHICS_ROOT; + if (!configuredRoot) return Object.freeze({ + ...cssgraphicsSourceManifest, + manifestVerified: false, + verification: "not checked; DOMFORMAT_CSSGRAPHICS_ROOT was not supplied", + workloadRelationship: "synthetic dimensions informed by the pinned sources; no adapter code is executed and no behavior parity is claimed", + root: null, + }); + const sourceRoot = resolve(configuredRoot); + const revision = (await execFileAsync("git", ["rev-parse", `${cssgraphicsSourceManifest.revision}^{commit}`], { cwd: sourceRoot, maxBuffer: 1024 * 1024, timeout: 10_000 })).stdout.trim(); + invariant(revision === cssgraphicsSourceManifest.revision, "PUBLICATION_ADAPTER_PROVENANCE", "cssGraphics source revision does not match the pinned publication workload provenance."); + for (const file of cssgraphicsSourceManifest.files) { + const blob = (await execFileAsync("git", ["rev-parse", `${revision}:${file.path}`], { cwd: sourceRoot, maxBuffer: 1024 * 1024, timeout: 10_000 })).stdout.trim(); + invariant(blob === file.blob, "PUBLICATION_ADAPTER_PROVENANCE", `cssGraphics source ${file.path} does not match the pinned publication workload provenance.`); + } + return Object.freeze({ + ...cssgraphicsSourceManifest, + manifestVerified: true, + verification: "revision and file blob identities checked in the configured Git checkout", + workloadRelationship: "synthetic dimensions informed by the pinned sources; no adapter code is executed and no behavior parity is claimed", + root: sourceRoot, + }); +} + +function contentType(pathname) { + if (pathname.endsWith(".html")) return "text/html;charset=utf-8"; + if (pathname.endsWith(".js")) return "text/javascript;charset=utf-8"; + if (pathname.endsWith(".json")) return "application/json;charset=utf-8"; + if (pathname.endsWith(".css")) return "text/css;charset=utf-8"; + if (pathname.endsWith(".gz")) return "application/gzip"; + return "application/octet-stream"; +} + +function browserArguments() { + return process.env.DOMFORMAT_BROWSER_NO_SANDBOX === "1" || (typeof process.getuid === "function" && process.getuid() === 0) ? ["--no-sandbox"] : []; +} + +async function availableBrowser() { + const candidates = [process.env.DOMFORMAT_BROWSER, chromium.executablePath(), "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome"].filter(Boolean); + for (const candidate of candidates) { + try { await access(candidate); return candidate; } catch {} + } + invariant(false, "MISSING_RELEASE_BROWSER", "A Chromium-family browser is required for the publication trace."); +} + +function base64Integers(values, width) { + const bytes = new Uint8Array(values.length * width); + const view = new DataView(bytes.buffer); + for (let index = 0; index < values.length; index += 1) { + if (width === 1) bytes[index] = values[index]; + else if (width === 2) view.setUint16(index * width, values[index], true); + else view.setUint32(index * width, values[index], true); + } + return Buffer.from(bytes).toString("base64"); +} + +function packedBits(values) { + const bytes = new Uint8Array(Math.ceil(values.length / 8)); + for (let index = 0; index < values.length; index += 1) bytes[index >> 3] |= values[index] << (index & 7); + return Buffer.from(bytes).toString("base64"); +} + +function transform(frame, leaf) { + return `matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,${frame * 2_000 + leaf + 1},0,0,1)`; +} + +function frameTargets(workload, frame) { + const targets = []; + for (let index = 0; index < workload.denseTransformCount; index += 1) targets.push(index); + if (frame === 1) { + for (let index = 0; index < workload.sparseTransformPool; index += 1) targets.push(workload.sparseTransformStart + index); + } else { + const offset = (frame * workload.sparseTransformCount) % Math.max(1, workload.sparseTransformPool); + for (let index = 0; index < workload.sparseTransformCount; index += 1) targets.push(workload.sparseTransformStart + (offset + index) % workload.sparseTransformPool); + } + targets.sort((left, right) => left - right); + return targets; +} + +function playbackRows(workload) { + const rows = new Array(frameCount + 1); + const current = new Uint16Array(workload.leafCount); + current.fill(1); + const targets = new Array(frameCount + 1); + for (let frame = 1; frame <= frameCount; frame += 1) { + const changed = frameTargets(workload, frame); + targets[frame] = changed; + for (const leaf of changed) current[leaf] = frame; + rows[frame] = current.slice(); + } + return { rows, targets }; +} + +function playbackPage(workload, playback, pageIndex) { + const startFrame = pageIndex * framesPerPage + 1; + const endFrame = startFrame + framesPerPage - 1; + const transforms = [null, null]; + const indices = new Map(); + const reference = (value) => { + const prior = indices.get(value); + if (prior !== undefined) return prior; + const index = transforms.length; + indices.set(value, index); + transforms.push(value); + return index; + }; + const keyframeIndices = Array.from(playback.rows[startFrame], (sourceFrame, leaf) => reference(transform(sourceFrame, leaf))); + const appearances = new Array(framesPerPage).fill(0); + const modelTransforms = new Array(framesPerPage).fill(0xffffffff); + const shapeOffsets = new Array(framesPerPage + 1).fill(0); + const leafOffsets = [0]; + const leafTargets = []; + const leafTransforms = []; + for (let frame = startFrame; frame <= endFrame; frame += 1) { + for (const leaf of playback.targets[frame]) { + leafTargets.push(leaf); + leafTransforms.push(reference(transform(playback.rows[frame][leaf], leaf))); + } + leafOffsets.push(leafTargets.length); + } + const payload = { + version: 0, + codec: "polycss-paged-playback-page@0", + channel: "playback", + startFrame, + endFrame, + transforms, + keyframe: { appearance: 0, modelTransform: 0, shapeTransformIndicesBase64: base64Integers([1], 4), shapeVisibilityBitsBase64: base64Integers([1], 1), leafTransformIndicesBase64: base64Integers(keyframeIndices, 4) }, + sequential: { + appearanceIndicesBase64: base64Integers(appearances, 2), + modelTransformIndicesBase64: base64Integers(modelTransforms, 4), + shapeOffsetsBase64: base64Integers(shapeOffsets, 4), + shapeTargetIndicesBase64: "", + shapeTransformIndicesBase64: "", + shapeVisibilityBase64: "", + leafOffsetsBase64: base64Integers(leafOffsets, 4), + leafTargetIndicesBase64: base64Integers(leafTargets, 4), + leafTransformIndicesBase64: base64Integers(leafTransforms, 4), + }, + }; + const transformBytes = transforms.reduce((total, value) => total + 8 + (value?.length ?? 0) * 2, 0); + const materializedByteLength = transformBytes + 4 + 1 + workload.leafCount * 4 + framesPerPage * 6 + (framesPerPage + 1) * 8 + leafTargets.length * 8; + return { payload, descriptor: { startFrame, endFrame, transformCount: transforms.length, shapeChangeCount: 0, leafChangeCount: leafTargets.length, materializedByteLength } }; +} + +function variantPage(workload, pageIndex) { + const startFrame = pageIndex * framesPerPage + 1; + const endFrame = startFrame + framesPerPage - 1; + const keyframe = new Uint16Array(workload.leafCount); + keyframe.fill(0xffff); + keyframe.fill(0, 0, workload.variantChangeCount); + if ((startFrame - 1) % 2 === 1) keyframe.fill(1, 0, workload.variantChangeCount); + const offsets = [0, 0]; + const targets = []; + const classes = []; + for (let frame = startFrame + 1; frame <= endFrame; frame += 1) { + for (let target = 0; target < workload.variantChangeCount; target += 1) { + targets.push(target); + classes.push((frame - 1) % 2); + } + offsets.push(targets.length); + } + const payload = { + version: 0, + codec: "polycss-paged-variants-page@0", + channel: "variants", + startFrame, + endFrame, + keyframeClassIndicesBase64: base64Integers(keyframe, 2), + sequential: { offsetsBase64: base64Integers(offsets, 4), targetIndicesBase64: base64Integers(targets, 2), classIndicesBase64: base64Integers(classes, 2) }, + }; + return { payload, descriptor: { startFrame, endFrame, changeCount: targets.length, materializedByteLength: workload.leafCount * 2 + offsets.length * 4 + targets.length * 4 } }; +} + +function workloadTree(base, workload) { + const original = base.tree.nodes.find((node) => node.id === "synthetic/leaf"); + const scene = structuredClone(base.tree.nodes.find((node) => node.id === "synthetic/scene")); + const shape = structuredClone(base.tree.nodes.find((node) => node.id === "synthetic/shape")); + shape.styles = { ...shape.styles, transform: "", visibility: "visible" }; + const cursor = structuredClone(base.tree.nodes.find((node) => node.id === "synthetic/cursor")); + const cursorOpen = structuredClone(base.tree.nodes.find((node) => node.id === "synthetic/cursor:open")); + const cursorClosed = structuredClone(base.tree.nodes.find((node) => node.id === "synthetic/cursor:closed")); + const camera = structuredClone(base.tree.nodes.find((node) => node.id === "synthetic/camera")); + const nodes = [scene, shape]; + for (let leaf = 0; leaf < workload.leafCount; leaf += 1) nodes.push({ + ...structuredClone(original), + id: `synthetic/leaf:${leaf}`, + sibling: leaf, + classes: leaf < workload.variantChangeCount ? [...original.classes, "class-a"] : [...original.classes], + styles: { ...structuredClone(original.styles), backgroundPositionY: "0", transform: transform(1, leaf), visibility: "visible" }, + }); + nodes.push(cursor, cursorOpen, cursorClosed, camera); + const indexById = new Map(nodes.map((node, index) => [node.id, index])); + return { ...structuredClone(base.tree), nodes: nodes.map((node, index) => ({ ...node, index, parent: node.parent === -1 ? -1 : indexById.get(base.tree.nodes[node.parent].id) })) }; +} + +function surfacePacket(workload, leafIds) { + const faces = []; + const sourceFrameDeltas = []; + let stateOffset = 0; + for (let leaf = 0; leaf < workload.leafCount; leaf += 1) { + const stateCount = leaf < workload.surfaceChangeCount ? frameCount : 1; + faces.push({ faceId: leafIds[leaf], sourceOrder: leaf, stateOffset, stateCount, leafWidth: 16, leafHeight: 16 }); + sourceFrameDeltas.push(0, ...new Array(stateCount - 1).fill(1)); + stateOffset += stateCount; + } + const surfaceOffsets = [0]; + const faceIndexDeltas = []; + const stateIndexDeltas = []; + const visibilityOffsets = [0]; + const visibilityFaces = []; + const visibilityStart = workload.leafCount - workload.visibilityChangeCount; + const visibilityTargets = Array.from({ length: workload.visibilityChangeCount }, (_, index) => visibilityStart + index); + const currentVisibility = new Uint8Array(workload.leafCount).fill(1); + if ((frameCount - 1) & 1) for (const face of visibilityTargets) currentVisibility[face] = 0; + const encodedStates = new Uint32Array(workload.leafCount); + for (let segment = 0; segment < frameCount; segment += 1) { + const changedVisibility = visibilityTargets; + const nextVisibility = currentVisibility.slice(); + for (const face of changedVisibility) nextVisibility[face] ^= 1; + const lightingFaces = []; + for (let face = 0; face < workload.surfaceChangeCount; face += 1) { + const fromState = segment === 0 ? frameCount - 1 : segment - 1; + const toState = segment; + if (nextVisibility[face] === 1 && (currentVisibility[face] === 0 || fromState !== toState)) lightingFaces.push(face); + } + for (const face of changedVisibility) if (nextVisibility[face] === 1 && currentVisibility[face] === 0 && face >= workload.surfaceChangeCount) lightingFaces.push(face); + lightingFaces.sort((left, right) => left - right); + let previousFace = 0; + for (const [index, face] of lightingFaces.entries()) { + const targetState = face < workload.surfaceChangeCount ? segment : 0; + faceIndexDeltas.push(index === 0 ? face : face - previousFace); + stateIndexDeltas.push(targetState - encodedStates[face]); + encodedStates[face] = targetState; + previousFace = face; + } + surfaceOffsets.push(faceIndexDeltas.length); + for (const face of changedVisibility) visibilityFaces.push(face); + visibilityOffsets.push(visibilityFaces.length); + currentVisibility.set(nextVisibility); + } + return { + version: 0, + frameCount, + surface: { faces, statePacking: { stateCount: sourceFrameDeltas.length, sourceFrameDeltas } }, + transitions: { initialFrame: 1, sequential: { offsetsBase64: base64Integers(surfaceOffsets, 4), faceIndexDeltas, stateIndexDeltas }, nonInteractiveJumps: [] }, + visibility: { initialFrame: 1, initialVisibleBitsBase64: packedBits(new Uint8Array(workload.leafCount).fill(1)), sequential: { offsetsBase64: base64Integers(visibilityOffsets, 4), faceIndicesBase64: base64Integers(visibilityFaces, 2) }, nonInteractiveJumps: [] }, + }; +} + +async function workloadInput(workload) { + const input = await syntheticExecutableInteractionInput(); + const presentationState = structuredClone(input.state.channels.find((channel) => channel.codec === "static-presentation@0")); + const presentationBinding = structuredClone(input.bindings.channels.find((channel) => channel.interpreter === "static-presentation@0")); + input.tree = workloadTree(input, workload); + const playback = playbackRows(workload); + const pages = []; + for (let index = 0; index < pageCount; index += 1) { + const page = playbackPage(workload, playback, index); + const id = `${workload.id}-playback-${String(index + 1).padStart(2, "0")}`; + input.resourceInputs.push({ id, kind: "state-page", mediaType: "application/vnd.layoutit.domformat-state-page+json", path: `state/${id}.json.gz`, bytes: encodeCanonicalJson(page.payload), encoding: "gzip", codec: "polycss-paged-playback-page@0" }); + pages.push({ resource: id, ...page.descriptor }); + } + const leafIds = Array.from({ length: workload.leafCount }, (_, index) => `synthetic/leaf:${index}`); + const playbackState = { id: "playback", codec: "polycss-paged-playback@0", data: { packet: { version: 0, shapeCount: 1, leafCount: workload.leafCount, appearances: [["default", 1, 0]], timeline: { introTicks: 0, loopTicks: frameCount, frames: Array.from({ length: frameCount }, (_, index) => index + 1) }, initial: { sourceFrame: 1, appearance: 0 }, pages, lookaheadPages: 1, maxResidentPages: 8 } } }; + const surfaceState = { id: "surface", codec: "polycss-surface-packed@0", data: { packet: surfacePacket(workload, leafIds) } }; + const playbackBinding = { id: "playback", state: "playback", interpreter: "polycss-paged-playback@0", status: "executable", inputs: ["time.tick"], targets: { model: "synthetic/scene", shapes: ["synthetic/shape"], leaves: leafIds }, sinks: ["style.transform", "style.visibility"], parameters: { baseSceneTransform: "translate3d(0px, 0px, 0px)", frameCount, tickRateHz, catchUpPolicy: "single-step" } }; + const surfaceBinding = { id: "surface", state: "surface", interpreter: "polycss-surface@0", status: "executable", inputs: ["time.source-frame"], targets: { leaves: leafIds }, sinks: ["style.backgroundPositionY", "style.visibility"] }; + const states = [playbackState, presentationState, surfaceState]; + const bindings = [playbackBinding, presentationBinding, surfaceBinding]; + if (workload.variantChangeCount > 0) { + const variantPages = []; + for (let index = 0; index < pageCount; index += 1) { + const page = variantPage(workload, index); + const id = `${workload.id}-variants-${String(index + 1).padStart(2, "0")}`; + input.resourceInputs.push({ id, kind: "state-page", mediaType: "application/vnd.layoutit.domformat-state-page+json", path: `state/${id}.json.gz`, bytes: encodeCanonicalJson(page.payload), encoding: "gzip", codec: "polycss-paged-variants-page@0" }); + variantPages.push({ resource: id, ...page.descriptor }); + } + const initial = new Uint16Array(workload.leafCount); + initial.fill(0xffff); + initial.fill(0, 0, workload.variantChangeCount); + states.push({ id: "variants", codec: "polycss-paged-variants@0", data: { packet: { version: 0, frameCount, classes: ["class-a", "class-b"], effects: [{ classIndex: 0, ownerIndex: 0, targetIndex: 65535, styles: { color: "#f00" } }, { classIndex: 1, ownerIndex: 0, targetIndex: 65535, styles: { color: "#0f0" } }], initial: { frame: 1, classIndicesBase64: base64Integers(initial, 2) }, pages: variantPages, lookaheadPages: 1, maxResidentPages: 8 } } }); + bindings.push({ id: "variants", state: "variants", interpreter: "polycss-paged-variants@0", status: "executable", inputs: ["time.source-frame"], targets: { effectNodes: [], nodes: leafIds }, sinks: ["class.prepared", "style.color"] }); + } + input.state.channels = states.sort((left, right) => left.id.localeCompare(right.id)); + input.bindings.channels = bindings.sort((left, right) => left.id.localeCompare(right.id)); + const usedInputs = new Set(input.bindings.channels.flatMap((channel) => channel.inputs)); + input.bindings.inputs = input.bindings.inputs.filter((entry) => usedInputs.has(entry.id)); + input.meta = { title: `${workload.id} publication trace`, capabilities: ["css-semantic-closure", "deterministic-json", "explicit-retained-tree", "logical-assets", "prepared-paged-state", "prepared-playback", ...(workload.variantChangeCount > 0 ? ["prepared-variants"] : []), "prepared-surface-lighting"], optionalCapabilities: [], conformance: { executable: ["retained-tree", ...(workload.variantChangeCount > 0 ? ["paged-variants"] : []), "paged-playback", "presentation", "surface-lighting"], declaredOnly: [] }, counts: { nodes: input.tree.nodes.length, shapes: 1, leaves: workload.leafCount, sourceFrames: frameCount } }; + delete input.meta.initialExperience; + return input; +} + +async function writeFixtures() { + const routes = new Map(); + for (const workload of workloads) { + const built = buildDom(await workloadInput(workload)); + const directory = join(temporary, workload.id); + await mkdir(directory, { recursive: true }); + const model = join(directory, "model.json"); + await writeFile(model, built.bytes); + routes.set(`/${workload.id}/model.json`, model); + for (const [relative, bytes] of built.externalResources) { + const target = join(directory, ...relative.split("/")); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, bytes); + routes.set(`/${workload.id}/${relative}`, target); + } + } + const suite = join(temporary, "suite.html"); + await writeFile(suite, `${workloads.map((workload) => ``).join("")}`); + routes.set("/suite.html", suite); + const diagnosticViewer = join(temporary, "diagnostic-viewer.html"); + await writeFile(diagnosticViewer, '
loading'); + routes.set("/diagnostic-viewer.html", diagnosticViewer); + const diagnostics = join(temporary, "diagnostics.html"); + await writeFile(diagnostics, `${workloads.map((workload) => ``).join("")}`); + routes.set("/diagnostics.html", diagnostics); + return routes; +} + +function serve(routes, installedRuntime) { + return createServer(async (request, response) => { + try { + const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname); + if (pathname === "/favicon.ico") { + response.writeHead(204, { "cache-control": "no-store" }); + response.end(); + return; + } + const explicit = routes.get(pathname); + const installed = pathname.startsWith("/dist/"); + const sourceRoot = installed ? installedRuntime : root; + const target = explicit ?? resolve(sourceRoot, `.${pathname}`); + invariant(explicit !== undefined || target.startsWith(`${sourceRoot}${sep}`), "UNSAFE_TEST_PATH", "Publication request escaped its fixture root."); + const bytes = await readFile(target); + response.writeHead(200, { "cache-control": "no-store", "content-length": bytes.length, "content-type": contentType(target) }); + response.end(bytes); + } catch { + response.writeHead(404, { "content-type": "text/plain;charset=utf-8" }); + response.end("missing"); + } + }); +} + +const browserInstrumentation = () => { + const state = { raf: [], longTasks: [] }; + globalThis.__domformatPublicationTrace = state; + const sample = (timestamp) => { state.raf.push(timestamp); requestAnimationFrame(sample); }; + requestAnimationFrame(sample); + try { new PerformanceObserver((records) => { for (const entry of records.getEntries()) state.longTasks.push({ start: entry.startTime, duration: entry.duration }); }).observe({ type: "longtask", buffered: true }); } catch {} +}; + +async function startTrace(cdp) { + const events = []; + cdp.on("Tracing.dataCollected", (payload) => { if (Array.isArray(payload.value)) events.push(...payload.value); }); + await cdp.send("Tracing.start", PUBLICATION_TRACE_START_CONFIG); + return events; +} + +async function stopTrace(cdp) { + const complete = once(cdp, "Tracing.tracingComplete"); + await cdp.send("Tracing.end"); + const [completion] = await complete; + return completion; +} + +async function settleTraceEnd(page) { + await page.evaluate(() => new Promise((resolveSettle) => { + requestAnimationFrame(() => requestAnimationFrame(() => { + performance.mark("domformat-publication:flush"); + setTimeout(resolveSettle, 0); + })); + })); + await page.waitForTimeout(tracePostFlushSettleMs); +} + +async function collectDiagnostics(context, origin) { + if (!diagnosticsEnabled) return null; + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + await page.goto(`${origin}/diagnostics.html`, { waitUntil: "load", timeout: 120_000 }); + await page.waitForFunction(() => [...document.querySelectorAll("iframe")].every((frame) => frame.contentDocument?.documentElement.hasAttribute("data-domformat-ready")), undefined, { timeout: 120_000 }); + const starts = await page.evaluate(() => { + const values = []; + for (const frame of document.querySelectorAll("iframe")) { + const win = frame.contentWindow; + win.__publicationLeaves = [...win.document.querySelectorAll("[data-domformat-node]")]; + for (const key of Object.keys(win.domformatDiagnosticProof.diagnostics)) win.domformatDiagnosticProof.diagnostics[key] = 0; + values.push({ id: frame.dataset.workload, startFrame: win.domformatDiagnosticProof.sourceFrame }); + } + return values; + }); + await page.waitForFunction(({ startById, minimumAdvances }) => [...document.querySelectorAll("iframe")].every((frame) => frame.contentWindow.domformatDiagnosticProof.sourceFrame >= startById[frame.dataset.workload] + minimumAdvances), { startById: Object.fromEntries(starts.map((entry) => [entry.id, entry.startFrame])), minimumAdvances: framesPerPage * 2 }, { timeout: 60_000 }); + const evidence = await page.evaluate((startById) => [...document.querySelectorAll("iframe")].map((frame) => { + const win = frame.contentWindow; + return { id: frame.dataset.workload, startFrame: startById[frame.dataset.workload], endFrame: win.domformatDiagnosticProof.sourceFrame, diagnostics: { ...win.domformatDiagnosticProof.diagnostics }, stableIdentity: win.__publicationLeaves.every((leaf) => leaf.isConnected && leaf.ownerDocument === win.document) }; + }), Object.fromEntries(starts.map((entry) => [entry.id, entry.startFrame]))); + invariant(errors.length === 0 && evidence.every((entry) => entry.stableIdentity), "PUBLICATION_DIAGNOSTIC_FAILURE", `Publication diagnostic pass failed (${errors.join("; ")}).`); + await page.close(); + return evidence; +} + +function percentile(values, ratio) { + if (values.length === 0) return 0; + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]; +} + +function fixed(value) { return Number(value.toFixed(3)); } + +async function writeRawTrace(path, events, traceCompletion) { + const output = createWriteStream(path); + output.write(`{"domformatCapture":${JSON.stringify({ startConfig: PUBLICATION_TRACE_START_CONFIG, tracingComplete: traceCompletion ?? null })},"traceEvents":[`); + for (let start = 0; start < events.length; start += 256) { + const chunk = events.slice(start, start + 256).map((event) => JSON.stringify(event)).join(","); + if (!output.write(`${start === 0 ? "" : ","}${chunk}`)) await once(output, "drain"); + } + output.end("]}"); + await once(output, "close"); +} + +function pageBoundariesCrossed(startFrame, endFrame) { + return publicationPageBoundariesCrossed(startFrame, endFrame, frameCount, framesPerPage); +} + +function frameAdvances(startFrame, endFrame) { + return publicationFrameAdvances(startFrame, endFrame, frameCount); +} + +function baselineDiagnostics(workload, startFrame, endFrame) { + const advances = frameAdvances(startFrame, endFrame); + const boundaries = pageBoundariesCrossed(startFrame, endFrame); + const playbackVisits = advances * (workload.denseTransformCount + workload.sparseTransformCount); + return { + playbackCanonicalReconstructions: advances, + playbackCanonicalShapeVisits: advances, + playbackCanonicalLeafVisits: advances * workload.leafCount, + playbackPublicationShapeVisits: 0, + playbackPublicationLeafVisits: playbackVisits, + playbackBoundaryShapeVisits: boundaries * 3, + playbackBoundaryLeafVisits: boundaries * (workload.leafCount * 2 + (workload.denseTransformCount + workload.sparseTransformCount) * 3), + variantCanonicalReconstructions: workload.variantChangeCount > 0 ? boundaries : 0, + variantLogicalTargetVisits: workload.variantChangeCount > 0 ? boundaries * workload.leafCount + (advances - boundaries) * workload.variantChangeCount : 0, + variantComparisonTargetVisits: workload.variantChangeCount > 0 ? advances * workload.leafCount : 0, + variantDomWrites: workload.variantChangeCount > 0 ? advances * workload.variantChangeCount * 2 : 0, + surfaceFullReconstructions: 0, + surfaceLightingTargetVisits: advances * workload.surfaceChangeCount, + surfaceVisibilityTargetVisits: advances * workload.visibilityChangeCount, + }; +} + +function summarizeTrace(events, startPerf, endPerf, evidence) { + const start = events.find((event) => event.name === "domformat-publication:start" && Number.isFinite(event.ts)); + const end = events.findLast((event) => event.name === "domformat-publication:end" && Number.isFinite(event.ts)); + const flush = events.findLast((event) => event.name === "domformat-publication:flush" && Number.isFinite(event.ts)); + const missing = [["start", start], ["end", end], ["flush", flush]].filter(([, event]) => !event).map(([name]) => name); + invariant(missing.length === 0, "TRACE_MARK_MISSING", `Publication trace lacks CDP user-timing mark(s): ${missing.join(", ")}.`); + invariant(start.pid === end.pid && start.tid === end.tid && end.pid === flush.pid && end.tid === flush.tid && start.ts < end.ts && end.ts < flush.ts, "TRACE_MARK_ORDER", "Publication trace marks are not ordered on one renderer thread."); + const startTrace = start.ts; + const endTrace = end.ts; + const tasks = events.filter((event) => event.ph === "X" && event.name === PUBLICATION_MAIN_TASK_EVENT && event.pid === end.pid && event.tid === end.tid && event.ts < endTrace && event.ts + event.dur > startTrace); + const idle = events.filter((event) => event.ph === "X" && event.name === "FireIdleCallback" && event.pid === end.pid && event.tid === end.tid && event.ts < endTrace && event.ts + event.dur > startTrace); + const pageTasks = tasks.filter((task) => idle.some((callback) => callback.ts >= task.ts && callback.ts + callback.dur <= task.ts + task.dur)); + const taskDurations = tasks.map((event) => event.dur / 1_000); + const pageDurations = pageTasks.map((event) => event.dur / 1_000); + const rafGaps = evidence.raf.slice(1).map((value, index) => value - evidence.raf[index]); + const observedLongTasks = evidence.longTasks.filter((entry) => entry.start < endPerf && entry.start + entry.duration > startPerf); + const dropped = rafGaps.filter((gap) => gap > 50); + const groups = new Map(); + for (const event of events) { + if (event.ph !== "X" || event.pid !== end.pid || event.tid !== end.tid || event.ts >= endTrace || event.ts + event.dur <= startTrace || event.name === PUBLICATION_MAIN_TASK_EVENT) continue; + groups.set(event.name, (groups.get(event.name) ?? 0) + event.dur / 1_000); + } + const topEvents = [...groups].sort((left, right) => right[1] - left[1]).slice(0, 12).map(([name, totalMs]) => ({ name, totalMs: fixed(totalMs) })); + return { + durationMs: fixed((endTrace - startTrace) / 1_000), + performanceClockDurationMs: fixed(endPerf - startPerf), + marks: { start: "domformat-publication:start", end: "domformat-publication:end", flush: "domformat-publication:flush", endToFlushMs: fixed((flush.ts - end.ts) / 1_000), postFlushSettleMs: tracePostFlushSettleMs }, + mainThread: { taskCount: tasks.length, maxTaskMs: fixed(Math.max(0, ...taskDurations)), p95TaskMs: fixed(percentile(taskDurations, 0.95)), longTaskObserverCount: observedLongTasks.length }, + cadence: { sampleCount: rafGaps.length, p50Ms: fixed(percentile(rafGaps, 0.5)), p95Ms: fixed(percentile(rafGaps, 0.95)), maxPresentationGapMs: fixed(Math.max(0, ...rafGaps)), gapsOver50Ms: dropped.length }, + pagePreparation: { attribution: PUBLICATION_PAGE_PREPARATION_ATTRIBUTION, idleCallbackCount: idle.length, taskCount: pageTasks.length, maxTaskMs: fixed(Math.max(0, ...pageDurations)), p95TaskMs: fixed(percentile(pageDurations, 0.95)) }, + remainingDroppedFrameCauses: { gapsOver50Ms: dropped.length, topMainThreadEvents: topEvents }, + }; +} + +function markdown(report) { + const diagnosticRows = report.workloads.map((entry) => `| ${entry.id} | ${entry.traceStartFrame} | ${entry.traceEndFrame} | ${entry.tracePageBoundariesCrossed} | ${entry.visitStartFrame} | ${entry.visitEndFrame} | ${entry.visitPageBoundariesCrossed} | ${entry.visitEvidenceProvenance} | ${entry.diagnostics.playbackPublicationLeafVisits} | ${(entry.diagnostics.playbackBoundaryShapeVisits ?? 0) + (entry.diagnostics.playbackBoundaryLeafVisits ?? 0)} | ${entry.diagnostics.variantComparisonTargetVisits} | ${entry.diagnostics.surfaceLightingTargetVisits} | ${entry.diagnostics.surfaceVisibilityTargetVisits} | ${entry.diagnostics.playbackCanonicalReconstructions + entry.diagnostics.variantCanonicalReconstructions + entry.diagnostics.surfaceFullReconstructions} |`); + const allocation = report.allocationEvidence; + const provenance = report.cssgraphicsProvenance; + return `# DOMFormat prepared publication trace (${report.label})\n\nRuntime root: \`${report.runtimeRoot}\`\n\nRuntime revision: \`${report.runtimeGitRevision}\` (dirty: ${report.runtimeGitDirty ? "yes" : "no"})\n\nPacked tarball SHA-256: \`${report.packedTarballSha256}\`\n\nBrowser: ${report.browserVersion}\n\nTrace capture: \`${report.traceCapture.startConfig.traceConfig.recordMode}\` across ${report.traceCapture.startConfig.traceConfig.includedCategories.length} categories; data loss reported: ${report.traceCapture.dataLossOccurred ? "yes" : "no"}; ${report.traceCapture.eventCount} raw events.\n\nTrace-marker duration: ${report.trace.durationMs.toFixed(3)} ms (requested: ${report.requestedDurationMs} ms)\n\nTrace end was followed by two animation frames and a zero-delay task; the \`${report.trace.marks.flush}\` user-timing mark arrived ${report.trace.marks.endToFlushMs.toFixed(3)} ms after the end mark, followed by a ${report.trace.marks.postFlushSettleMs} ms settle before \`Tracing.end\`. The raw trace is written before trace interpretation, data-loss validation, or threshold checks so failed evidence remains inspectable.\n\n| Max main task | Max RAF gap | Cadence p50 | Cadence p95 | Page-preparation max |\n|---:|---:|---:|---:|---:|\n| ${report.trace.mainThread.maxTaskMs.toFixed(3)} ms | ${report.trace.cadence.maxPresentationGapMs.toFixed(3)} ms | ${report.trace.cadence.p50Ms.toFixed(3)} ms | ${report.trace.cadence.p95Ms.toFixed(3)} ms | ${report.trace.pagePreparation.maxTaskMs.toFixed(3)} ms |\n\nThe only enforced timing threshold is ${report.regressionThresholds.pagePreparationTaskMaxMs} ms for page-preparation tasks, preserving the browser Long Tasks boundary. It is bound to ${report.trace.pagePreparation.taskCount} renderer task(s) containing ${report.trace.pagePreparation.idleCallbackCount} \`FireIdleCallback\` event(s); zero attribution fails instead of passing with a synthetic zero. General renderer scheduler tasks, RAF cadence, and relative-speed values are reported observations, not gates: they include unattributed browser/compositor work, and this single headless run is not a noise-calibrated regression distribution.\n\n| Synthetic publication stress fixture | Trace start | Trace end | Trace boundaries | Visit start | Visit end | Visit boundaries | Visit evidence | Sparse playback leaf visits | Boundary validation visits | Variant compares | Surface visits | Visibility visits | Full reconstructions |\n|---|---:|---:|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|---:|\n${diagnosticRows.join("\n")}\n\nTrace and visit boundaries are computed from their captured start frames, including a cyclic final-to-first transition. Visit counters cover transitions after **Visit start** through **Visit end**, not necessarily the independently timed trace window. Raw visit totals from different endpoints must not be compared without normalization. \`diagnosticStartFrame\` and \`diagnosticEndFrame\` are retained in JSON when visits came from the separate repo-internal conformance pass. That pass uses package-internal diagnostics and is not the timed production viewer path.\n\n## Bounded publication source-form evidence\n\nThe bounded TypeScript source guard found ${allocation.forbiddenSourceFormSites} forbidden source-form site(s) in its ${allocation.scopes.length} named guarded scopes. Guard enforced: ${allocation.enforced ? "yes" : "no (historical comparison runtime)"}. Paged dispatch precedes inline materialization: ${allocation.pagedDispatchBeforeInlineMaterialization ? "yes" : "no"}. Page-boundary validation call present: ${allocation.pageBoundaryValidationCalled ? "yes" : "no"}. Missing guarded scopes: ${allocation.missingScopes.length}.\n\nThis is not a general JavaScript heap-allocation measurement and does not traverse the call graph. It rejects typed/ordinary \`slice()\`, \`Array.from()\`, array/typed-array constructors, generic array literals, spread-array clones, Set/Map construction, sorting, and nested closures only in the named guarded scopes; explicit complete-row reconstruction branches remain outside the sparse-range claim.\n\nThe timing window uses the production browser mount with no diagnostics. Candidate-runtime target visits come from a separate repo-internal conformance pass. Historical-runtime target visits are deterministic workload estimates, not collected diagnostics. Retained identities are checked in both browser passes when the internal pass runs. Flowerbox is intentionally excluded. These are synthetic stress fixtures whose dimensions were selected with reference to the pinned Cloth, Solitaire, and Gravity Well sources; they do not execute adapter code and do not establish adapter behavior or performance parity. The pinned cssGraphics source manifest is \`${provenance.revision}\`; manifest verification: ${provenance.verification}. Exact file paths and Git blob ids are recorded in \`cssgraphicsProvenance.files\`.\n`; +} + +try { + assertSingleCycleTraceDuration(durationMs, frameCount, tickRateHz); + await mkdir(outputRoot, { recursive: true }); + const rawTrace = join(outputRoot, "publication.trace.json"); + const routes = await writeFixtures(); + const runtimeGitRevision = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: runtimeRoot, maxBuffer: 1024 * 1024, timeout: 10_000 })).stdout.trim(); + const runtimeGitDirty = (await execFileAsync("git", ["status", "--porcelain"], { cwd: runtimeRoot, maxBuffer: 32 * 1024 * 1024, timeout: 10_000 })).stdout.trim().length > 0; + invariant(/^[0-9a-f]{40}$/u.test(runtimeGitRevision), "PUBLICATION_RUNTIME_IDENTITY", "Publication runtime git revision is unavailable."); + const adapterProvenance = await cssgraphicsProvenance(); + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const packRoot = join(temporary, "pack"); + const installRoot = join(temporary, "install"); + await Promise.all([mkdir(packRoot), mkdir(installRoot)]); + await execFileAsync(npm, ["run", "build"], { cwd: runtimeRoot, maxBuffer: 32 * 1024 * 1024, timeout: 120_000 }); + const packed = await execFileAsync(npm, ["pack", "--json", "--pack-destination", packRoot], { cwd: runtimeRoot, maxBuffer: 32 * 1024 * 1024, timeout: 120_000 }); + const reportStart = packed.stdout.lastIndexOf("\n["); + const packReports = JSON.parse(reportStart === -1 ? packed.stdout : packed.stdout.slice(reportStart + 1)); + const packedTarball = join(packRoot, packReports[0].filename); + const packedTarballSha256 = createHash("sha256").update(await readFile(packedTarball)).digest("hex"); + await execFileAsync(npm, ["install", "--prefix", installRoot, "--no-audit", "--no-fund", packedTarball], { maxBuffer: 32 * 1024 * 1024, timeout: 120_000 }); + const installedRuntime = join(installRoot, "node_modules", "@layoutit", "polycss-domformat"); + server = serve(routes, installedRuntime); + await new Promise((resolveListen, rejectListen) => { server.once("error", rejectListen); server.listen(0, "127.0.0.1", resolveListen); }); + const address = server.address(); + invariant(address && typeof address === "object", "PUBLICATION_SERVER", "Publication trace server did not bind."); + const executablePath = await availableBrowser(); + browser = await chromium.launch({ executablePath, headless: true, args: browserArguments() }); + const context = await browser.newContext({ viewport: { width: 1_440, height: 900 }, deviceScaleFactor: 1 }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + await page.addInitScript(browserInstrumentation); + const origin = `http://127.0.0.1:${address.port}`; + await page.goto(`${origin}/suite.html`, { waitUntil: "load", timeout: 120_000 }); + try { + await page.waitForFunction(() => [...document.querySelectorAll("iframe")].every((frame) => frame.contentDocument?.documentElement.hasAttribute("data-domformat-ready")), undefined, { timeout: 120_000 }); + } catch (error) { + throw new Error(`Publication fixtures did not become ready (${errors.join("; ")}).`, { cause: error }); + } + await page.evaluate(() => { + for (const frame of document.querySelectorAll("iframe")) { + const win = frame.contentWindow; + win.__publicationLeaves = [...win.document.querySelectorAll("[data-domformat-node]")]; + } + }); + const cdp = await context.newCDPSession(page); + const events = await startTrace(cdp); + let traceActive = true; + let traceCompletion; + let startPerf; + let evidence; + try { + const startEvidence = await page.evaluate(() => { + globalThis.__domformatPublicationTrace.raf.length = 0; + globalThis.__domformatPublicationTrace.longTasks.length = 0; + performance.mark("domformat-publication:start"); + return { + startPerf: performance.now(), + workloads: [...document.querySelectorAll("iframe")].map((frame) => ({ id: frame.dataset.workload, startFrame: frame.contentWindow.domformatProof.sourceFrame })), + }; + }); + startPerf = startEvidence.startPerf; + await page.waitForTimeout(durationMs); + evidence = await page.evaluate((startWorkloads) => { + performance.mark("domformat-publication:end"); + const endPerf = performance.now(); + const starts = Object.fromEntries(startWorkloads.map((entry) => [entry.id, entry.startFrame])); + return { + endPerf, + raf: [...globalThis.__domformatPublicationTrace.raf], + longTasks: [...globalThis.__domformatPublicationTrace.longTasks], + workloads: [...document.querySelectorAll("iframe")].map((frame) => { + const win = frame.contentWindow; + return { id: frame.dataset.workload, startFrame: starts[frame.dataset.workload], endFrame: win.domformatProof.sourceFrame, stableIdentity: win.__publicationLeaves.every((leaf) => leaf.isConnected && leaf.ownerDocument === win.document) }; + }), + }; + }, startEvidence.workloads); + await settleTraceEnd(page); + traceCompletion = await stopTrace(cdp); + traceActive = false; + } catch (error) { + if (traceActive) { + try { traceCompletion = await stopTrace(cdp); } catch {} + traceActive = false; + } + try { + await writeRawTrace(rawTrace, events, traceCompletion); + process.stderr.write(`Publication trace failed; raw trace preserved at ${rawTrace}.\n`); + } catch (preservationError) { + throw new AggregateError([error, preservationError], "Publication trace failed and its raw events could not be preserved."); + } + throw error; + } + await writeRawTrace(rawTrace, events, traceCompletion); + assertPublicationTraceComplete(traceCompletion); + assertSingleCycleTraceDuration(evidence.endPerf - startPerf, frameCount, tickRateHz); + invariant(errors.length === 0 && evidence.workloads.every((entry) => entry.stableIdentity), "PUBLICATION_TRACE_FAILURE", `Publication trace failed (${errors.join("; ")}).`); + const trace = summarizeTrace(events, startPerf, evidence.endPerf, evidence); + assertPublicationPagePreparationGate(trace); + await page.close(); + const diagnosticEvidence = await collectDiagnostics(context, origin); + const diagnosticsByWorkload = new Map((diagnosticEvidence ?? []).map((entry) => [entry.id, entry])); + const resultWorkloads = evidence.workloads.map((entry) => { + const workload = workloads.find((candidate) => candidate.id === entry.id); + const diagnostic = diagnosticsByWorkload.get(entry.id); + const visitStartFrame = diagnostic?.startFrame ?? entry.startFrame; + const visitEndFrame = diagnostic?.endFrame ?? entry.endFrame; + return { + id: entry.id, + stableIdentity: entry.stableIdentity, + traceStartFrame: entry.startFrame, + traceEndFrame: entry.endFrame, + tracePageBoundariesCrossed: pageBoundariesCrossed(entry.startFrame, entry.endFrame), + diagnosticStartFrame: diagnostic?.startFrame ?? null, + diagnosticEndFrame: diagnostic?.endFrame ?? null, + visitStartFrame, + visitEndFrame, + visitWindowMatchesTrace: visitStartFrame === entry.startFrame && visitEndFrame === entry.endFrame, + visitPageBoundariesCrossed: pageBoundariesCrossed(visitStartFrame, visitEndFrame), + visitEvidenceProvenance: diagnostic ? "separate-repo-internal-conformance-pass" : "deterministic-workload-estimate", + diagnostics: diagnostic?.diagnostics ?? baselineDiagnostics(workload, visitStartFrame, visitEndFrame), + }; + }); + invariant(resultWorkloads.every((entry) => entry.tracePageBoundariesCrossed >= 2 && entry.visitPageBoundariesCrossed >= 2), "PUBLICATION_BOUNDARY_COVERAGE", "Every publication timing and visit-evidence window must cross at least two page boundaries."); + const report = { label, runtimeRoot, runtimeGitRevision, runtimeGitDirty, packedTarballSha256, diagnosticsEnabled, timingInstrumented: false, traceCapture: { startConfig: PUBLICATION_TRACE_START_CONFIG, dataLossOccurred: traceCompletion.dataLossOccurred, eventCount: events.length }, regressionThresholds, allocationEvidence, cssgraphicsProvenance: adapterProvenance, browserVersion: browser.version(), requestedDurationMs: durationMs, framesPerPage, frameCount, packedTarballBytes: packReports[0].size, trace, workloads: resultWorkloads, rawTrace }; + await writeFile(join(outputRoot, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + await writeFile(join(outputRoot, "report.md"), markdown(report)); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + await context.close(); +} finally { + if (browser) await browser.close(); + if (server) await new Promise((resolveClose) => server.close(resolveClose)); + await rm(temporary, { recursive: true, force: true }); +} diff --git a/packages/domformat/scripts/publication-allocation-guard.js b/packages/domformat/scripts/publication-allocation-guard.js new file mode 100644 index 00000000..4c5af4c0 --- /dev/null +++ b/packages/domformat/scripts/publication-allocation-guard.js @@ -0,0 +1,200 @@ +import ts from "typescript"; + +const FORBIDDEN_SCOPE_NAMES = Object.freeze([ + "playbackSparseStage", + "stagePlayback:sequential", + "stageVariants:sequential", + "applyPlaybackStage:range", + "applyVariantStage:range", + "publishVariantTarget", + "installActiveStage", + "applyStage:range", + "publishStageShapeVisibility", + "publishSurfaceTarget", + "publishSurfaceRangeWithForced", + "applySurface", + "stageProfileVisibility", + "recoverSurface", + "recoverPendingTransforms", + "publishProfileVisibility", + "publishRecoveredShapeVisibility", +]); + +function optionalNamedFunction(sourceFile, name) { + let match; + const visit = (node) => { + if (match) return; + if (ts.isFunctionDeclaration(node) && node.name?.text === name) match = node; + else if (ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) + && node.name.text === name + && node.initializer + && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) match = node.initializer; + if (!match) ts.forEachChild(node, visit); + }; + visit(sourceFile); + return match; +} + +function namedFunction(sourceFile, name) { + const match = optionalNamedFunction(sourceFile, name); + if (!match) throw new Error(`Publication allocation guard could not find ${name}.`); + return match; +} + +function optionalBranchWithCondition(sourceFile, functionNode, pattern) { + let branch; + const visit = (node) => { + if (branch) return; + if (ts.isIfStatement(node) && pattern.test(node.expression.getText(sourceFile))) branch = node.thenStatement; + if (!branch) ts.forEachChild(node, visit); + }; + visit(functionNode.body); + return branch; +} + +function branchWithCondition(sourceFile, functionNode, pattern, label) { + const branch = optionalBranchWithCondition(sourceFile, functionNode, pattern); + if (!branch) throw new Error(`Publication allocation guard could not find ${label}.`); + return branch; +} + +function forbiddenOperation(sourceFile, node) { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { + const owner = node.expression.expression.getText(sourceFile); + const method = node.expression.name.text; + if (method === "slice") return "slice-copy"; + if (method === "from" && /(?:^|\.)(?:Array|(?:Uint|Int|Float|BigInt|BigUint)\d*Array)$/u.test(owner)) return "array-from-copy"; + if (method === "sort" || method === "toSorted") return "sort-call"; + } + if (ts.isNewExpression(node)) { + const constructor = node.expression.getText(sourceFile); + if (/(?:^|\.)(?:Array|(?:Uint|Int|Float|BigInt|BigUint)\d*Array)$/u.test(constructor)) return "array-constructor"; + if (/(?:^|\.)(?:Set|Map)$/u.test(constructor)) return "set-map-constructor"; + } + if (ts.isArrayLiteralExpression(node)) return node.elements.some(ts.isSpreadElement) ? "spread-array-clone" : "array-literal"; + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) return "nested-closure"; + return null; +} + +function within(node, ancestor) { + for (let current = node; current; current = current.parent) if (current === ancestor) return true; + return false; +} + +function auditNode(sourceFile, node, scope, excluded = []) { + const violations = []; + const visit = (candidate) => { + if (excluded.some((branch) => within(candidate, branch))) return; + const operation = forbiddenOperation(sourceFile, candidate); + if (operation) { + const position = sourceFile.getLineAndCharacterOfPosition(candidate.getStart(sourceFile)); + violations.push(Object.freeze({ + scope, + operation, + line: position.line + 1, + column: position.character + 1, + expression: candidate.getText(sourceFile).replace(/\s+/gu, " ").slice(0, 160), + })); + } + ts.forEachChild(candidate, visit); + }; + visit(node); + return violations; +} + +function completeBranches(sourceFile, functionNode) { + const branches = []; + const visit = (node) => { + if (ts.isIfStatement(node) && /stage\.(?:kind\s*===\s*["']complete["']|complete)/u.test(node.expression.getText(sourceFile))) branches.push(node.thenStatement); + ts.forEachChild(node, visit); + }; + visit(functionNode.body); + return branches; +} + +function pagedDispatchGuard(sourceFile) { + const stageFrame = namedFunction(sourceFile, "stageFrame"); + const first = ts.isBlock(stageFrame.body) ? stageFrame.body.statements[0] : undefined; + const text = first?.getText(sourceFile).replace(/\s+/gu, " ") ?? ""; + return Boolean(first + && ts.isIfStatement(first) + && /packet\.kind\s*===\s*["']paged["']/u.test(first.expression.getText(sourceFile)) + && /return\s+options\.pagedState!?\.stage\(frame,\s*true\)/u.test(text)); +} + +export function auditSequentialPagedPublicationSources({ pagedSource, polycssSource, statePagesSource, pagedFile = "src/state/paged-state.ts", polycssFile = "src/state/polycss.ts", statePagesFile = "src/state-pages.ts" }) { + const paged = ts.createSourceFile(pagedFile, pagedSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const polycss = ts.createSourceFile(polycssFile, polycssSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const statePages = ts.createSourceFile(statePagesFile, statePagesSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const playbackSparseStage = namedFunction(paged, "playbackSparseStage"); + const stagePlayback = namedFunction(paged, "stagePlayback"); + const stageVariants = namedFunction(paged, "stageVariants"); + const applyPlaybackStage = namedFunction(paged, "applyPlaybackStage"); + const applyVariantStage = namedFunction(paged, "applyVariantStage"); + const publishVariantTarget = optionalNamedFunction(paged, "publishVariantTarget"); + const installActiveStage = optionalNamedFunction(paged, "installActiveStage"); + const applyStage = namedFunction(polycss, "applyStage"); + const publishStageShapeVisibility = optionalNamedFunction(polycss, "publishStageShapeVisibility"); + const publishSurfaceTarget = optionalNamedFunction(polycss, "publishSurfaceTarget"); + const publishSurfaceRangeWithForced = optionalNamedFunction(polycss, "publishSurfaceRangeWithForced"); + const applySurface = namedFunction(polycss, "applySurface"); + const stageProfileVisibility = namedFunction(polycss, "stageProfileVisibility"); + const recoverSurface = optionalNamedFunction(polycss, "recoverSurface"); + const recoverPendingTransforms = optionalNamedFunction(polycss, "recoverPendingTransforms"); + const publishProfileVisibility = optionalNamedFunction(polycss, "publishProfileVisibility"); + const publishRecoveredShapeVisibility = optionalNamedFunction(polycss, "publishRecoveredShapeVisibility"); + const validatePagedPlaybackBoundaryFromCanonical = namedFunction(statePages, "validatePagedPlaybackBoundaryFromCanonical"); + const stagePlaybackSequential = branchWithCondition(paged, stagePlayback, /frame\s*===\s*expected|frame\s*===\s*\(.*expected/u, "stagePlayback sequential branch"); + const stageVariantsSequential = branchWithCondition(paged, stageVariants, /frame\s*===\s*expected/u, "stageVariants sequential branch"); + const pageBoundaryValidationCalled = /validatePagedPlaybackBoundaryFromCanonical\s*\(/u.test(stagePlaybackSequential.getText(paged)); + const applyStageRange = optionalBranchWithCondition(polycss, applyStage, /next\.kind\s*===\s*["']range["']/u); + const missingScopes = [ + ...(publishVariantTarget ? [] : ["publishVariantTarget"]), + ...(installActiveStage ? [] : ["installActiveStage"]), + ...(applyStageRange ? [] : ["applyStage:range"]), + ...(publishStageShapeVisibility ? [] : ["publishStageShapeVisibility"]), + ...(publishSurfaceTarget ? [] : ["publishSurfaceTarget"]), + ...(publishSurfaceRangeWithForced ? [] : ["publishSurfaceRangeWithForced"]), + ...(recoverSurface ? [] : ["recoverSurface"]), + ...(recoverPendingTransforms ? [] : ["recoverPendingTransforms"]), + ...(publishProfileVisibility ? [] : ["publishProfileVisibility"]), + ...(publishRecoveredShapeVisibility ? [] : ["publishRecoveredShapeVisibility"]), + ...(pageBoundaryValidationCalled ? [] : ["validatePagedPlaybackBoundaryFromCanonical:call-site"]), + ]; + const violations = [ + ...auditNode(paged, playbackSparseStage.body, "playbackSparseStage"), + ...auditNode(paged, stagePlaybackSequential, "stagePlayback:sequential"), + ...auditNode(paged, stageVariantsSequential, "stageVariants:sequential"), + ...auditNode(paged, applyPlaybackStage.body, "applyPlaybackStage:range", completeBranches(paged, applyPlaybackStage)), + ...auditNode(paged, applyVariantStage.body, "applyVariantStage:range", completeBranches(paged, applyVariantStage)), + ...(publishVariantTarget ? auditNode(paged, publishVariantTarget.body, "publishVariantTarget") : []), + ...(installActiveStage ? auditNode(paged, installActiveStage.body, "installActiveStage") : []), + ...(applyStageRange ? auditNode(polycss, applyStageRange, "applyStage:range") : []), + ...(publishStageShapeVisibility ? auditNode(polycss, publishStageShapeVisibility.body, "publishStageShapeVisibility") : []), + ...(publishSurfaceTarget ? auditNode(polycss, publishSurfaceTarget.body, "publishSurfaceTarget") : []), + ...(publishSurfaceRangeWithForced ? auditNode(polycss, publishSurfaceRangeWithForced.body, "publishSurfaceRangeWithForced") : []), + ...auditNode(polycss, applySurface.body, "applySurface"), + ...auditNode(polycss, stageProfileVisibility.body, "stageProfileVisibility"), + ...(recoverSurface ? auditNode(polycss, recoverSurface.body, "recoverSurface") : []), + ...(recoverPendingTransforms ? auditNode(polycss, recoverPendingTransforms.body, "recoverPendingTransforms") : []), + ...(publishProfileVisibility ? auditNode(polycss, publishProfileVisibility.body, "publishProfileVisibility") : []), + ...(publishRecoveredShapeVisibility ? auditNode(polycss, publishRecoveredShapeVisibility.body, "publishRecoveredShapeVisibility") : []), + ...auditNode(statePages, validatePagedPlaybackBoundaryFromCanonical.body, "validatePagedPlaybackBoundaryFromCanonical"), + ]; + const pagedDispatchBeforeInlineMaterialization = pagedDispatchGuard(polycss); + return Object.freeze({ + schema: "domformat-sequential-paged-source-guard@1", + method: "typescript-ast-bounded-forbidden-form-guard", + measuredHeapAllocations: false, + files: Object.freeze([pagedFile, polycssFile, statePagesFile]), + scopes: Object.freeze([...FORBIDDEN_SCOPE_NAMES, "validatePagedPlaybackBoundaryFromCanonical"]), + forbiddenOperations: Object.freeze(["slice-copy", "array-from-copy", "array-constructor", "array-literal", "spread-array-clone", "set-map-constructor", "sort-call", "nested-closure"]), + pagedDispatchBeforeInlineMaterialization, + pageBoundaryValidationCalled, + missingScopes: Object.freeze(missingScopes), + violations: Object.freeze(violations), + pass: pagedDispatchBeforeInlineMaterialization && missingScopes.length === 0 && violations.length === 0, + limitation: "This bounded source guard rejects selected source forms only in the named guarded scopes. It does not traverse the call graph and is not a general JavaScript heap-allocation measurement.", + }); +} diff --git a/packages/domformat/scripts/publication-diagnostics-viewer.js b/packages/domformat/scripts/publication-diagnostics-viewer.js new file mode 100644 index 00000000..c6b5fb44 --- /dev/null +++ b/packages/domformat/scripts/publication-diagnostics-viewer.js @@ -0,0 +1,73 @@ +import { readDomBrowserUrl } from "/dist/browser.js"; +import { createPolycssPublicationDiagnostics } from "/dist/internal-conformance.js"; +import { mountConformanceDom } from "/conformance/viewer/mount.js"; + +const host = document.querySelector("#viewer"); +const status = document.querySelector("#status"); +const parameters = new URLSearchParams(location.search); +const modelUrl = parameters.get("model"); +let runtime = null; + +async function loadStatePage(record, signal) { + const packageUrl = new URL(modelUrl, location.href); + const resourceUrl = new URL(record.path, packageUrl); + if (resourceUrl.origin !== packageUrl.origin || resourceUrl.username || resourceUrl.password) throw new Error(`State page ${record.id} escapes the package origin.`); + const response = await fetch(resourceUrl, { cache: "no-store", credentials: "omit", redirect: "error", signal }); + if (!response.ok || !response.body) throw new Error(`State page ${record.id} request failed.`); + const declared = response.headers.get("content-length"); + if (declared !== null && (!/^\d+$/u.test(declared) || Number(declared) !== record.byteLength)) throw new Error(`State page ${record.id} has the wrong Content-Length.`); + const reader = response.body.getReader(); + const chunks = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > record.byteLength) throw new Error(`State page ${record.id} exceeds its declared length.`); + chunks.push(value); + } + } finally { + try { reader.releaseLock(); } catch {} + } + if (length !== record.byteLength) throw new Error(`State page ${record.id} has the wrong length.`); + const output = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +try { + if (!modelUrl) throw new Error("Missing required ?model=/path/to/model.json URL."); + const result = await readDomBrowserUrl(modelUrl); + const diagnostics = createPolycssPublicationDiagnostics(); + runtime = await mountConformanceDom(result, host, { + animate: true, + mode: "animation", + viewportWidth: innerWidth, + viewportHeight: innerHeight, + loadStatePage, + diagnostics, + }); + document.documentElement.dataset.domformatReady = ""; + globalThis.domformatDiagnosticProof = Object.freeze({ + diagnostics, + implementation: "repo-internal-conformance", + get sourceFrame() { return runtime.sourceFrame; }, + destroy() { + runtime.destroy(); + document.documentElement.removeAttribute("data-domformat-ready"); + document.documentElement.dataset.domformatDestroyed = ""; + }, + }); + addEventListener("pagehide", () => runtime.destroy(), { once: true }); + status.textContent = `${result.document.meta.format} · internal publication diagnostics`; +} catch (error) { + runtime?.destroy(); + document.documentElement.dataset.domformatError = ""; + status.textContent = error instanceof Error ? error.message : String(error); + console.error(error); +} diff --git a/packages/domformat/scripts/publication-trace-policy.js b/packages/domformat/scripts/publication-trace-policy.js new file mode 100644 index 00000000..3fda45f4 --- /dev/null +++ b/packages/domformat/scripts/publication-trace-policy.js @@ -0,0 +1,36 @@ +import { invariant } from "../src/errors.js"; + +export const PUBLICATION_PAGE_PREPARATION_MAX_TASK_MS = 50; +export const PUBLICATION_MAIN_TASK_EVENT = "ThreadControllerImpl::RunTask"; +export const PUBLICATION_PAGE_PREPARATION_ATTRIBUTION = `${PUBLICATION_MAIN_TASK_EVENT} containing FireIdleCallback`; +export const PUBLICATION_TRACE_START_CONFIG = Object.freeze({ + transferMode: "ReportEvents", + traceConfig: Object.freeze({ + recordMode: "recordAsMuchAsPossible", + includedCategories: Object.freeze([ + "blink.user_timing", + "devtools.timeline", + "toplevel", + ]), + }), +}); + +export function assertPublicationTraceComplete(completion) { + invariant(completion?.dataLossOccurred === false, "PUBLICATION_TRACE_DATA_LOSS", "Chrome reported data loss in the publication trace."); +} + +export function assertPublicationPagePreparationGate(trace) { + const preparation = trace?.pagePreparation; + invariant( + preparation?.attribution === PUBLICATION_PAGE_PREPARATION_ATTRIBUTION + && preparation.idleCallbackCount > 0 + && preparation.taskCount > 0, + "PAGE_PREPARATION_ATTRIBUTION_MISSING", + "Publication trace contains no attributable page-preparation task; the 50 ms gate cannot pass vacuously.", + ); + invariant( + preparation.maxTaskMs <= PUBLICATION_PAGE_PREPARATION_MAX_TASK_MS, + "PAGE_PREPARATION_LONG_TASK", + `Publication trace page preparation reached ${preparation.maxTaskMs} ms, above ${PUBLICATION_PAGE_PREPARATION_MAX_TASK_MS} ms.`, + ); +} diff --git a/packages/domformat/scripts/publication-trace-window.js b/packages/domformat/scripts/publication-trace-window.js new file mode 100644 index 00000000..7b7841e2 --- /dev/null +++ b/packages/domformat/scripts/publication-trace-window.js @@ -0,0 +1,20 @@ +import { invariant } from "../src/errors.js"; + +export function publicationFrameAdvances(startFrame, endFrame, frameCount) { + invariant(startFrame >= 1 && startFrame <= frameCount && endFrame >= 1 && endFrame <= frameCount, "PUBLICATION_DIAGNOSTIC_WINDOW", "Publication diagnostic window contains an invalid frame."); + return endFrame >= startFrame ? endFrame - startFrame : frameCount - startFrame + endFrame; +} + +export function publicationPageBoundariesCrossed(startFrame, endFrame, frameCount, framesPerPage) { + invariant(frameCount % framesPerPage === 0, "PUBLICATION_DIAGNOSTIC_WINDOW", "Publication diagnostic pages do not divide the frame cycle."); + const advances = publicationFrameAdvances(startFrame, endFrame, frameCount); + if (advances === 0) return 0; + const startPage = Math.floor((startFrame - 1) / framesPerPage); + const endPage = Math.floor((endFrame - 1) / framesPerPage); + return endFrame >= startFrame ? endPage - startPage : frameCount / framesPerPage - startPage + endPage; +} + +export function assertSingleCycleTraceDuration(durationMs, frameCount, tickRateHz) { + invariant(Number.isFinite(durationMs) && durationMs >= 40_000, "PUBLICATION_TRACE_DURATION", "The publication trace must run for at least 40 seconds."); + invariant(durationMs <= frameCount / tickRateHz * 1_000 - 3_000, "PUBLICATION_TRACE_DURATION", "The publication trace must retain three seconds of headroom before one prepared playback cycle so frame-window evidence is unambiguous."); +} diff --git a/packages/domformat/src/internal-conformance.ts b/packages/domformat/src/internal-conformance.ts index b25324bb..5c3da63e 100644 --- a/packages/domformat/src/internal-conformance.ts +++ b/packages/domformat/src/internal-conformance.ts @@ -6,6 +6,6 @@ export { createPolycssCompositorTiming } from "./state/compositor-timing.js"; export { createPolycssEffects } from "./state/effects.js"; export { createPolycssInteraction } from "./state/interaction.js"; export { createPolycssOrbitInput } from "./state/orbit.js"; -export { createPolycssPagedState } from "./state/paged-state.js"; +export { createPolycssPagedState, createPolycssPublicationDiagnostics } from "./state/paged-state.js"; export { createPolycssPlayback, materializePolycssState } from "./state/polycss.js"; export { createStaticPresentation } from "./state/presentation.js"; diff --git a/packages/domformat/src/state-pages.ts b/packages/domformat/src/state-pages.ts index 80ba42b1..ede1a78a 100644 --- a/packages/domformat/src/state-pages.ts +++ b/packages/domformat/src/state-pages.ts @@ -464,27 +464,39 @@ function playbackRow(page: DecodedPagedPlaybackPage, localFrame: number): Canoni } export function validatePagedPlaybackBoundaryFromCanonical(from: CanonicalPlaybackRow, target: DecodedPagedPlaybackPage): void { - const to = playbackRow(target, 0); - invariant(target.appearances[0] === to.appearance, "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary appearance disagrees with its keyframe.`); - const expectedModel = from.modelTransform === to.modelTransform ? 0xffffffff : target.keyframe.modelTransform; + invariant(target.appearances[0] === target.keyframe.appearance, "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary appearance disagrees with its keyframe.`); + const keyframeModelTransform = target.transforms[target.keyframe.modelTransform]; + const expectedModel = from.modelTransform === keyframeModelTransform ? 0xffffffff : target.keyframe.modelTransform; invariant(target.modelTransforms[0] === expectedModel, "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary model delta is incomplete or excessive.`); - const expectedShapes: number[] = []; - for (let index = 0; index < to.shapeTransforms.length; index += 1) { - if (from.shapeTransforms[index] !== to.shapeTransforms[index] || from.shapeVisibility[index] !== to.shapeVisibility[index]) expectedShapes.push(index); + const shapeStart = target.shapeOffsets[0]; + const shapeEnd = target.shapeOffsets[1]; + let shapeCursor = shapeStart; + for (let shape = 0; shape < target.keyframe.shapeTransforms.length; shape += 1) { + const keyframeTransform = target.transforms[target.keyframe.shapeTransforms[shape]]; + const changed = from.shapeTransforms[shape] !== keyframeTransform || from.shapeVisibility[shape] !== target.keyframe.shapeVisibility[shape]; + const declared = shapeCursor < shapeEnd && target.shapeTargets[shapeCursor] === shape; + invariant(declared === changed, "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary shape targets are incomplete or excessive.`); + if (declared) shapeCursor += 1; } - const actualShapes = [...target.shapeTargets.subarray(target.shapeOffsets[0], target.shapeOffsets[1])]; - invariant(actualShapes.length === expectedShapes.length && actualShapes.every((value, index) => value === expectedShapes[index]), "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary shape targets are incomplete or excessive.`); - for (let cursor = target.shapeOffsets[0]; cursor < target.shapeOffsets[1]; cursor += 1) { + invariant(shapeCursor === shapeEnd, "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary shape targets are incomplete or excessive.`); + for (let cursor = shapeStart; cursor < shapeEnd; cursor += 1) { const shape = target.shapeTargets[cursor]; - invariant(target.transforms[target.shapeTransforms[cursor]] === to.shapeTransforms[shape] && target.shapeVisibility[cursor] === to.shapeVisibility[shape], "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary shape ${shape} disagrees with its keyframe.`); + invariant(target.transforms[target.shapeTransforms[cursor]] === target.transforms[target.keyframe.shapeTransforms[shape]] && target.shapeVisibility[cursor] === target.keyframe.shapeVisibility[shape], "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary shape ${shape} disagrees with its keyframe.`); } - const expectedLeaves: number[] = []; - for (let index = 0; index < to.leafTransforms.length; index += 1) if (from.leafTransforms[index] !== to.leafTransforms[index]) expectedLeaves.push(index); - const actualLeaves = [...target.leafTargets.subarray(target.leafOffsets[0], target.leafOffsets[1])]; - invariant(actualLeaves.length === expectedLeaves.length && actualLeaves.every((value, index) => value === expectedLeaves[index]), "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary leaf targets are incomplete or excessive.`); - for (let cursor = target.leafOffsets[0]; cursor < target.leafOffsets[1]; cursor += 1) { + const leafStart = target.leafOffsets[0]; + const leafEnd = target.leafOffsets[1]; + let leafCursor = leafStart; + for (let leaf = 0; leaf < target.keyframe.leafTransforms.length; leaf += 1) { + const keyframeTransform = target.transforms[target.keyframe.leafTransforms[leaf]]; + const changed = from.leafTransforms[leaf] !== keyframeTransform; + const declared = leafCursor < leafEnd && target.leafTargets[leafCursor] === leaf; + invariant(declared === changed, "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary leaf targets are incomplete or excessive.`); + if (declared) leafCursor += 1; + } + invariant(leafCursor === leafEnd, "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary leaf targets are incomplete or excessive.`); + for (let cursor = leafStart; cursor < leafEnd; cursor += 1) { const leaf = target.leafTargets[cursor]; - invariant(target.transforms[target.leafTransforms[cursor]] === to.leafTransforms[leaf], "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary leaf ${leaf} disagrees with its keyframe.`); + invariant(target.transforms[target.leafTransforms[cursor]] === target.transforms[target.keyframe.leafTransforms[leaf]], "STATE_PAGE_BOUNDARY_MISMATCH", `State page ${target.channel}/${target.startFrame} boundary leaf ${leaf} disagrees with its keyframe.`); } } diff --git a/packages/domformat/src/state/paged-state.ts b/packages/domformat/src/state/paged-state.ts index 819e756a..f336da37 100644 --- a/packages/domformat/src/state/paged-state.ts +++ b/packages/domformat/src/state/paged-state.ts @@ -26,9 +26,67 @@ interface PagedVariantPacket { type PagePacket = Pick; type StatePageBytesLoader = (record: DomResourceRecord, signal?: AbortSignal) => Promise; -export interface PagedPlaybackStage { +export interface PolycssPublicationDiagnostics { + playbackCanonicalReconstructions: number; + playbackCanonicalShapeVisits: number; + playbackCanonicalLeafVisits: number; + playbackBoundaryShapeVisits: number; + playbackBoundaryLeafVisits: number; + playbackPublicationShapeVisits: number; + playbackPublicationLeafVisits: number; + variantCanonicalReconstructions: number; + variantLogicalTargetVisits: number; + variantComparisonTargetVisits: number; + variantDomWrites: number; + surfaceFullReconstructions: number; + surfaceLightingTargetVisits: number; + surfaceVisibilityTargetVisits: number; +} + +export function createPolycssPublicationDiagnostics(): PolycssPublicationDiagnostics { + return { + playbackCanonicalReconstructions: 0, + playbackCanonicalShapeVisits: 0, + playbackCanonicalLeafVisits: 0, + playbackBoundaryShapeVisits: 0, + playbackBoundaryLeafVisits: 0, + playbackPublicationShapeVisits: 0, + playbackPublicationLeafVisits: 0, + variantCanonicalReconstructions: 0, + variantLogicalTargetVisits: 0, + variantComparisonTargetVisits: 0, + variantDomWrites: 0, + surfaceFullReconstructions: 0, + surfaceLightingTargetVisits: 0, + surfaceVisibilityTargetVisits: 0, + }; +} + +interface PagedPlaybackRangeStage { + readonly frame: number; + readonly kind: "range"; + readonly appearance: number; + readonly modelTransform?: string; + readonly page: DecodedPagedPlaybackPage; + readonly shapeStart: number; + readonly shapeEnd: number; + readonly leafStart: number; + readonly leafEnd: number; +} + +interface PagedPlaybackCompleteStage { readonly frame: number; - readonly complete: boolean; + readonly kind: "complete"; + readonly appearance: number; + readonly modelTransform: string; + readonly shapeTransforms: string[]; + readonly shapeVisibility: Uint8Array; + readonly leafTransforms: string[]; +} + +export interface PagedPlaybackMaterializedStage { + readonly frame: number; + readonly kind: "materialized"; readonly appearance: number; readonly modelTransform?: string; readonly shapeTargets: Uint32Array; @@ -38,13 +96,24 @@ export interface PagedPlaybackStage { readonly leafTransforms: readonly string[]; } -export interface PagedVariantStage { +export type PagedPlaybackStage = PagedPlaybackRangeStage | PagedPlaybackCompleteStage | PagedPlaybackMaterializedStage; + +interface PagedVariantRangeStage { + readonly frame: number; + readonly kind: "range"; + readonly page: DecodedPagedVariantPage; + readonly start: number; + readonly end: number; +} + +interface PagedVariantCompleteStage { readonly frame: number; - readonly complete: boolean; - readonly targets: Uint16Array; - readonly classes: Uint16Array; + readonly kind: "complete"; + readonly row: Uint16Array; } +export type PagedVariantStage = PagedVariantRangeStage | PagedVariantCompleteStage; + export interface PagedStateStage { readonly frame: number; readonly playback: PagedPlaybackStage | null; @@ -52,12 +121,12 @@ export interface PagedStateStage { } export interface PagedPlaybackCanonical { - readonly frame: number; - readonly appearance: number; - readonly modelTransform: string; - readonly shapeTransforms: readonly string[]; - readonly shapeVisibility: Uint8Array; - readonly leafTransforms: readonly string[]; + frame: number; + appearance: number; + modelTransform: string; + shapeTransforms: string[]; + shapeVisibility: Uint8Array; + leafTransforms: string[]; } export interface PolycssPagedState { @@ -70,7 +139,7 @@ export interface PolycssPagedState { readonly peakDocumentStateBytes: number; readonly frame: number; readonly activeFramePin: number; - readonly initialPlayback: PagedPlaybackCanonical | null; + readonly canonicalPlayback: PagedPlaybackCanonical | null; prepareInitial(signal?: AbortSignal): Promise; ensureFrame(frame: number, signal?: AbortSignal): Promise; isFrameReady(frame: number): boolean; @@ -109,15 +178,32 @@ function contract(document: DomDocument, codec: string, interpreter: string): return Object.freeze({ state, binding, packet: (state.data as unknown as { readonly packet: T }).packet }); } -function transformLiveBytes(playback: PagedPlaybackCanonical | null, variants: Uint16Array | null, publishedVariants: Uint16Array | null): number { - if (!playback) return (variants?.byteLength ?? 0) + (publishedVariants?.byteLength ?? 0); - let total = 8 + playback.modelTransform.length * 2 + playback.shapeVisibility.byteLength + (variants?.byteLength ?? 0) + (publishedVariants?.byteLength ?? 0); - for (const transform of playback.shapeTransforms) total += 8 + transform.length * 2; - for (const transform of playback.leafTransforms) total += 8 + transform.length * 2; +function transformBytes(transform: string): number { + const value = 8 + transform.length * 2; + invariant(Number.isSafeInteger(value), "STATE_PAGE_RESIDENCY_LIMIT", "Paged playback transform byte accounting overflowed."); + return value; +} + +function addBytes(left: number, right: number, label: string): number { + const value = left + right; + invariant(Number.isSafeInteger(value) && value >= 0, "STATE_PAGE_RESIDENCY_LIMIT", `${label} byte accounting overflowed.`); + return value; +} + +function replaceTransformBytes(total: number, previous: string, next: string): number { + const retained = total - transformBytes(previous); + invariant(Number.isSafeInteger(retained) && retained >= 0, "STATE_PAGE_RESIDENCY_LIMIT", "Paged playback live-row byte accounting underflowed."); + return addBytes(retained, transformBytes(next), "Paged playback live-row"); +} + +function playbackLiveBytes(playback: PagedPlaybackCanonical): number { + let total = addBytes(transformBytes(playback.modelTransform), playback.shapeVisibility.byteLength, "Paged playback live-row"); + for (const transform of playback.shapeTransforms) total = addBytes(total, transformBytes(transform), "Paged playback live-row"); + for (const transform of playback.leafTransforms) total = addBytes(total, transformBytes(transform), "Paged playback live-row"); return total; } -function playbackCanonical(page: DecodedPagedPlaybackPage, frame: number): PagedPlaybackCanonical { +function playbackCanonical(page: DecodedPagedPlaybackPage, frame: number, diagnostics?: PolycssPublicationDiagnostics): PagedPlaybackCanonical { const localTarget = frame - page.startFrame; let appearance = page.keyframe.appearance; let modelTransform = page.transforms[page.keyframe.modelTransform]; @@ -137,12 +223,17 @@ function playbackCanonical(page: DecodedPagedPlaybackPage, frame: number): Paged leafTransforms[target] = page.transforms[page.leafTransforms[cursor]]; } } - return Object.freeze({ frame, appearance, modelTransform, shapeTransforms: Object.freeze(shapeTransforms), shapeVisibility, leafTransforms: Object.freeze(leafTransforms) }); + if (diagnostics) { + diagnostics.playbackCanonicalReconstructions += 1; + diagnostics.playbackCanonicalShapeVisits += shapeTransforms.length; + diagnostics.playbackCanonicalLeafVisits += leafTransforms.length; + } + return { frame, appearance, modelTransform, shapeTransforms, shapeVisibility, leafTransforms }; } -function playbackFullStage(page: DecodedPagedPlaybackPage, frame: number): PagedPlaybackStage { - const row = playbackCanonical(page, frame); - return Object.freeze({ frame, complete: true, appearance: row.appearance, modelTransform: row.modelTransform, shapeTargets: Uint32Array.from({ length: row.shapeTransforms.length }, (_, index) => index), shapeTransforms: row.shapeTransforms, shapeVisibility: row.shapeVisibility, leafTargets: Uint32Array.from({ length: row.leafTransforms.length }, (_, index) => index), leafTransforms: row.leafTransforms }); +function playbackFullStage(page: DecodedPagedPlaybackPage, frame: number, diagnostics?: PolycssPublicationDiagnostics): PagedPlaybackStage { + const row = playbackCanonical(page, frame, diagnostics); + return Object.freeze({ frame, kind: "complete", appearance: row.appearance, modelTransform: row.modelTransform, shapeTransforms: row.shapeTransforms, shapeVisibility: row.shapeVisibility, leafTransforms: row.leafTransforms }); } function playbackSparseStage(page: DecodedPagedPlaybackPage, frame: number, local: number): PagedPlaybackStage { @@ -153,20 +244,21 @@ function playbackSparseStage(page: DecodedPagedPlaybackPage, frame: number, loca const leafEnd = page.leafOffsets[local + 1]; return Object.freeze({ frame, - complete: false, + kind: "range", appearance: page.appearances[local], ...(model === 0xffffffff ? {} : { modelTransform: page.transforms[model] }), - shapeTargets: page.shapeTargets.slice(shapeStart, shapeEnd), - shapeTransforms: Object.freeze(Array.from(page.shapeTransforms.subarray(shapeStart, shapeEnd), (index) => page.transforms[index])), - shapeVisibility: page.shapeVisibility.slice(shapeStart, shapeEnd), - leafTargets: page.leafTargets.slice(leafStart, leafEnd), - leafTransforms: Object.freeze(Array.from(page.leafTransforms.subarray(leafStart, leafEnd), (index) => page.transforms[index])), + page, + shapeStart, + shapeEnd, + leafStart, + leafEnd, }); } -function variantRow(page: DecodedPagedVariantPage, frame: number): Uint16Array { +function variantRow(page: DecodedPagedVariantPage, frame: number, diagnostics?: PolycssPublicationDiagnostics): Uint16Array { const row = page.keyframe.slice(); for (let local = 1; local <= frame - page.startFrame; local += 1) for (let cursor = page.offsets[local]; cursor < page.offsets[local + 1]; cursor += 1) row[page.targets[cursor]] = page.classes[cursor]; + if (diagnostics) diagnostics.variantCanonicalReconstructions += 1; return row; } @@ -175,7 +267,7 @@ export function createPolycssPagedState( mounted: MountedTree, limits: DomLimits, load: StatePageBytesLoader, - options: { readonly boundTargets?: ReadonlyMap>; readonly onLateFailure?: (error: unknown) => void } = {}, + options: { readonly boundTargets?: ReadonlyMap>; readonly onLateFailure?: (error: unknown) => void; readonly diagnostics?: PolycssPublicationDiagnostics } = {}, ): PolycssPagedState | null { const playback = contract(document, "polycss-paged-playback@0", "polycss-paged-playback@0"); const variants = contract(document, "polycss-paged-variants@0", "polycss-paged-variants@0"); @@ -209,8 +301,15 @@ export function createPolycssPagedState( }; let currentVariants = variants ? uint16(variants.packet.initial.classIndicesBase64) : null; let publishedVariants = currentVariants?.slice() ?? null; + let variantsSynchronized = true; let variantFrame = variants?.packet.initial.frame ?? playbackInitial; let currentPlayback: PagedPlaybackCanonical | null = null; + let currentPlaybackBytes = 0; + const variantLiveBytes = addBytes(currentVariants?.byteLength ?? 0, publishedVariants?.byteLength ?? 0, "Paged variant retained-row"); + let activeStage: PagedStateStage | null = null; + let activePlaybackStageResource: string | null = null; + let activeVariantStageResource: string | null = null; + let activeStageBytes = 0; let resident = new Map(); let controller: AbortController | null = null; let generation = 0; @@ -220,18 +319,36 @@ export function createPolycssPagedState( let peakMaterializedBytes = 0; let peakDocumentStateBytes = 0; - const liveBytes = () => transformLiveBytes(currentPlayback, currentVariants, publishedVariants); - const residentMaterialized = () => [...resident.values()].reduce((total, page) => total + page.materializedByteLength, 0); - const measure = (validationBytes = 0, transientMaterialized = 0, incomingPage = false, incomingMaterialized = 0): void => { + const liveBytes = () => addBytes(currentPlaybackBytes, variantLiveBytes, "Paged state live-row"); + const residentMaterialized = (excluded?: ReadonlySet): number => { + let total = 0; + for (const [resource, page] of resident) if (!excluded?.has(resource)) total = addBytes(total, page.materializedByteLength, "Paged state residency"); + return total; + }; + const measure = ({ + validationBytes = 0, + transientMaterialized = 0, + incomingMaterialized = 0, + residentBytes = residentMaterialized(), + residentPages = resident.size, + retainedLiveBytes = liveBytes(), + }: { + readonly validationBytes?: number; + readonly transientMaterialized?: number; + readonly incomingMaterialized?: number; + readonly residentBytes?: number; + readonly residentPages?: number; + readonly retainedLiveBytes?: number; + } = {}): void => { const decoded = validationBytes; - const residentBytes = residentMaterialized(); - const materialized = residentBytes + transientMaterialized + incomingMaterialized; - const total = decoded + residentBytes + transientMaterialized + liveBytes(); - peakResidentPages = Math.max(peakResidentPages, resident.size + (incomingPage ? 1 : 0)); + const materialized = addBytes(addBytes(addBytes(residentBytes, transientMaterialized, "Paged state materialization"), incomingMaterialized, "Paged state materialization"), activeStageBytes, "Paged state materialization"); + const total = addBytes(addBytes(addBytes(addBytes(decoded, residentBytes, "Paged state aggregate"), transientMaterialized, "Paged state aggregate"), activeStageBytes, "Paged state aggregate"), retainedLiveBytes, "Paged state aggregate"); + invariant(Number.isSafeInteger(residentPages) && residentPages >= 0, "STATE_PAGE_RESIDENCY_LIMIT", "Paged state resident-page accounting overflowed."); + invariant(total <= limits.maxAggregateDecodedBytes, "STATE_PAGE_RESIDENCY_LIMIT", "Paged state validation, materialization, residency, and live rows exceed the document-wide byte ceiling."); + peakResidentPages = Math.max(peakResidentPages, residentPages); peakDecodedBytes = Math.max(peakDecodedBytes, decoded); peakMaterializedBytes = Math.max(peakMaterializedBytes, materialized); peakDocumentStateBytes = Math.max(peakDocumentStateBytes, total); - invariant(total <= limits.maxAggregateDecodedBytes, "STATE_PAGE_RESIDENCY_LIMIT", "Paged state validation, materialization, residency, and live rows exceed the document-wide byte ceiling."); }; const desiredResources = (frame: number, includeLookahead = true): string[] => { const resources = new Set(); @@ -244,6 +361,8 @@ export function createPolycssPagedState( for (const pin of new Set([...fixedPins, activeFramePin])) for (const packet of packets) resources.add(pageAt(packet, pin).resource); if (playback && currentPlayback) resources.add(pageAt(playback.packet, currentPlayback.frame).resource); if (variants) resources.add(pageAt(variants.packet, variantFrame).resource); + if (activePlaybackStageResource) resources.add(activePlaybackStageResource); + if (activeVariantStageResource) resources.add(activeVariantStageResource); if (includeLookahead) for (let offset = 1; offset <= Math.max(...packets.map((packet) => packet.lookaheadPages)); offset += 1) { for (let index = 0; index < packets.length; index += 1) { const packet = packets[index]; @@ -257,16 +376,24 @@ export function createPolycssPagedState( const loadPage = async (resource: string, protectedResources: ReadonlySet, signal?: AbortSignal): Promise => { const cached = resident.get(resource); if (cached) { touch(resource, cached); return cached; } - while (resident.size >= maxResidentPages) { - const candidate = [...resident.keys()].find((entry) => !protectedResources.has(entry)); - invariant(candidate, "STATE_PAGE_RESIDENCY_LIMIT", "Paged state cannot reserve capacity without evicting a protected page."); - resident.delete(candidate); + const evictions: string[] = []; + for (const candidate of resident.keys()) { + if (resident.size - evictions.length < maxResidentPages) break; + if (!protectedResources.has(candidate)) evictions.push(candidate); } + invariant(resident.size - evictions.length < maxResidentPages, "STATE_PAGE_RESIDENCY_LIMIT", "Paged state cannot reserve capacity without evicting a protected page."); const owner = descriptors.get(resource); const record = records.get(resource); invariant(owner && record?.kind === "state-page" && record.decodedByteLength !== undefined, "MISSING_EXTERNAL_RESOURCE", `State page ${resource} is undeclared.`); const validationBytes = statePageValidationWorkspaceBytes(record.decodedByteLength, owner.descriptor.materializedByteLength); - measure(validationBytes, 0, true, owner.descriptor.materializedByteLength); + const excluded = new Set(evictions); + measure({ + validationBytes, + incomingMaterialized: owner.descriptor.materializedByteLength, + residentBytes: residentMaterialized(excluded), + residentPages: resident.size - evictions.length + 1, + }); + for (const candidate of evictions) resident.delete(candidate); let bytes: Uint8Array; try { bytes = await load(record, signal); @@ -280,8 +407,6 @@ export function createPolycssPagedState( : validatePagedVariantPageBytes(bytes, { ...(owner.descriptor as DomPagedVariantPageDescriptor), channel: owner.channel }, variantNodes.length, variants!.packet.classes.length, limits); invariant(!destroyed && !signal?.aborted, "OPERATION_ABORTED", `State page ${resource} request was aborted.`); touch(resource, page); - measure(); - invariant(resident.size <= maxResidentPages, "STATE_PAGE_RESIDENCY_LIMIT", "Paged state residency exceeded its document-wide page ceiling."); return page; }; const loadWindow = async (frame: number, signal?: AbortSignal, includeLookahead = true): Promise => { @@ -314,10 +439,14 @@ export function createPolycssPagedState( const local = frame - page.startFrame; if (local === 0) { validatePagedPlaybackBoundaryFromCanonical(currentPlayback, page); + if (options.diagnostics) { + options.diagnostics.playbackBoundaryShapeVisits += page.keyframe.shapeTransforms.length + page.shapeOffsets[1] - page.shapeOffsets[0]; + options.diagnostics.playbackBoundaryLeafVisits += page.keyframe.leafTransforms.length + page.leafOffsets[1] - page.leafOffsets[0]; + } } return playbackSparseStage(page, frame, local); } - return playbackFullStage(page, frame); + return playbackFullStage(page, frame, options.diagnostics); }; const stageVariants = (frame: number): PagedVariantStage | null => { if (!variants) return null; @@ -325,53 +454,143 @@ export function createPolycssPagedState( const expected = variantFrame === variants.packet.frameCount ? 1 : variantFrame + 1; if (frame === expected && frame > page.startFrame) { const local = frame - page.startFrame; - return Object.freeze({ frame, complete: false, targets: page.targets.slice(page.offsets[local], page.offsets[local + 1]), classes: page.classes.slice(page.offsets[local], page.offsets[local + 1]) }); + return Object.freeze({ frame, kind: "range", page, start: page.offsets[local], end: page.offsets[local + 1] }); } - const row = variantRow(page, frame); - return Object.freeze({ frame, complete: true, targets: Uint16Array.from({ length: row.length }, (_, index) => index), classes: row }); + return Object.freeze({ frame, kind: "complete", row: variantRow(page, frame, options.diagnostics) }); + }; + const playbackStageWorkspace = (frame: number, includePlayback: boolean): number => { + if (!playback || !includePlayback) return 0; + const page = playbackPage(frame); + const expected = currentPlayback ? (currentPlayback.frame === playback.packet.pages.at(-1)!.endFrame ? 1 : currentPlayback.frame + 1) : -1; + return currentPlayback && frame === expected + ? 0 + : pagedPlaybackPublicationWorkspaceBytes(page.materializedByteLength, playback.packet.shapeCount, playback.packet.leafCount); + }; + const variantStageWorkspace = (frame: number): number => { + if (!variants) return 0; + const page = variantPage(frame); + const expected = variantFrame === variants.packet.frameCount ? 1 : variantFrame + 1; + return frame === expected && frame > page.startFrame ? 0 : pagedVariantPublicationWorkspaceBytes(variantNodes.length); + }; + const publishVariantTarget = (index: number, value: number): void => { + invariant(publishedVariants && variants, "INVALID_VARIANT_PUBLICATION", "Paged variant row has no owner."); + const previous = publishedVariants[index]; + if (previous === value) return; + if (previous !== 0xffff) { variantNodes[index].classList.remove(variants.packet.classes[previous]); if (options.diagnostics) options.diagnostics.variantDomWrites += 1; } + if (value !== 0xffff) { variantNodes[index].classList.add(variants.packet.classes[value]); if (options.diagnostics) options.diagnostics.variantDomWrites += 1; } + publishedVariants[index] = value; }; const publishVariantRow = (): void => { invariant(currentVariants && publishedVariants && variants, "INVALID_VARIANT_PUBLICATION", "Paged variant row has no owner."); + if (options.diagnostics) options.diagnostics.variantComparisonTargetVisits += currentVariants.length; for (let index = 0; index < currentVariants.length; index += 1) { const value = currentVariants[index]; - const previous = publishedVariants[index]; - if (previous === value) continue; - if (previous !== 0xffff) variantNodes[index].classList.remove(variants.packet.classes[previous]); - if (value !== 0xffff) variantNodes[index].classList.add(variants.packet.classes[value]); - publishedVariants[index] = value; + publishVariantTarget(index, value); } + variantsSynchronized = true; }; const applyVariantStage = (stage: PagedVariantStage, publish: boolean): void => { invariant(currentVariants && variants, "INVALID_VARIANT_PUBLICATION", "Paged variant stage has no owner."); - for (let cursor = 0; cursor < stage.targets.length; cursor += 1) { - const index = stage.targets[cursor]; - const value = stage.classes[cursor]; - const previous = currentVariants[index]; - if (previous === value) continue; - currentVariants[index] = value; + if (stage.kind === "complete") { + if (options.diagnostics) options.diagnostics.variantLogicalTargetVisits += stage.row.length; + currentVariants.set(stage.row); + } else { + if (options.diagnostics) options.diagnostics.variantLogicalTargetVisits += stage.end - stage.start; + for (let cursor = stage.start; cursor < stage.end; cursor += 1) { + const index = stage.page.targets[cursor]; + const value = stage.page.classes[cursor]; + currentVariants[index] = value; + } } variantFrame = stage.frame; - if (publish) publishVariantRow(); + if (!publish) { variantsSynchronized = false; return; } + if (stage.kind === "complete" || !variantsSynchronized) { publishVariantRow(); return; } + if (options.diagnostics) options.diagnostics.variantComparisonTargetVisits += stage.end - stage.start; + for (let cursor = stage.start; cursor < stage.end; cursor += 1) { + const index = stage.page.targets[cursor]; + publishVariantTarget(index, currentVariants[index]); + } }; - const applyPlaybackStage = (stage: PagedPlaybackStage): void => { + const playbackBytesAfterStage = (stage: PagedPlaybackStage): number => { invariant(playback, "INVALID_PLAYBACK_PUBLICATION", "Paged playback stage has no owner."); - if (!currentPlayback || stage.complete) { - invariant(stage.modelTransform !== undefined && stage.shapeTargets.length === playback.packet.shapeCount && stage.leafTargets.length === playback.packet.leafCount, "INVALID_PLAYBACK_PUBLICATION", "Complete paged playback stage is incomplete."); - currentPlayback = Object.freeze({ frame: stage.frame, appearance: stage.appearance, modelTransform: stage.modelTransform, shapeTransforms: Object.freeze([...stage.shapeTransforms]), shapeVisibility: stage.shapeVisibility.slice(), leafTransforms: Object.freeze([...stage.leafTransforms]) }); + if (stage.kind === "complete") { + invariant(stage.shapeTransforms.length === playback.packet.shapeCount && stage.shapeVisibility.length === playback.packet.shapeCount && stage.leafTransforms.length === playback.packet.leafCount, "INVALID_PLAYBACK_PUBLICATION", "Complete paged playback stage is incomplete."); + return playbackLiveBytes(stage); + } + invariant(currentPlayback && stage.kind === "range", "INVALID_PLAYBACK_PUBLICATION", "Paged playback sparse stage is invalid."); + let total = currentPlaybackBytes; + if (stage.modelTransform !== undefined) total = replaceTransformBytes(total, currentPlayback.modelTransform, stage.modelTransform); + for (let cursor = stage.shapeStart; cursor < stage.shapeEnd; cursor += 1) { + const target = stage.page.shapeTargets[cursor]; + total = replaceTransformBytes(total, currentPlayback.shapeTransforms[target], stage.page.transforms[stage.page.shapeTransforms[cursor]]); + } + for (let cursor = stage.leafStart; cursor < stage.leafEnd; cursor += 1) { + const target = stage.page.leafTargets[cursor]; + total = replaceTransformBytes(total, currentPlayback.leafTransforms[target], stage.page.transforms[stage.page.leafTransforms[cursor]]); + } + return total; + }; + const applyPlaybackStage = (stage: PagedPlaybackStage, nextPlaybackBytes: number): void => { + invariant(playback, "INVALID_PLAYBACK_PUBLICATION", "Paged playback stage has no owner."); + if (stage.kind === "complete") { + if (!currentPlayback) { + currentPlayback = { frame: stage.frame, appearance: stage.appearance, modelTransform: stage.modelTransform, shapeTransforms: [...stage.shapeTransforms], shapeVisibility: stage.shapeVisibility.slice(), leafTransforms: [...stage.leafTransforms] }; + } else { + currentPlayback.frame = stage.frame; + currentPlayback.appearance = stage.appearance; + currentPlayback.modelTransform = stage.modelTransform; + for (let index = 0; index < stage.shapeTransforms.length; index += 1) currentPlayback.shapeTransforms[index] = stage.shapeTransforms[index]; + currentPlayback.shapeVisibility.set(stage.shapeVisibility); + for (let index = 0; index < stage.leafTransforms.length; index += 1) currentPlayback.leafTransforms[index] = stage.leafTransforms[index]; + } + currentPlaybackBytes = nextPlaybackBytes; return; } - const shapeTransforms = [...currentPlayback.shapeTransforms]; - const shapeVisibility = currentPlayback.shapeVisibility.slice(); - const leafTransforms = [...currentPlayback.leafTransforms]; - for (let cursor = 0; cursor < stage.shapeTargets.length; cursor += 1) { const target = stage.shapeTargets[cursor]; shapeTransforms[target] = stage.shapeTransforms[cursor]; shapeVisibility[target] = stage.shapeVisibility[cursor]; } - for (let cursor = 0; cursor < stage.leafTargets.length; cursor += 1) leafTransforms[stage.leafTargets[cursor]] = stage.leafTransforms[cursor]; - currentPlayback = Object.freeze({ frame: stage.frame, appearance: stage.appearance, modelTransform: stage.modelTransform ?? currentPlayback.modelTransform, shapeTransforms: Object.freeze(shapeTransforms), shapeVisibility, leafTransforms: Object.freeze(leafTransforms) }); + invariant(currentPlayback && stage.kind === "range", "INVALID_PLAYBACK_PUBLICATION", "Paged playback sparse stage is invalid."); + currentPlayback.frame = stage.frame; + currentPlayback.appearance = stage.appearance; + if (stage.modelTransform !== undefined) { + currentPlayback.modelTransform = stage.modelTransform; + } + if (options.diagnostics) { + options.diagnostics.playbackCanonicalShapeVisits += stage.shapeEnd - stage.shapeStart; + options.diagnostics.playbackCanonicalLeafVisits += stage.leafEnd - stage.leafStart; + } + for (let cursor = stage.shapeStart; cursor < stage.shapeEnd; cursor += 1) { + const target = stage.page.shapeTargets[cursor]; + const transform = stage.page.transforms[stage.page.shapeTransforms[cursor]]; + currentPlayback.shapeTransforms[target] = transform; + currentPlayback.shapeVisibility[target] = stage.page.shapeVisibility[cursor]; + } + for (let cursor = stage.leafStart; cursor < stage.leafEnd; cursor += 1) { + const target = stage.page.leafTargets[cursor]; + const transform = stage.page.transforms[stage.page.leafTransforms[cursor]]; + currentPlayback.leafTransforms[target] = transform; + } + currentPlaybackBytes = nextPlaybackBytes; + }; + const discardActiveStage = (): void => { + activeStage = null; + activePlaybackStageResource = null; + activeVariantStageResource = null; + activeStageBytes = 0; }; + const installActiveStage = (stage: PagedStateStage): PagedStateStage => { + // Range stages borrow resident typed columns, so their page pins must survive until synchronous commit or failure cleanup. + activeStage = stage; + activePlaybackStageResource = stage.playback?.kind === "range" ? pageAt(playback!.packet, stage.frame).resource : null; + activeVariantStageResource = stage.variants?.kind === "range" ? pageAt(variants!.packet, stage.frame).resource : null; + return stage; + }; + const isActiveStage = (stage: PagedStateStage): boolean => Boolean(activeStage + && stage.frame === activeStage.frame + && stage.playback === activeStage.playback + && stage.variants === activeStage.variants); return Object.freeze({ get hasPlayback() { return Boolean(playback); }, get hasVariants() { return Boolean(variants); }, get residentResources() { return Object.freeze([...resident.keys()]); }, get peakResidentPages() { return peakResidentPages; }, get peakDecodedBytes() { return peakDecodedBytes; }, get peakMaterializedBytes() { return peakMaterializedBytes; }, get peakDocumentStateBytes() { return peakDocumentStateBytes; }, - get frame() { return currentPlayback?.frame ?? variantFrame; }, get activeFramePin() { return activeFramePin; }, get initialPlayback() { return currentPlayback; }, + get frame() { return currentPlayback?.frame ?? variantFrame; }, get activeFramePin() { return activeFramePin; }, get canonicalPlayback() { return currentPlayback; }, async prepareInitial(signal?: AbortSignal) { invariant(!signal?.aborted, "OPERATION_ABORTED", "Paged state initial request was aborted."); const request = startRequest(); @@ -380,23 +599,32 @@ export function createPolycssPagedState( try { await loadWindow(playbackInitial, request.signal, false); invariant(request.id === generation && !request.signal.aborted, "OPERATION_ABORTED", "Paged state initial request was superseded."); + const initialPlaybackPage = playback ? playbackPage(playback.packet.initial.sourceFrame) : null; + const initialWorkspace = addBytes( + initialPlaybackPage && playback ? pagedPlaybackLiveRowCeiling(initialPlaybackPage.materializedByteLength, playback.packet.shapeCount, playback.packet.leafCount) : 0, + variants ? pagedVariantPublicationWorkspaceBytes(variantNodes.length) : 0, + "Paged state initial publication", + ); + measure({ transientMaterialized: initialWorkspace }); + let preparedPlayback: PagedPlaybackCanonical | null = null; if (playback) { - const initialPage = playbackPage(playback.packet.initial.sourceFrame); - measure(0, pagedPlaybackLiveRowCeiling(initialPage.materializedByteLength, playback.packet.shapeCount, playback.packet.leafCount)); - const initial = playbackCanonical(initialPage, playback.packet.initial.sourceFrame); + const initial = playbackCanonical(initialPlaybackPage!, playback.packet.initial.sourceFrame, options.diagnostics); invariant(initial.appearance === playback.packet.initial.appearance, "STATE_PAGE_INITIAL_MISMATCH", "Paged playback initial page disagrees with its shell appearance."); const base = (playback.binding.parameters as unknown as { readonly baseSceneTransform: string }).baseSceneTransform; const expectedModel = initial.modelTransform === "" ? base : `${base} ${initial.modelTransform}`; invariant(playbackModel!.style.transform === cssTransform(expectedModel), "STATE_PAGE_INITIAL_MISMATCH", "Paged playback initial model transform disagrees with TREE."); for (let index = 0; index < playbackShapes.length; index += 1) invariant(playbackShapes[index].style.transform === cssTransform(initial.shapeTransforms[index]) && playbackShapes[index].style.visibility === (initial.shapeVisibility[index] === 1 ? "visible" : "hidden"), "STATE_PAGE_INITIAL_MISMATCH", `Paged playback initial shape ${index} disagrees with TREE.`); for (let index = 0; index < playbackLeaves.length; index += 1) invariant(playbackLeaves[index].style.transform === cssTransform(initial.leafTransforms[index]), "STATE_PAGE_INITIAL_MISMATCH", `Paged playback initial leaf ${index} disagrees with TREE.`); - currentPlayback = initial; + preparedPlayback = initial; } if (variants) { - const initial = variantRow(variantPage(variants.packet.initial.frame), variants.packet.initial.frame); + const initial = variantRow(variantPage(variants.packet.initial.frame), variants.packet.initial.frame, options.diagnostics); invariant(initial.length === currentVariants!.length && initial.every((value, index) => value === currentVariants![index]), "STATE_PAGE_INITIAL_MISMATCH", "Paged variant initial page disagrees with its shell/TREE row."); } - measure(); + if (preparedPlayback) { + currentPlaybackBytes = playbackLiveBytes(preparedPlayback); + currentPlayback = preparedPlayback; + } } finally { signal?.removeEventListener("abort", abort); } }, async ensureFrame(frame: number, signal?: AbortSignal) { @@ -435,19 +663,48 @@ export function createPolycssPagedState( assertFrameReady(frame: number) { invariant(ready(frame), "STATE_PAGE_NOT_READY", `Every paged state channel must be resident before frame ${frame} publication.`); }, stage(frame: number, includePlayback = true) { invariant(ready(frame), "STATE_PAGE_NOT_READY", `Every paged state channel must be resident before frame ${frame} staging.`); - const playbackWorkspace = includePlayback && playback - ? pagedPlaybackPublicationWorkspaceBytes(playbackPage(frame).materializedByteLength, playback.packet.shapeCount, playback.packet.leafCount) - : 0; - measure(0, playbackWorkspace + pagedVariantPublicationWorkspaceBytes(variantNodes.length)); - return Object.freeze({ frame, playback: includePlayback ? stagePlayback(frame) : null, variants: stageVariants(frame) }); + discardActiveStage(); + try { + activeStageBytes = addBytes(playbackStageWorkspace(frame, includePlayback), variantStageWorkspace(frame), "Paged state publication workspace"); + measure(); + const playbackStage = includePlayback ? stagePlayback(frame) : null; + const variantStage = stageVariants(frame); + return installActiveStage(Object.freeze({ frame, playback: playbackStage, variants: variantStage })); + } catch (error) { + discardActiveStage(); + throw error; + } + }, + commit(stage: PagedStateStage, publishVariants = true) { + invariant(isActiveStage(stage), "INVALID_PLAYBACK_PUBLICATION", "Paged state commit does not own the active staged row."); + try { + const nextPlaybackBytes = stage.playback ? playbackBytesAfterStage(stage.playback) : currentPlaybackBytes; + measure({ retainedLiveBytes: addBytes(nextPlaybackBytes, variantLiveBytes, "Paged state projected live-row") }); + if (stage.playback) applyPlaybackStage(stage.playback, nextPlaybackBytes); + if (stage.variants) applyVariantStage(stage.variants, publishVariants); + else if (publishVariants && variants) publishVariantRow(); + return stage.frame; + } finally { discardActiveStage(); } + }, + publishVariants(frame: number) { + invariant(ready(frame), "STATE_PAGE_NOT_READY", `Every paged state channel must be resident before frame ${frame} variant publication.`); + discardActiveStage(); + if (variants && frame === variantFrame) { + measure(); + publishVariantRow(); + return frame; + } + const workspace = variantStageWorkspace(frame); + measure({ transientMaterialized: workspace }); + const variantStage = stageVariants(frame); + if (variantStage) applyVariantStage(variantStage, true); + return frame; }, - commit(stage: PagedStateStage, publishVariants = true) { if (stage.playback) applyPlaybackStage(stage.playback); if (stage.variants) applyVariantStage(stage.variants, publishVariants); else if (publishVariants && variants) publishVariantRow(); measure(); return stage.frame; }, - publishVariants(frame: number) { invariant(ready(frame), "STATE_PAGE_NOT_READY", `Every paged state channel must be resident before frame ${frame} variant publication.`); measure(0, pagedVariantPublicationWorkspaceBytes(variantNodes.length)); const staged = Object.freeze({ frame, playback: null, variants: stageVariants(frame) }); if (staged.variants) applyVariantStage(staged.variants, true); measure(); return frame; }, setActiveFramePin(frame: number) { invariant(Number.isSafeInteger(frame) && frame >= 1 && ready(frame), "STATE_PAGE_NOT_READY", `Prepared bank entry frame ${frame} must be resident before it can be pinned.`); activeFramePin = frame; }, preloadAfter(frame: number) { const request = startRequest(); void loadWindow(frame, request.signal).catch((error) => { if (!destroyed && request.id === generation && !request.signal.aborted) options.onLateFailure?.(error); }); }, cancelPending() { controller?.abort(); controller = null; generation += 1; }, resetPreload(frame: number) { controller?.abort(); controller = null; generation += 1; const request = startRequest(); void loadWindow(frame, request.signal).catch((error) => { if (!destroyed && request.id === generation && !request.signal.aborted) options.onLateFailure?.(error); }); }, - destroy() { if (destroyed) return false; destroyed = true; controller?.abort(); controller = null; generation += 1; resident.clear(); currentPlayback = null; currentVariants = null; publishedVariants = null; return true; }, + destroy() { if (destroyed) return false; destroyed = true; controller?.abort(); controller = null; generation += 1; discardActiveStage(); resident.clear(); currentPlayback = null; currentPlaybackBytes = 0; currentVariants = null; publishedVariants = null; return true; }, }); } diff --git a/packages/domformat/src/state/polycss.ts b/packages/domformat/src/state/polycss.ts index 6f733fa8..793f9501 100644 --- a/packages/domformat/src/state/polycss.ts +++ b/packages/domformat/src/state/polycss.ts @@ -1,8 +1,8 @@ -import { invariant } from "../errors.js"; +import { DomFormatError, invariant } from "../errors.js"; import { cssNumber } from "./numeric.js"; import type { DomBindingChannel, DomBindings, DomState, DomStateChannel } from "../public-types.js"; import type { MountedTree } from "../retained-dom.js"; -import type { PagedPlaybackStage, PagedStateStage, PolycssPagedState } from "./paged-state.js"; +import type { PagedPlaybackStage, PagedStateStage, PolycssPagedState, PolycssPublicationDiagnostics } from "./paged-state.js"; import type { PolycssCompositorTiming } from "./compositor-timing.js"; const MATRIX_WIDTH = 12; @@ -865,6 +865,7 @@ export function createPolycssPlayback( readonly pagedState?: PolycssPagedState | null; readonly assertPagedFrameReady?: (frame: number) => void; readonly compositorTiming?: PolycssCompositorTiming | null; + readonly diagnostics?: PolycssPublicationDiagnostics; } = {}, ): PolycssPlayback { const publishAppearance = options.publishAppearance; @@ -909,7 +910,11 @@ export function createPolycssPlayback( const profileResponsiveTransforms = new Array(packet.leafCount).fill(null); const profileVisible = new Uint8Array(packet.leafCount); profileVisible.fill(1); - const pagedInitial = packet.kind === "paged" ? options.pagedState?.initialPlayback : null; + const profileVisibilityDirty = new Uint16Array(packet.leafCount); + const pendingProfileVisibility = new Uint8Array(packet.leafCount); + const pendingShapeTransforms = new Uint8Array(packet.shapeCount); + const pendingLeafTransforms = new Uint8Array(packet.leafCount); + const pagedInitial = packet.kind === "paged" ? options.pagedState?.canonicalPlayback : null; if (packet.kind === "inline") { for (let offset = 0; offset < packet.initial.shapes.length; offset += 3) { shapeTransforms[packet.initial.shapes[offset]] = packet.transforms[packet.initial.shapes[offset + 1]]; @@ -928,9 +933,20 @@ export function createPolycssPlayback( const degenerate = new Uint8Array(packet.leafCount); const appliedStates = new Uint16Array(packet.leafCount); const dirtySurfaceStates = new Uint8Array(packet.leafCount); + const surfaceScratchStates = new Uint16Array(packet.leafCount); + const surfaceDirtyFlags = new Uint8Array(packet.leafCount); + const surfaceDirtyIndices = new Uint16Array(packet.leafCount); + const visibilityDirtyFlags = new Uint8Array(packet.leafCount); + const visibilityDirtyIndices = new Uint16Array(packet.leafCount); let currentForced = new Uint16Array(0); let sourceFrame = packet.initial.sourceFrame; let surfaceFrame = packet.initial.sourceFrame; + let pendingSurfaceFrame = 0; + let pendingShapeVisibilityRecovery = false; + let pendingFramePublication = false; + let pendingAppearancePublication = false; + let pendingModelPublication = false; + let pendingProfileVisibilityPublication = false; let appearanceIndex = packet.initial.appearance; let modelTransform = packet.kind === "inline" ? packet.transforms[packet.initial.modelTransform] : pagedInitial!.modelTransform; let tick = 0; @@ -956,6 +972,18 @@ export function createPolycssPlayback( if (scene.style.transform !== next) scene.style.transform = next; }; + const publishPendingAppearance = (): void => { + if (!pendingAppearancePublication) return; + applyAppearance(); + pendingAppearancePublication = false; + }; + + const publishPendingModel = (): void => { + if (!pendingModelPublication) return; + writeModel(); + pendingModelPublication = false; + }; + const isPaintVisible = (index: number): boolean => ((visible[index] === 1 && profileVisible[index] === 1) || forced[index] === 1) && degenerate[index] === 0; const preparedLeafTransform = (index: number): string => { @@ -976,27 +1004,31 @@ export function createPolycssPlayback( return result; }; - const stageProfileVisibility = (frame: number): number[] => { + const stageProfileVisibility = (frame: number): number => { const profile = selectedViewportProfile < 0 ? null : materialized.viewportProfiles!.profiles[selectedViewportProfile]; if (!profile) { profileVisibilityFrame = frame; - return []; + return 0; } - const changed: number[] = []; + let changed = 0; const offsets = profile.visibilityOffsets; const targets = profile.visibilityLeaves; if (offsets && targets && frame === (profileVisibilityFrame === playbackParameters.frameCount ? 1 : profileVisibilityFrame + 1)) { for (let cursor = offsets[frame - 1]; cursor < offsets[frame]; cursor += 1) { const leaf = targets[cursor]; profileVisible[leaf] ^= 1; - changed.push(leaf); + profileVisibilityDirty[changed++] = leaf; + pendingProfileVisibility[leaf] = 1; + pendingProfileVisibilityPublication = true; } } else { const next = profileVisibilityAt(profile, frame); for (let leaf = 0; leaf < packet.leafCount; leaf += 1) { if (profileVisible[leaf] === next[leaf]) continue; profileVisible[leaf] = next[leaf]; - changed.push(leaf); + profileVisibilityDirty[changed++] = leaf; + pendingProfileVisibility[leaf] = 1; + pendingProfileVisibilityPublication = true; } } profileVisibilityFrame = frame; @@ -1050,9 +1082,31 @@ export function createPolycssPlayback( else dirtyHiddenTransforms[index] = 1; }; + const publishPendingShapeTransform = (index: number): void => { + if (pendingShapeTransforms[index] === 0) return; + shapes[index].style.transform = shapeTransforms[index]; + pendingShapeTransforms[index] = 0; + }; + + const publishPendingLeafTransform = (index: number): void => { + if (pendingLeafTransforms[index] === 0) return; + publishOrDeferPreparedLeafTransform(index); + pendingLeafTransforms[index] = 0; + }; + + const recoverPendingTransforms = (synchronizeLeaves = false): void => { + publishPendingModel(); + publishPendingAppearance(); + for (let shape = 0; shape < packet.shapeCount; shape += 1) publishPendingShapeTransform(shape); + if (!synchronizeLeaves) for (let leaf = 0; leaf < packet.leafCount; leaf += 1) publishPendingLeafTransform(leaf); + }; + const synchronizePreparedLeafTransforms = () => { for (let index = 0; index < leaves.length; index += 1) { - if (dirtyHiddenTransforms[index] === 1 || interactionTransforms[index] === 1) writePreparedLeafTransform(index); + if (pendingLeafTransforms[index] === 1 || dirtyHiddenTransforms[index] === 1 || interactionTransforms[index] === 1) { + writePreparedLeafTransform(index); + pendingLeafTransforms[index] = 0; + } } }; @@ -1082,6 +1136,22 @@ export function createPolycssPlayback( if (leaves[index].style.visibility !== next) leaves[index].style.visibility = next; }; + const publishPendingProfileVisibilityTarget = (index: number, frame: number): void => { + if (pendingProfileVisibility[index] === 0) return; + writeVisibility(index, frame); + pendingProfileVisibility[index] = 0; + }; + + const publishProfileVisibility = (changeCount: number, recovering: boolean, frame: number): void => { + if (!pendingProfileVisibilityPublication) return; + if (recovering) { + for (let leaf = 0; leaf < packet.leafCount; leaf += 1) publishPendingProfileVisibilityTarget(leaf, frame); + } else { + for (let cursor = 0; cursor < changeCount; cursor += 1) publishPendingProfileVisibilityTarget(profileVisibilityDirty[cursor], frame); + } + pendingProfileVisibilityPublication = false; + }; + const applyVariants = (frame: number): void => { if (options.pagedState) { options.pagedState.publishVariants(frame); @@ -1127,6 +1197,11 @@ export function createPolycssPlayback( }; const publishSurfaceState = (frame: number): void => { + if (options.diagnostics) { + options.diagnostics.surfaceFullReconstructions += 1; + options.diagnostics.surfaceLightingTargetVisits += leaves.length; + options.diagnostics.surfaceVisibilityTargetVisits += leaves.length; + } for (let index = 0; index < leaves.length; index += 1) { const face = light.faces[index]; const state = stateAt(face, frame - 1); @@ -1137,53 +1212,141 @@ export function createPolycssPlayback( surfaceFrame = frame; }; + const publishSurfaceTarget = (index: number, state: number, frame: number): void => { + const face = light.faces[index]; + invariant(state >= 0 && state < face.stateCount, "INVALID_SURFACE_PUBLICATION", `Prepared surface leaf ${index} has no state for frame ${frame}.`); + if (isPaintVisible(index) && (appliedStates[index] !== state || dirtySurfaceStates[index] === 1)) { + if (interactionTransforms[index] === 0) flushPreparedLeafTransform(index); + writePreparedSurfaceState(index, state); + } + else if (!isPaintVisible(index)) dirtySurfaceStates[index] = appliedStates[index] === state ? 0 : 1; + }; + + const publishSurfaceRangeWithForced = (faces: Uint16Array, states: Uint16Array, start: number, end: number, frame: number): void => { + let cursor = start; + let forcedCursor = 0; + while (cursor < end || forcedCursor < currentForced.length) { + const scheduled = cursor < end ? faces[cursor] : 0x10000; + const forcedIndex = forcedCursor < currentForced.length ? currentForced[forcedCursor] : 0x10000; + let index: number; + let state: number; + if (scheduled < forcedIndex) { + index = scheduled; + state = states[cursor++]; + } else if (forcedIndex < scheduled) { + index = forcedIndex; + state = stateAt(light.faces[index], frame - 1); + forcedCursor += 1; + } else { + index = scheduled; + state = states[cursor++]; + forcedCursor += 1; + } + if (options.diagnostics) options.diagnostics.surfaceLightingTargetVisits += 1; + publishSurfaceTarget(index, state, frame); + } + }; + + const markSurfaceSegment = (segment: number): void => { + for (let cursor = light.sequentialOffsets[segment]; cursor < light.sequentialOffsets[segment + 1]; cursor += 1) { + const index = light.sequentialFaces[cursor]; + surfaceScratchStates[index] = light.sequentialStates[cursor]; + surfaceDirtyFlags[index] = 1; + } + }; + + const toggleVisibilitySegment = (segment: number, markDirty: boolean): void => { + for (let cursor = visibility.sequentialOffsets[segment]; cursor < visibility.sequentialOffsets[segment + 1]; cursor += 1) { + const index = visibility.sequentialFaces[cursor]; + visible[index] ^= 1; + if (markDirty) visibilityDirtyFlags[index] = 1; + } + }; + + const reconstructVisibility = (frame: number): void => { + visible.set(visibility.initial); + for (let next = 2; next <= frame; next += 1) toggleVisibilitySegment(next - 1, false); + }; + + const recoverSurface = (frame: number): void => { + if (options.diagnostics) { + options.diagnostics.surfaceFullReconstructions += 1; + options.diagnostics.surfaceLightingTargetVisits += packet.leafCount; + options.diagnostics.surfaceVisibilityTargetVisits += packet.leafCount; + } + reconstructVisibility(frame); + for (let index = 0; index < packet.leafCount; index += 1) publishSurfaceTarget(index, stateAt(light.faces[index], frame - 1), frame); + for (let index = 0; index < packet.leafCount; index += 1) writeVisibility(index, frame); + surfaceFrame = frame; + pendingSurfaceFrame = 0; + }; + const applySurface = (nextFrame: number): void => { + if (pendingSurfaceFrame !== 0) { + pendingSurfaceFrame = nextFrame; + try { + recoverSurface(nextFrame); + } catch (error) { + surfaceFrame = nextFrame; + pendingShapeVisibilityRecovery = true; + throw error; + } + return; + } if (nextFrame === surfaceFrame) return; + pendingSurfaceFrame = nextFrame; const frameCount = playbackParameters.frameCount; const sequential = nextFrame === (surfaceFrame === frameCount ? 1 : surfaceFrame + 1); const jump = sequential ? undefined : light.jumps.get(`${surfaceFrame}>${nextFrame}`); const visibilityJump = sequential ? undefined : visibility.jumps.get(`${surfaceFrame}>${nextFrame}`); - const changedVisibility = new Set(); - const changedSurface = new Map(); - const applyVisibilitySegment = (segment: number): void => { - for (let cursor = visibility.sequentialOffsets[segment]; cursor < visibility.sequentialOffsets[segment + 1]; cursor += 1) { - const index = visibility.sequentialFaces[cursor]; - visible[index] ^= 1; - changedVisibility.add(index); - } - }; - const applySurfaceSegment = (segment: number): void => { - for (let cursor = light.sequentialOffsets[segment]; cursor < light.sequentialOffsets[segment + 1]; cursor += 1) { - changedSurface.set(light.sequentialFaces[cursor], light.sequentialStates[cursor]); - } - }; - if (sequential) { - applyVisibilitySegment(nextFrame - 1); - applySurfaceSegment(nextFrame - 1); - } else if (jump && visibilityJump) { - for (const index of visibilityJump) { - visible[index] ^= 1; - changedVisibility.add(index); - } - for (let cursor = 0; cursor < jump.faces.length; cursor += 1) changedSurface.set(jump.faces[cursor], jump.states[cursor]); - } else { - let frame = surfaceFrame; - while (frame !== nextFrame) { - frame = frame === frameCount ? 1 : frame + 1; - applyVisibilitySegment(frame - 1); - applySurfaceSegment(frame - 1); + try { + if (sequential) { + const visibilityStart = visibility.sequentialOffsets[nextFrame - 1]; + const visibilityEnd = visibility.sequentialOffsets[nextFrame]; + toggleVisibilitySegment(nextFrame - 1, false); + publishSurfaceRangeWithForced(light.sequentialFaces, light.sequentialStates, light.sequentialOffsets[nextFrame - 1], light.sequentialOffsets[nextFrame], nextFrame); + if (options.diagnostics) options.diagnostics.surfaceVisibilityTargetVisits += visibilityEnd - visibilityStart; + for (let cursor = visibilityStart; cursor < visibilityEnd; cursor += 1) writeVisibility(visibility.sequentialFaces[cursor], nextFrame); + } else if (jump && visibilityJump) { + for (const index of visibilityJump) visible[index] ^= 1; + publishSurfaceRangeWithForced(jump.faces, jump.states, 0, jump.faces.length, nextFrame); + if (options.diagnostics) options.diagnostics.surfaceVisibilityTargetVisits += visibilityJump.length; + for (const index of visibilityJump) writeVisibility(index, nextFrame); + } else { + if (options.diagnostics) options.diagnostics.surfaceFullReconstructions += 1; + let frame = surfaceFrame; + while (frame !== nextFrame) { + frame = frame === frameCount ? 1 : frame + 1; + toggleVisibilitySegment(frame - 1, true); + markSurfaceSegment(frame - 1); + } + for (const index of currentForced) { + surfaceScratchStates[index] = stateAt(light.faces[index], nextFrame - 1); + surfaceDirtyFlags[index] = 1; + } + let surfaceDirtyCount = 0; + let visibilityDirtyCount = 0; + for (let index = 0; index < packet.leafCount; index += 1) { + if (surfaceDirtyFlags[index] === 1) { surfaceDirtyFlags[index] = 0; surfaceDirtyIndices[surfaceDirtyCount++] = index; } + if (visibilityDirtyFlags[index] === 1) { visibilityDirtyFlags[index] = 0; visibilityDirtyIndices[visibilityDirtyCount++] = index; } + } + if (options.diagnostics) { + options.diagnostics.surfaceLightingTargetVisits += surfaceDirtyCount; + options.diagnostics.surfaceVisibilityTargetVisits += visibilityDirtyCount; + } + for (let cursor = 0; cursor < surfaceDirtyCount; cursor += 1) { + const index = surfaceDirtyIndices[cursor]; + publishSurfaceTarget(index, surfaceScratchStates[index], nextFrame); + } + for (let cursor = 0; cursor < visibilityDirtyCount; cursor += 1) writeVisibility(visibilityDirtyIndices[cursor], nextFrame); } + surfaceFrame = nextFrame; + pendingSurfaceFrame = 0; + } catch (error) { + surfaceFrame = nextFrame; + pendingShapeVisibilityRecovery = true; + throw error; } - for (const index of currentForced) changedSurface.set(index, stateAt(light.faces[index], nextFrame - 1)); - for (const index of [...changedSurface.keys()].sort((left, right) => left - right)) { - const face = light.faces[index]; - const state = changedSurface.get(index)!; - invariant(state >= 0 && state < face.stateCount, "INVALID_SURFACE_PUBLICATION", `Prepared surface leaf ${index} has no state for frame ${nextFrame}.`); - if (isPaintVisible(index) && (appliedStates[index] !== state || dirtySurfaceStates[index] === 1)) writePreparedSurfaceState(index, state); - else if (!isPaintVisible(index)) dirtySurfaceStates[index] = appliedStates[index] === state ? 0 : 1; - } - for (const index of [...changedVisibility].sort((left, right) => left - right)) writeVisibility(index, nextFrame); - surfaceFrame = nextFrame; }; const stageFrame = (frame: number, includePagedVariants = true): PagedStateStage => { @@ -1206,45 +1369,170 @@ export function createPolycssPlayback( leafTargets[index] = packet.leafChanges[base]; stagedLeafTransforms[index] = packet.transforms[packet.leafChanges[base + 1]]; } - const playbackStage: PagedPlaybackStage = Object.freeze({ frame, complete: false, appearance: row[1], ...(row[2] === -1 ? {} : { modelTransform: packet.transforms[row[2]] }), shapeTargets, shapeTransforms: Object.freeze(stagedShapeTransforms), shapeVisibility: stagedShapeVisibility, leafTargets, leafTransforms: Object.freeze(stagedLeafTransforms) }); + const playbackStage: PagedPlaybackStage = Object.freeze({ frame, kind: "materialized", appearance: row[1], ...(row[2] === -1 ? {} : { modelTransform: packet.transforms[row[2]] }), shapeTargets, shapeTransforms: Object.freeze(stagedShapeTransforms), shapeVisibility: stagedShapeVisibility, leafTargets, leafTransforms: Object.freeze(stagedLeafTransforms) }); return Object.freeze({ frame, playback: playbackStage, variants: includePagedVariants ? options.pagedState?.stage(frame, false).variants ?? null : null }); }; - const applyStage = (staged: PagedStateStage, publish: boolean, dirtyShapes?: Set, dirtyLeaves?: Set, dirtyProfileLeaves?: Set): void => { + const applyStage = (staged: PagedStateStage, publish: boolean, dirtyShapes?: Set, dirtyLeaves?: Set): void => { const next = staged.playback; invariant(next, "INVALID_PLAYBACK_PUBLICATION", `Prepared playback frame ${staged.frame} has no transform stage.`); - if (publish && options.pagedState) options.pagedState.commit(packet.kind === "paged" ? staged : Object.freeze({ frame: staged.frame, playback: null, variants: staged.variants })); const nextFrame = next.frame; - if (next.appearance !== appearanceIndex) { + const appearanceChanged = next.appearance !== appearanceIndex; + if (appearanceChanged) { appearanceIndex = next.appearance; - if (publish) applyAppearance(); + pendingAppearancePublication = true; } + let modelChanged = false; if (next.modelTransform !== undefined) { + modelChanged = next.modelTransform !== modelTransform; modelTransform = next.modelTransform; - if (publish) writeModel(); - } - for (let index = 0; index < next.shapeTargets.length; index += 1) { - const shape = next.shapeTargets[index]; - shapeTransforms[shape] = next.shapeTransforms[index]; - shapeVisibility[shape] = next.shapeVisibility[index]; - dirtyShapes?.add(shape); - if (publish) shapes[shape].style.transform = shapeTransforms[shape]; + if (modelChanged) pendingModelPublication = true; } - for (let index = 0; index < next.leafTargets.length; index += 1) { - const leaf = next.leafTargets[index]; - leafTransforms[leaf] = next.leafTransforms[index]; - dirtyLeaves?.add(leaf); - if (publish) publishOrDeferPreparedLeafTransform(leaf); + if (next.kind === "range") { + if (options.diagnostics) { + options.diagnostics.playbackPublicationShapeVisits += next.shapeEnd - next.shapeStart; + options.diagnostics.playbackPublicationLeafVisits += next.leafEnd - next.leafStart; + } + for (let cursor = next.shapeStart; cursor < next.shapeEnd; cursor += 1) { + const shape = next.page.shapeTargets[cursor]; + shapeTransforms[shape] = next.page.transforms[next.page.shapeTransforms[cursor]]; + shapeVisibility[shape] = next.page.shapeVisibility[cursor]; + pendingShapeTransforms[shape] = 1; + dirtyShapes?.add(shape); + } + for (let cursor = next.leafStart; cursor < next.leafEnd; cursor += 1) { + const leaf = next.page.leafTargets[cursor]; + leafTransforms[leaf] = next.page.transforms[next.page.leafTransforms[cursor]]; + pendingLeafTransforms[leaf] = 1; + dirtyLeaves?.add(leaf); + } + } else if (next.kind === "complete") { + if (options.diagnostics) { + options.diagnostics.playbackPublicationShapeVisits += next.shapeTransforms.length; + options.diagnostics.playbackPublicationLeafVisits += next.leafTransforms.length; + } + for (let shape = 0; shape < next.shapeTransforms.length; shape += 1) { + shapeTransforms[shape] = next.shapeTransforms[shape]; + shapeVisibility[shape] = next.shapeVisibility[shape]; + pendingShapeTransforms[shape] = 1; + dirtyShapes?.add(shape); + } + for (let leaf = 0; leaf < next.leafTransforms.length; leaf += 1) { + leafTransforms[leaf] = next.leafTransforms[leaf]; + pendingLeafTransforms[leaf] = 1; + dirtyLeaves?.add(leaf); + } + } else { + if (options.diagnostics) { + options.diagnostics.playbackPublicationShapeVisits += next.shapeTargets.length; + options.diagnostics.playbackPublicationLeafVisits += next.leafTargets.length; + } + for (let index = 0; index < next.shapeTargets.length; index += 1) { + const shape = next.shapeTargets[index]; + shapeTransforms[shape] = next.shapeTransforms[index]; + shapeVisibility[shape] = next.shapeVisibility[index]; + pendingShapeTransforms[shape] = 1; + dirtyShapes?.add(shape); + } + for (let index = 0; index < next.leafTargets.length; index += 1) { + const leaf = next.leafTargets[index]; + leafTransforms[leaf] = next.leafTransforms[index]; + pendingLeafTransforms[leaf] = 1; + dirtyLeaves?.add(leaf); + } } - const profileVisibilityChanges = stageProfileVisibility(nextFrame); - for (const leaf of profileVisibilityChanges) dirtyProfileLeaves?.add(leaf); + sourceFrame = nextFrame; if (publish) { - if (!options.pagedState) applyVariants(nextFrame); - applySurface(nextFrame); - for (const leaf of profileVisibilityChanges) writeVisibility(leaf, nextFrame); - for (const shape of next.shapeTargets) shapes[shape].style.visibility = shapeVisibility[shape] === 1 ? "visible" : "hidden"; + if (appearanceChanged) publishPendingAppearance(); + if (modelChanged) publishPendingModel(); + if (next.kind === "range") { + for (let cursor = next.shapeStart; cursor < next.shapeEnd; cursor += 1) { + const shape = next.page.shapeTargets[cursor]; + publishPendingShapeTransform(shape); + } + for (let cursor = next.leafStart; cursor < next.leafEnd; cursor += 1) publishPendingLeafTransform(next.page.leafTargets[cursor]); + } else if (next.kind === "complete") { + for (let shape = 0; shape < next.shapeTransforms.length; shape += 1) publishPendingShapeTransform(shape); + for (let leaf = 0; leaf < next.leafTransforms.length; leaf += 1) publishPendingLeafTransform(leaf); + } else { + for (let cursor = 0; cursor < next.shapeTargets.length; cursor += 1) { + const shape = next.shapeTargets[cursor]; + publishPendingShapeTransform(shape); + } + for (let cursor = 0; cursor < next.leafTargets.length; cursor += 1) publishPendingLeafTransform(next.leafTargets[cursor]); + } } - sourceFrame = nextFrame; + }; + + const publishStageShapeVisibility = (staged: PagedStateStage): void => { + const next = staged.playback!; + pendingShapeVisibilityRecovery = true; + if (next.kind === "range") { + for (let cursor = next.shapeStart; cursor < next.shapeEnd; cursor += 1) { + const shape = next.page.shapeTargets[cursor]; + shapes[shape].style.visibility = shapeVisibility[shape] === 1 ? "visible" : "hidden"; + } + } else if (next.kind === "complete") { + for (let shape = 0; shape < next.shapeTransforms.length; shape += 1) shapes[shape].style.visibility = shapeVisibility[shape] === 1 ? "visible" : "hidden"; + } else { + for (let cursor = 0; cursor < next.shapeTargets.length; cursor += 1) { + const shape = next.shapeTargets[cursor]; + shapes[shape].style.visibility = shapeVisibility[shape] === 1 ? "visible" : "hidden"; + } + } + pendingShapeVisibilityRecovery = false; + }; + + const publishRecoveredShapeVisibility = (): boolean => { + if (!pendingShapeVisibilityRecovery) return false; + for (let shape = 0; shape < shapes.length; shape += 1) { + const nextVisibility = shapeVisibility[shape] === 1 ? "visible" : "hidden"; + if (shapes[shape].style.visibility !== nextVisibility) shapes[shape].style.visibility = nextVisibility; + } + pendingShapeVisibilityRecovery = false; + return true; + }; + + const publishDirtyShapeVisibility = (dirtyShapes: Set): void => { + if (publishRecoveredShapeVisibility()) return; + pendingShapeVisibilityRecovery = true; + for (const index of [...dirtyShapes].sort((left, right) => left - right)) { + const nextVisibility = shapeVisibility[index] === 1 ? "visible" : "hidden"; + if (shapes[index].style.visibility !== nextVisibility) shapes[index].style.visibility = nextVisibility; + } + pendingShapeVisibilityRecovery = false; + }; + + const publishCatchupState = ( + initialAppearance: number, + initialModelTransform: string, + dirtyShapes: Set, + dirtyLeaves: Set, + dirtyProfileLeaves: Set, + ): void => { + const recovering = pendingFramePublication; + const recoveringProfileVisibility = pendingProfileVisibilityPublication; + pendingFramePublication = true; + applyVariants(sourceFrame); + const profileVisibilityChangeCount = stageProfileVisibility(sourceFrame); + for (let cursor = 0; cursor < profileVisibilityChangeCount; cursor += 1) dirtyProfileLeaves.add(profileVisibilityDirty[cursor]); + if (recovering) recoverPendingTransforms(); + else { + if (modelTransform !== initialModelTransform) publishPendingModel(); + else pendingModelPublication = false; + if (appearanceIndex !== initialAppearance) publishPendingAppearance(); + else pendingAppearancePublication = false; + for (const index of [...dirtyShapes].sort((left, right) => left - right)) publishPendingShapeTransform(index); + for (const index of [...dirtyLeaves].sort((left, right) => left - right)) publishPendingLeafTransform(index); + } + applySurface(sourceFrame); + if (recoveringProfileVisibility) publishProfileVisibility(profileVisibilityChangeCount, true, sourceFrame); + else { + for (const index of [...dirtyProfileLeaves].sort((left, right) => left - right)) publishPendingProfileVisibilityTarget(index, sourceFrame); + pendingProfileVisibilityPublication = false; + } + publishDirtyShapeVisibility(dirtyShapes); + pendingFramePublication = false; }; const seekTo = ( @@ -1252,45 +1540,59 @@ export function createPolycssPlayback( synchronize: boolean, publicationKind: "seek" | "restart" | null = "seek", publicationTick = tick, + onCommitted?: () => void, ): number => { invariant(Number.isSafeInteger(target) && target >= 1 && target <= playbackParameters.frameCount, "FRAME_RANGE", `Prepared playback frame ${target} is out of range.`); options.assertPagedFrameReady?.(target); - const preflight = options.pagedState?.stage(target, packet.kind === "paged" && target !== sourceFrame); + const frameChanged = target !== sourceFrame; + const preflight = options.pagedState?.stage(target, packet.kind === "paged" && frameChanged); + const dirtyShapes = new Set(); + const dirtyLeaves = new Set(); const dirtyProfileLeaves = new Set(); if (publicationKind) options.compositorTiming?.before(publicationKind, publicationTick); - if (target !== sourceFrame) { - const dirtyShapes = new Set(); - const dirtyLeaves = new Set(); - let pagedStage: PagedStateStage | undefined = preflight; + if (preflight) options.pagedState!.commit(preflight); + else applyVariants(target); + onCommitted?.(); + const recovering = pendingFramePublication; + const recoveringProfileVisibility = pendingProfileVisibilityPublication; + pendingFramePublication = true; + if (frameChanged) { if (packet.kind === "paged") { - invariant(pagedStage?.playback, "INVALID_PLAYBACK_PUBLICATION", `Paged playback frame ${target} has no staged transform row.`); - applyStage(pagedStage, false, dirtyShapes, dirtyLeaves, dirtyProfileLeaves); + invariant(preflight?.playback, "INVALID_PLAYBACK_PUBLICATION", `Paged playback frame ${target} has no staged transform row.`); + applyStage(preflight, false, dirtyShapes, dirtyLeaves); } else { let next = sourceFrame; while (next !== target) { next = next === playbackParameters.frameCount ? 1 : next + 1; - applyStage(stageFrame(next, false), false, dirtyShapes, dirtyLeaves, dirtyProfileLeaves); + applyStage(stageFrame(next, false), false, dirtyShapes, dirtyLeaves); } } - if (pagedStage) options.pagedState!.commit(packet.kind === "paged" ? pagedStage : Object.freeze({ frame: target, playback: null, variants: pagedStage.variants })); - writeModel(); - applyAppearance(); + } + const profileVisibilityChangeCount = stageProfileVisibility(target); + for (let cursor = 0; cursor < profileVisibilityChangeCount; cursor += 1) dirtyProfileLeaves.add(profileVisibilityDirty[cursor]); + if (recovering) recoverPendingTransforms(synchronize); + else if (frameChanged) { + pendingModelPublication = true; + pendingAppearancePublication = true; + publishPendingModel(); + publishPendingAppearance(); for (const index of [...dirtyShapes].sort((left, right) => left - right)) { - shapes[index].style.transform = shapeTransforms[index]; - shapes[index].style.visibility = shapeVisibility[index] === 1 ? "visible" : "hidden"; + publishPendingShapeTransform(index); } for (const index of [...dirtyLeaves].sort((left, right) => left - right)) { if (synchronize) dirtyHiddenTransforms[index] = 1; - else publishOrDeferPreparedLeafTransform(index); + else publishPendingLeafTransform(index); } - sourceFrame = target; } - else if (preflight) options.pagedState!.commit(Object.freeze({ frame: target, playback: null, variants: preflight.variants })); if (synchronize) synchronizePreparedLeafTransforms(); - if (packet.kind === "inline" && !options.pagedState) applyVariants(target); - const profileVisibilityChanges = stageProfileVisibility(target); applySurface(target); - for (const index of new Set([...dirtyProfileLeaves, ...profileVisibilityChanges])) writeVisibility(index, target); + if (recoveringProfileVisibility) publishProfileVisibility(profileVisibilityChangeCount, true, target); + else { + for (const index of dirtyProfileLeaves) publishPendingProfileVisibilityTarget(index, target); + pendingProfileVisibilityPublication = false; + } + publishDirtyShapeVisibility(dirtyShapes); + pendingFramePublication = false; if (publicationKind) options.compositorTiming?.after(publicationKind, publicationTick); return target; }; @@ -1328,35 +1630,68 @@ export function createPolycssPlayback( return timelineFrame(activeTimeline, tick + count); }; - const advanceOne = (publish: boolean, dirtyShapes?: Set, dirtyLeaves?: Set, dirtyProfileLeaves?: Set): number => { + const advanceOne = (publish: boolean, dirtyShapes?: Set, dirtyLeaves?: Set): number => { + const previousTick = tick; const nextTick = tick + 1; const target = timelineFrame(activeTimeline, nextTick); options.assertPagedFrameReady?.(target); tick = nextTick; - if (target === sourceFrame) return target; - const expected = sourceFrame === playbackParameters.frameCount ? 1 : sourceFrame + 1; - if (target === expected) { - if (publish) options.compositorTiming?.before(target === 1 ? "wrap" : "advance", tick); - const staged = stageFrame(target); - if (!publish && packet.kind === "paged") options.pagedState!.commit(staged, false); - applyStage(staged, publish, dirtyShapes, dirtyLeaves, dirtyProfileLeaves); - if (publish) options.compositorTiming?.after(target === 1 ? "wrap" : "advance", tick); - } - else if (publish) seekTo(target, false); - else if (packet.kind === "paged") { - const staged = stageFrame(target); - options.pagedState!.commit(staged, false); - applyStage(staged, false, dirtyShapes, dirtyLeaves, dirtyProfileLeaves); - } - else { - let next = sourceFrame; - while (next !== target) { - next = next === playbackParameters.frameCount ? 1 : next + 1; - const staged = stageFrame(next); - applyStage(staged, false, dirtyShapes, dirtyLeaves, dirtyProfileLeaves); + try { + if (target === sourceFrame) { + if (publish && (pendingFramePublication || pendingProfileVisibilityPublication || pendingSurfaceFrame !== 0 || pendingShapeVisibilityRecovery)) { + const recoveringProfileVisibility = pendingProfileVisibilityPublication; + pendingFramePublication = true; + recoverPendingTransforms(); + applySurface(target); + publishProfileVisibility(0, recoveringProfileVisibility, target); + publishRecoveredShapeVisibility(); + pendingFramePublication = false; + } + return target; + } + const expected = sourceFrame === playbackParameters.frameCount ? 1 : sourceFrame + 1; + if (target === expected) { + if (publish) options.compositorTiming?.before(target === 1 ? "wrap" : "advance", tick); + const staged = stageFrame(target); + if (options.pagedState) options.pagedState.commit(packet.kind === "paged" ? staged : Object.freeze({ frame: staged.frame, playback: null, variants: staged.variants }), publish); + else if (publish) applyVariants(target); + const recovering = publish && pendingFramePublication; + const recoveringProfileVisibility = publish && pendingProfileVisibilityPublication; + if (publish) pendingFramePublication = true; + const profileVisibilityChangeCount = publish ? stageProfileVisibility(target) : 0; + applyStage(staged, publish && !recovering, dirtyShapes, dirtyLeaves); + if (publish) { + if (recovering) recoverPendingTransforms(); + applySurface(target); + publishProfileVisibility(profileVisibilityChangeCount, recoveringProfileVisibility, target); + if (!publishRecoveredShapeVisibility()) publishStageShapeVisibility(staged); + pendingFramePublication = false; + } + if (publish) options.compositorTiming?.after(target === 1 ? "wrap" : "advance", tick); } + else if (publish) seekTo(target, false); + else if (packet.kind === "paged") { + const staged = stageFrame(target); + options.pagedState!.commit(staged, false); + applyStage(staged, false, dirtyShapes, dirtyLeaves); + } + else { + if (options.pagedState) { + const staged = options.pagedState.stage(target, false); + options.pagedState.commit(staged, false); + } + let next = sourceFrame; + while (next !== target) { + next = next === playbackParameters.frameCount ? 1 : next + 1; + const staged = stageFrame(next, false); + applyStage(staged, false, dirtyShapes, dirtyLeaves); + } + } + return target; + } catch (error) { + if (sourceFrame !== target) tick = previousTick; + throw error; } - return target; }; const advanceMany = (count: number): readonly number[] => { @@ -1371,21 +1706,23 @@ export function createPolycssPlayback( const dirtyLeaves = new Set(); const dirtyProfileLeaves = new Set(); const frames: number[] = []; - for (let index = 0; index < count; index += 1) frames.push(advanceOne(false, dirtyShapes, dirtyLeaves, dirtyProfileLeaves)); - applyVariants(sourceFrame); - if (modelTransform !== initialModelTransform) writeModel(); - if (appearanceIndex !== initialAppearance) applyAppearance(); - for (const index of [...dirtyShapes].sort((left, right) => left - right)) { - const transform = shapeTransforms[index]; - if (shapes[index].style.transform !== transform) shapes[index].style.transform = transform; - } - for (const index of [...dirtyLeaves].sort((left, right) => left - right)) publishOrDeferPreparedLeafTransform(index); - applySurface(sourceFrame); - for (const index of [...dirtyProfileLeaves].sort((left, right) => left - right)) writeVisibility(index, sourceFrame); - for (const index of [...dirtyShapes].sort((left, right) => left - right)) { - const nextVisibility = shapeVisibility[index] === 1 ? "visible" : "hidden"; - if (shapes[index].style.visibility !== nextVisibility) shapes[index].style.visibility = nextVisibility; + try { + for (let index = 0; index < count; index += 1) frames.push(advanceOne(false, dirtyShapes, dirtyLeaves)); + } catch (error) { + if (frames.length > 0) { + try { + publishCatchupState(initialAppearance, initialModelTransform, dirtyShapes, dirtyLeaves, dirtyProfileLeaves); + } catch (recoveryError) { + throw new DomFormatError( + "PLAYBACK_PUBLICATION_RECOVERY_FAILED", + "Prepared playback catch-up failed and its committed prefix could not be published.", + { publicationError: error, recoveryError }, + ); + } + } + throw error; } + publishCatchupState(initialAppearance, initialModelTransform, dirtyShapes, dirtyLeaves, dirtyProfileLeaves); options.compositorTiming?.after("catch-up", tick); return Object.freeze(frames); }; @@ -1396,8 +1733,14 @@ export function createPolycssPlayback( const target = timelineFrame(activeTimeline, nextTick); options.assertPagedFrameReady?.(target); options.compositorTiming?.before("catch-up", nextTick); + const previousTick = tick; tick = nextTick; - seekTo(target, false, null, nextTick); + try { + seekTo(target, false, null, nextTick); + } catch (error) { + if (sourceFrame !== target) tick = previousTick; + throw error; + } options.compositorTiming?.after("catch-up", tick); return target; }; @@ -1411,6 +1754,7 @@ export function createPolycssPlayback( if (next[index] === 0) nextIndices.push(index); next[index] = 1; } + nextIndices.sort((left, right) => left - right); const changed = new Set([...currentForced, ...nextIndices]); for (const index of changed) { forced[index] = next[index]; @@ -1457,14 +1801,22 @@ export function createPolycssPlayback( return profile?.id ?? null; }; - const restoreInteraction = (shapeIndices: readonly number[], leafIndices: readonly number[]): void => { + const validateInteractionIndices = (shapeIndices: readonly number[], leafIndices: readonly number[]): void => { for (const index of shapeIndices) { invariant(Number.isSafeInteger(index) && index >= 0 && index < packet.shapeCount, "INVALID_INTERACTION_PUBLICATION", `Interaction shape ${index} is out of range.`); + } + for (const index of leafIndices) { + invariant(Number.isSafeInteger(index) && index >= 0 && index < packet.leafCount, "INVALID_INTERACTION_PUBLICATION", `Interaction leaf ${index} is out of range.`); + } + }; + + const restoreInteraction = (shapeIndices: readonly number[], leafIndices: readonly number[]): void => { + validateInteractionIndices(shapeIndices, leafIndices); + for (const index of shapeIndices) { shapes[index].style.transform = shapeTransforms[index]; shapes[index].style.visibility = shapeVisibility[index] === 1 ? "visible" : "hidden"; } for (const index of leafIndices) { - invariant(Number.isSafeInteger(index) && index >= 0 && index < packet.leafCount, "INVALID_INTERACTION_PUBLICATION", `Interaction leaf ${index} is out of range.`); degenerate[index] = 0; writePreparedLeafTransform(index); writeVisibility(index); @@ -1514,24 +1866,34 @@ export function createPolycssPlayback( const previousBank = activeBank; const previousTimeline = activeTimeline; const previousTick = tick; + let committed = false; activeBank = bank; activeTimeline = timelineFor(bank, selectedTimelineProfileId); try { tick = 0; - return seekTo(bank.entryFrame, true, "restart", 0); + return seekTo(bank.entryFrame, true, "restart", 0, () => { committed = true; }); } catch (error) { - activeBank = previousBank; - activeTimeline = previousTimeline; - tick = previousTick; + if (!committed) { + activeBank = previousBank; + activeTimeline = previousTimeline; + tick = previousTick; + } throw error; } }, restart(shapeIndices = [], leafIndices = []) { + validateInteractionIndices(shapeIndices, leafIndices); options.compositorTiming?.before("restart", 0); - seekTo(activeBank?.entryFrame ?? packet.initial.sourceFrame, true, null, 0); - restoreInteraction(shapeIndices, leafIndices); - forceVisible(new Uint16Array(0)); - tick = 0; + let committed = false; + try { + seekTo(activeBank?.entryFrame ?? packet.initial.sourceFrame, true, null, 0, () => { committed = true; }); + restoreInteraction(shapeIndices, leafIndices); + forceVisible(new Uint16Array(0)); + tick = 0; + } catch (error) { + if (committed) tick = 0; + throw error; + } options.compositorTiming?.after("restart", tick); return sourceFrame; }, @@ -1549,9 +1911,11 @@ export function createPolycssPlayback( const staged = options.pagedState ? options.pagedState.stage(nextFrame, false) : null; if (staged) options.pagedState!.commit(staged); else applyVariants(nextFrame); - const profileVisibilityChanges = stageProfileVisibility(nextFrame); + const recoveringProfileVisibility = pendingProfileVisibilityPublication; + const profileVisibilityChangeCount = stageProfileVisibility(nextFrame); applySurface(nextFrame); - for (const leaf of profileVisibilityChanges) writeVisibility(leaf, nextFrame); + publishProfileVisibility(profileVisibilityChangeCount, recoveringProfileVisibility, nextFrame); + publishRecoveredShapeVisibility(); return nextFrame; }, applyViewportProfile, diff --git a/packages/domformat/test/browser-reader.test.js b/packages/domformat/test/browser-reader.test.js index 19d2bfba..bc8d2cfb 100644 --- a/packages/domformat/test/browser-reader.test.js +++ b/packages/domformat/test/browser-reader.test.js @@ -7,11 +7,12 @@ import { mountDom, readDomBrowser, readDomBrowserUrl } from "../src/browser.js"; import { createInteractionInput } from "../src/browser-input.js"; import { decodeJson, encodeCanonicalJson } from "../src/canonical-json.js"; import { DEFAULT_LIMITS } from "../src/constants.js"; -import { createPolycssPagedState } from "../src/state/paged-state.js"; +import { pagedPlaybackPublicationWorkspaceBytes } from "../src/state-pages.js"; +import { createPolycssPagedState, createPolycssPublicationDiagnostics } from "../src/state/paged-state.js"; import { createPolycssPlayback, materializePolycssState } from "../src/state/polycss.js"; import { createStaticPresentation } from "../src/state/presentation.js"; import { buildDom } from "../src/writer.js"; -import { builtExternalResources, errorCode, largePagedDescriptorClosure, syntheticAdapterTechniquesInput, syntheticAnimationWithoutEffectsInput, syntheticAspectProfileTimelinesInput, syntheticCompositorTimingInput, syntheticEvictingPagedVariantsInput, syntheticExecutableInteractionInput, syntheticInput, syntheticOrbitInput, syntheticPagedPlaybackInput, syntheticPagedPreparedBanksInput, syntheticPreparedBanksInput, syntheticPagedProfileTimelinesWithoutInteractionInput, syntheticPagedVariantsInput, syntheticProfileTimelinesInput, syntheticResponsivePresentationInput, syntheticStaticPresentationInput, syntheticPolycssInput, syntheticViewportProfilesInput } from "./helpers.js"; +import { builtExternalResources, errorCode, largePagedDescriptorClosure, syntheticAdapterTechniquesInput, syntheticAnimationWithoutEffectsInput, syntheticAspectProfileTimelinesInput, syntheticCompositorTimingInput, syntheticEvictingPagedVariantsInput, syntheticExecutableInteractionInput, syntheticInput, syntheticOrbitInput, syntheticPagedPlaybackChangesInput, syntheticPagedPlaybackInput, syntheticPagedPreparedBanksInput, syntheticPreparedBanksInput, syntheticPagedProfileTimelinesWithoutInteractionInput, syntheticPagedVariantsInput, syntheticProfileTimelinesInput, syntheticResponsivePresentationInput, syntheticStaticPresentationInput, syntheticPolycssInput, syntheticViewportProfilesInput } from "./helpers.js"; import { dispatch, FakeElement, fakeBrowserDocument } from "./fake-browser.js"; function foreignArrayBuffer(bytes) { @@ -25,6 +26,56 @@ function base64Integers(values, width) { return Buffer.from(bytes).toString("base64"); } +function resetPublicationDiagnostics(diagnostics) { + for (const key of Object.keys(diagnostics)) diagnostics[key] = 0; +} + +function pagedStatePeaks(pagedState) { + return { + residentPages: pagedState.peakResidentPages, + decodedBytes: pagedState.peakDecodedBytes, + materializedBytes: pagedState.peakMaterializedBytes, + documentStateBytes: pagedState.peakDocumentStateBytes, + }; +} + +function withoutSequentialRangeCopies(callback) { + const originalArrayFrom = Array.from; + const OriginalSet = globalThis.Set; + const typedArrays = [Uint8Array, Uint16Array, Uint32Array]; + const originalSlices = typedArrays.map((constructor) => constructor.prototype.slice); + Array.from = () => { throw new Error("sequential staging called Array.from"); }; + globalThis.Set = class extends OriginalSet { constructor() { throw new Error("sequential staging allocated a Set"); } }; + for (const constructor of typedArrays) constructor.prototype.slice = () => { throw new Error("sequential staging sliced a typed range"); }; + try { + return callback(); + } finally { + Array.from = originalArrayFrom; + globalThis.Set = OriginalSet; + for (let index = 0; index < typedArrays.length; index += 1) typedArrays[index].prototype.slice = originalSlices[index]; + } +} + +function withoutPlaybackBoundaryAllocations(callback) { + const originalArrayFrom = Array.from; + const typedArrays = [Uint8Array, Uint16Array, Uint32Array]; + const originalSlices = typedArrays.map((constructor) => constructor.prototype.slice); + const originalUint32Subarray = Uint32Array.prototype.subarray; + const originalUint32Iterator = Uint32Array.prototype[Symbol.iterator]; + Array.from = () => { throw new Error("playback boundary validation called Array.from"); }; + Uint32Array.prototype.subarray = () => { throw new Error("playback boundary validation created a target subarray"); }; + Uint32Array.prototype[Symbol.iterator] = () => { throw new Error("playback boundary validation spread a target range"); }; + for (const constructor of typedArrays) constructor.prototype.slice = () => { throw new Error("playback boundary validation sliced a typed range"); }; + try { + return callback(); + } finally { + Array.from = originalArrayFrom; + Uint32Array.prototype.subarray = originalUint32Subarray; + Uint32Array.prototype[Symbol.iterator] = originalUint32Iterator; + for (let index = 0; index < typedArrays.length; index += 1) typedArrays[index].prototype.slice = originalSlices[index]; + } +} + function documentRoutes(built, modelUrl) { const routes = new Map([[modelUrl, built.bytes]]); for (const record of built.document.resources.resources) { @@ -825,6 +876,216 @@ test("paged variant admission never transiently exceeds the decoded residency ce paged.destroy(); }); +test("sequential paged variants compare only their resident typed target range", async () => { + const targetCount = 1_984; + const target = 937; + const keyframe = new Array(targetCount).fill(0); + const payload = { + version: 0, + codec: "polycss-paged-variants-page@0", + channel: "variants", + startFrame: 1, + endFrame: 3, + keyframeClassIndicesBase64: base64Integers(keyframe, 2), + sequential: { + offsetsBase64: base64Integers([0, 0, 1, 2], 4), + targetIndicesBase64: base64Integers([target, target + 1], 2), + classIndicesBase64: base64Integers([1, 1], 2), + }, + }; + const bytes = encodeCanonicalJson(payload); + const materializedByteLength = targetCount * 2 + 16 + 8; + const ids = Array.from({ length: targetCount }, (_, index) => `variant:${index}`); + const fake = fakeBrowserDocument(); + const nodes = ids.map((id) => { + const node = new FakeElement(fake.document, "s"); + node.classList.add("class-a"); + return [id, node]; + }); + fake.writes.splice(0); + const document = { + state: { channels: [{ id: "variants", codec: "polycss-paged-variants@0", data: { packet: { + frameCount: 3, + classes: ["class-a", "class-b"], + initial: { frame: 1, classIndicesBase64: base64Integers(keyframe, 2) }, + pages: [{ resource: "variants-page", startFrame: 1, endFrame: 3, changeCount: 2, materializedByteLength }], + lookaheadPages: 1, + maxResidentPages: 2, + } } }] }, + bindings: { channels: [{ id: "variants", state: "variants", interpreter: "polycss-paged-variants@0", targets: { nodes: ids } }] }, + resources: { resources: [{ id: "variants-page", kind: "state-page", decodedByteLength: bytes.length }] }, + }; + const diagnostics = createPolycssPublicationDiagnostics(); + const pagedState = createPolycssPagedState(document, { byId: new Map(nodes) }, DEFAULT_LIMITS, async () => bytes, { diagnostics }); + await pagedState.prepareInitial(); + resetPublicationDiagnostics(diagnostics); + + const staged = withoutSequentialRangeCopies(() => pagedState.stage(2, false)); + assert.equal(staged.variants.kind, "range"); + assert.equal(Object.hasOwn(staged.variants, "targets"), false); + assert.equal(Object.hasOwn(staged.variants, "classes"), false); + assert.ok(ArrayBuffer.isView(staged.variants.page.targets)); + const restaged = pagedState.stage(2, false); + assert.equal(restaged.variants.page, staged.variants.page); + assert.throws(() => pagedState.commit(staged), errorCode("INVALID_PLAYBACK_PUBLICATION")); + withoutSequentialRangeCopies(() => pagedState.commit(restaged)); + assert.equal(diagnostics.variantLogicalTargetVisits, 1); + assert.equal(diagnostics.variantComparisonTargetVisits, 1); + assert.equal(diagnostics.variantCanonicalReconstructions, 0); + assert.equal(diagnostics.variantDomWrites, 2); + assert.deepEqual(fake.writes.map((write) => [write.element === nodes[target][1] ? target : -1, write.property]), [[target, "class:remove"], [target, "class:add"]]); + + resetPublicationDiagnostics(diagnostics); + fake.writes.splice(0); + pagedState.publishVariants(2); + assert.equal(diagnostics.variantCanonicalReconstructions, 0); + assert.equal(diagnostics.variantLogicalTargetVisits, 0); + assert.equal(diagnostics.variantComparisonTargetVisits, targetCount); + assert.deepEqual(fake.writes, []); + + resetPublicationDiagnostics(diagnostics); + pagedState.publishVariants(1); + assert.equal(diagnostics.variantCanonicalReconstructions, 1, "page-start publication must retain an immutable complete stage"); + assert.equal(diagnostics.variantLogicalTargetVisits, targetCount); + assert.equal(diagnostics.variantComparisonTargetVisits, targetCount, "page-start complete publication must compare the full published row"); + assert.deepEqual(nodes[target][1].classes, ["class-a"]); + pagedState.destroy(); +}); + +test("paged playback mutates sparse canonical rows in place and visits every declared dense target", async () => { + const exercise = async (input) => { + const built = buildDom(await input); + const all = builtExternalResources(built); + const fake = fakeBrowserDocument(); + const mounted = { byId: new Map(built.document.tree.nodes.map((node) => { + const element = new FakeElement(fake.document, node.name); + Object.assign(element.style, node.styles ?? {}); + for (const className of node.classes) element.classList.add(className); + return [node.id, element]; + })) }; + const diagnostics = createPolycssPublicationDiagnostics(); + const pagedState = createPolycssPagedState(built.document, mounted, DEFAULT_LIMITS, async (record) => all.get(record.id), { diagnostics }); + await pagedState.prepareInitial(); + const playback = createPolycssPlayback(materializePolycssState(built.document.state), built.document.bindings, mounted, { + publishAppearance() {}, + pagedState, + assertPagedFrameReady: (frame) => pagedState.assertFrameReady(frame), + diagnostics, + }); + playback.publishInitial(); + return { built, diagnostics, fake, mounted, pagedState, playback }; + }; + + const sparse = await exercise(syntheticPagedPlaybackChangesInput({ variants: false })); + await sparse.pagedState.ensureFrame(2); + sparse.playback.advance(); + await sparse.pagedState.ensureFrame(3); + sparse.playback.advance(); + const sparseCanonical = sparse.pagedState.canonicalPlayback; + const sparseShapeRow = sparseCanonical.shapeTransforms; + const sparseVisibilityRow = sparseCanonical.shapeVisibility; + const sparseLeafRow = sparseCanonical.leafTransforms; + resetPublicationDiagnostics(sparse.diagnostics); + const sparseStage = withoutSequentialRangeCopies(() => sparse.pagedState.stage(4)); + assert.equal(sparseStage.playback.kind, "range"); + for (const property of ["shapeTargets", "shapeTransforms", "shapeVisibility", "leafTargets", "leafTransforms"]) assert.equal(Object.hasOwn(sparseStage.playback, property), false); + withoutSequentialRangeCopies(() => sparse.playback.advance()); + assert.equal(sparse.playback.sourceFrame, 4); + assert.equal(sparse.pagedState.canonicalPlayback, sparseCanonical); + assert.equal(sparse.pagedState.canonicalPlayback.shapeTransforms, sparseShapeRow); + assert.equal(sparse.pagedState.canonicalPlayback.shapeVisibility, sparseVisibilityRow); + assert.equal(sparse.pagedState.canonicalPlayback.leafTransforms, sparseLeafRow); + assert.equal(sparse.diagnostics.playbackCanonicalReconstructions, 0); + assert.equal(sparse.diagnostics.playbackCanonicalShapeVisits, 1); + assert.equal(sparse.diagnostics.playbackCanonicalLeafVisits, 0); + assert.equal(sparse.diagnostics.playbackBoundaryShapeVisits, 0); + assert.equal(sparse.diagnostics.playbackBoundaryLeafVisits, 0); + assert.equal(sparse.diagnostics.playbackPublicationShapeVisits, 1); + assert.equal(sparse.diagnostics.playbackPublicationLeafVisits, 0); + sparse.pagedState.destroy(); + + const denseInput = syntheticPagedPlaybackInput({ ranges: [[1, 8]], mutate(input) { + const packet = input.state.channels.find((channel) => channel.codec === "polycss-playback-packed@0").data.packet; + packet.transforms.count = 9; + const changed = [1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 0, 0]; + for (const group of [packet.transforms.groups[1], packet.transforms.groups[2]]) group.columns = changed.map((value) => [value]); + for (const group of [packet.transforms.groups[3], packet.transforms.groups[4]]) group.columns.forEach((column, component) => column.push(component === 9 ? 10_000 : column[0])); + packet.shapeChanges = { sources: [0, 1], transforms: [5, 1], visibility: [0, 0] }; + packet.frameRows[1][3] = 0; + packet.frameRows[1][4] = 2; + packet.leafChanges = { sources: [0, 1], transforms: [7, 1] }; + packet.frameRows[1][5] = 0; + packet.frameRows[1][6] = 2; + } }); + const dense = await exercise(denseInput); + const retained = [...dense.mounted.byId.values()]; + resetPublicationDiagnostics(dense.diagnostics); + dense.playback.advance(); + assert.equal(dense.diagnostics.playbackCanonicalReconstructions, 0); + assert.equal(dense.diagnostics.playbackCanonicalShapeVisits, 2); + assert.equal(dense.diagnostics.playbackCanonicalLeafVisits, 2); + assert.equal(dense.diagnostics.playbackPublicationShapeVisits, 2); + assert.equal(dense.diagnostics.playbackPublicationLeafVisits, 2); + assert.deepEqual([...dense.mounted.byId.values()], retained); + + resetPublicationDiagnostics(dense.diagnostics); + dense.playback.seek(6); + assert.equal(dense.diagnostics.playbackCanonicalReconstructions, 1); + assert.equal(dense.diagnostics.playbackCanonicalShapeVisits, 2); + assert.equal(dense.diagnostics.playbackCanonicalLeafVisits, 2); + assert.equal(dense.diagnostics.playbackPublicationShapeVisits, 2); + assert.equal(dense.diagnostics.playbackPublicationLeafVisits, 2); + + dense.pagedState.commit(dense.pagedState.stage(8)); + const wrapCanonical = dense.pagedState.canonicalPlayback; + const wrapShapeRow = wrapCanonical.shapeTransforms; + const wrapLeafRow = wrapCanonical.leafTransforms; + resetPublicationDiagnostics(dense.diagnostics); + const wrap = withoutPlaybackBoundaryAllocations(() => dense.pagedState.stage(1)); + assert.equal(wrap.playback.kind, "range"); + const densePacket = dense.built.document.state.channels.find((channel) => channel.codec === "polycss-paged-playback@0").data.packet; + assert.equal(dense.diagnostics.playbackBoundaryShapeVisits, densePacket.shapeCount + wrap.playback.shapeEnd - wrap.playback.shapeStart); + assert.equal(dense.diagnostics.playbackBoundaryLeafVisits, densePacket.leafCount + wrap.playback.leafEnd - wrap.playback.leafStart); + dense.pagedState.commit(wrap); + assert.equal(dense.pagedState.canonicalPlayback, wrapCanonical); + assert.equal(dense.pagedState.canonicalPlayback.shapeTransforms, wrapShapeRow); + assert.equal(dense.pagedState.canonicalPlayback.leafTransforms, wrapLeafRow); + assert.equal(dense.diagnostics.playbackCanonicalReconstructions, 0); + assert.equal(dense.diagnostics.playbackCanonicalShapeVisits, 2); + assert.equal(dense.diagnostics.playbackCanonicalLeafVisits, 2); + dense.pagedState.destroy(); +}); + +test("cross-page playback boundary validation avoids target-sized temporary rows and ranges", async () => { + const built = buildDom(await syntheticPagedPlaybackChangesInput({ variants: false })); + const all = builtExternalResources(built); + const fake = fakeBrowserDocument(); + const mounted = { byId: new Map(built.document.tree.nodes.map((node) => { + const element = new FakeElement(fake.document, node.name); + Object.assign(element.style, node.styles ?? {}); + return [node.id, element]; + })) }; + const diagnostics = createPolycssPublicationDiagnostics(); + const pagedState = createPolycssPagedState(built.document, mounted, DEFAULT_LIMITS, async (record) => all.get(record.id), { diagnostics }); + await pagedState.prepareInitial(); + await pagedState.ensureFrame(2); + resetPublicationDiagnostics(diagnostics); + const withinPage = pagedState.stage(2); + assert.equal(diagnostics.playbackBoundaryShapeVisits, 0); + assert.equal(diagnostics.playbackBoundaryLeafVisits, 0); + pagedState.commit(withinPage); + resetPublicationDiagnostics(diagnostics); + await pagedState.ensureFrame(3); + const staged = withoutPlaybackBoundaryAllocations(() => pagedState.stage(3)); + assert.equal(staged.playback.kind, "range"); + const packet = built.document.state.channels.find((channel) => channel.codec === "polycss-paged-playback@0").data.packet; + assert.equal(diagnostics.playbackBoundaryShapeVisits, packet.shapeCount + staged.playback.shapeEnd - staged.playback.shapeStart); + assert.equal(diagnostics.playbackBoundaryLeafVisits, packet.leafCount + staged.playback.leafEnd - staged.playback.leafStart); + pagedState.commit(staged); + assert.equal(pagedState.frame, 3); + pagedState.destroy(); +}); + test("paged playback preserves exact random, boundary, wrap, and cross-channel publication", async () => { const ranges = Array.from({ length: 8 }, (_, index) => [index + 1, index + 1]); const built = buildDom(await syntheticPagedPlaybackInput({ variants: true, ranges })); @@ -846,6 +1107,85 @@ test("paged playback preserves exact random, boundary, wrap, and cross-channel p runtime.destroy(); }); +test("range stages pin verified pages until commit and replacement discards abandoned pins", async () => { + const ranges = Array.from({ length: 8 }, (_, index) => [index + 1, index + 1]); + const built = buildDom(await syntheticPagedPlaybackInput({ ranges })); + const all = builtExternalResources(built); + const create = () => { + const fake = fakeBrowserDocument(); + const mounted = { byId: new Map(built.document.tree.nodes.map((node) => { + const element = new FakeElement(fake.document, node.name); + Object.assign(element.style, node.styles ?? {}); + for (const className of node.classes) element.classList.add(className); + return [node.id, element]; + })) }; + return createPolycssPagedState(built.document, mounted, DEFAULT_LIMITS, async (record) => all.get(record.id)); + }; + + const committed = create(); + await committed.prepareInitial(); + await committed.ensureFrame(2); + const frame2 = committed.stage(2); + for (const frame of [4, 6, 8]) await committed.ensureFrame(frame); + assert.equal(committed.residentResources.includes("playback-page-2"), true); + assert.equal(committed.isFrameReady(2), true); + assert.ok(committed.peakResidentPages <= 5); + committed.commit(frame2); + assert.equal(committed.frame, 2); + assert.equal(committed.isFrameReady(2), true); + committed.destroy(); + + const abandoned = create(); + await abandoned.prepareInitial(); + await abandoned.ensureFrame(2); + const stale = abandoned.stage(2); + await abandoned.ensureFrame(6); + const replacement = abandoned.stage(6); + await abandoned.ensureFrame(8); + await abandoned.ensureFrame(4); + assert.equal(abandoned.residentResources.includes("playback-page-2"), false); + assert.throws(() => abandoned.commit(stale), errorCode("INVALID_PLAYBACK_PUBLICATION")); + assert.equal(abandoned.commit(replacement), 6); + assert.ok(abandoned.peakResidentPages <= 5); + abandoned.destroy(); +}); + +test("complete paged stages remain stable after later sparse commits", async () => { + const built = buildDom(await syntheticPagedPlaybackChangesInput({ variants: false })); + const all = builtExternalResources(built); + const fake = fakeBrowserDocument(); + const mounted = { byId: new Map(built.document.tree.nodes.map((node) => { + const element = new FakeElement(fake.document, node.name); + Object.assign(element.style, node.styles ?? {}); + for (const className of node.classes) element.classList.add(className); + return [node.id, element]; + })) }; + const pagedState = createPolycssPagedState(built.document, mounted, DEFAULT_LIMITS, async (record) => all.get(record.id)); + await pagedState.prepareInitial(); + await pagedState.ensureFrame(4); + const complete = pagedState.stage(4); + assert.equal(complete.playback.kind, "complete"); + const packet = built.document.state.channels.find((channel) => channel.codec === "polycss-paged-playback@0").data.packet; + const pageBytes = new Map(packet.pages.map((page) => [page.resource, page.materializedByteLength])); + const completePage = packet.pages.find((page) => complete.frame >= page.startFrame && complete.frame <= page.endFrame); + const stageWorkspace = pagedPlaybackPublicationWorkspaceBytes(completePage.materializedByteLength, packet.shapeCount, packet.leafCount); + const shapes = [...complete.playback.shapeTransforms]; + const visibility = [...complete.playback.shapeVisibility]; + const leaves = [...complete.playback.leafTransforms]; + await pagedState.ensureFrame(8); + const residentBytes = pagedState.residentResources.reduce((total, resource) => total + pageBytes.get(resource), 0); + assert.ok(pagedState.peakMaterializedBytes >= residentBytes + stageWorkspace); + pagedState.commit(complete); + await pagedState.ensureFrame(5); + const sparse = pagedState.stage(5); + assert.equal(sparse.playback.kind, "range"); + pagedState.commit(sparse); + assert.deepEqual(complete.playback.shapeTransforms, shapes); + assert.deepEqual([...complete.playback.shapeVisibility], visibility); + assert.deepEqual(complete.playback.leafTransforms, leaves); + pagedState.destroy(); +}); + test("public prepared-bank selection keeps one retained topology and restarts the selected canonical timeline", async () => { const built = buildDom(await syntheticPreparedBanksInput()); const result = await readDomBrowser(built.bytes, { externalResources: builtExternalResources(built) }); @@ -1141,9 +1481,253 @@ test("document-wide page workspace ceiling rejects before fetching or materializ await assert.rejects(pagedState.prepareInitial(), errorCode("STATE_PAGE_RESIDENCY_LIMIT")); assert.equal(loads, 0); assert.equal(pagedState.residentResources.length, 0); + assert.deepEqual(pagedStatePeaks(pagedState), { residentPages: 0, decodedBytes: 0, materializedBytes: 0, documentStateBytes: 0 }); + pagedState.destroy(); +}); + +test("paged residency accounting rejects before eviction or fetch", async () => { + const built = buildDom(await syntheticEvictingPagedVariantsInput()); + const all = builtExternalResources(built); + const fake = fakeBrowserDocument(); + const mounted = { byId: new Map(built.document.tree.nodes.map((node) => [node.id, new FakeElement(fake.document, node.name)])) }; + const limits = { ...DEFAULT_LIMITS }; + const calls = []; + const pagedState = createPolycssPagedState(built.document, mounted, limits, async (record) => { + calls.push(record.id); + return all.get(record.id); + }); + await pagedState.prepareInitial(); + await pagedState.ensureFrame(7); + const before = pagedState.residentResources; + const peaksBefore = pagedStatePeaks(pagedState); + const callsBefore = calls.length; + limits.maxAggregateDecodedBytes = 1; + await assert.rejects(pagedState.ensureFrame(3), errorCode("STATE_PAGE_RESIDENCY_LIMIT")); + assert.deepEqual(pagedState.residentResources, before); + assert.deepEqual(pagedStatePeaks(pagedState), peaksBefore); + assert.equal(calls.length, callsBefore); + limits.maxAggregateDecodedBytes = DEFAULT_LIMITS.maxAggregateDecodedBytes; + await pagedState.ensureFrame(3); + pagedState.destroy(); +}); + +test("nonuniform complete publication preflights materialization and projected live bytes atomically", async () => { + const built = buildDom(await syntheticPagedPlaybackChangesInput({ variants: true })); + const all = builtExternalResources(built); + const fake = fakeBrowserDocument(); + const mounted = { byId: new Map(built.document.tree.nodes.map((node) => { + const element = new FakeElement(fake.document, node.name); + Object.assign(element.style, node.styles ?? {}); + for (const className of node.classes) element.classList.add(className); + return [node.id, element]; + })) }; + const limits = { ...DEFAULT_LIMITS }; + const pagedState = createPolycssPagedState(built.document, mounted, limits, async (record) => all.get(record.id)); + await pagedState.prepareInitial(); + await pagedState.ensureFrame(4); + const packet = built.document.state.channels.find((channel) => channel.codec === "polycss-paged-playback@0").data.packet; + assert.ok(new Set(packet.pages.map((page) => page.materializedByteLength)).size > 1); + + limits.maxAggregateDecodedBytes = 1; + const peaksBeforeStageRejection = pagedStatePeaks(pagedState); + const originalArrayFrom = Array.from; + let materialized = false; + let stageError; + Array.from = () => { materialized = true; throw new Error("complete row materialized before workspace admission"); }; + try { pagedState.stage(4); } catch (error) { stageError = error; } finally { Array.from = originalArrayFrom; } + assert.throws(() => { throw stageError; }, errorCode("STATE_PAGE_RESIDENCY_LIMIT")); + assert.equal(materialized, false); + assert.deepEqual(pagedStatePeaks(pagedState), peaksBeforeStageRejection); + + limits.maxAggregateDecodedBytes = DEFAULT_LIMITS.maxAggregateDecodedBytes; + const staged = pagedState.stage(4); + assert.equal(staged.playback.kind, "complete"); + assert.equal(staged.variants.kind, "complete"); + const canonical = pagedState.canonicalPlayback; + const shapeTransforms = [...canonical.shapeTransforms]; + const shapeVisibility = [...canonical.shapeVisibility]; + const leafTransforms = [...canonical.leafTransforms]; + const classes = [...mounted.byId.get("synthetic/leaf").classes]; + fake.writes.splice(0); + limits.maxAggregateDecodedBytes = 1; + const peaksBeforeCommitRejection = pagedStatePeaks(pagedState); + assert.throws(() => pagedState.commit(staged), errorCode("STATE_PAGE_RESIDENCY_LIMIT")); + assert.equal(pagedState.frame, 1); + assert.equal(pagedState.canonicalPlayback, canonical); + assert.deepEqual(canonical.shapeTransforms, shapeTransforms); + assert.deepEqual([...canonical.shapeVisibility], shapeVisibility); + assert.deepEqual(canonical.leafTransforms, leafTransforms); + assert.deepEqual(mounted.byId.get("synthetic/leaf").classes, classes); + assert.deepEqual(fake.writes, []); + assert.deepEqual(pagedStatePeaks(pagedState), peaksBeforeCommitRejection); + + limits.maxAggregateDecodedBytes = DEFAULT_LIMITS.maxAggregateDecodedBytes; + assert.throws(() => pagedState.commit(staged), errorCode("INVALID_PLAYBACK_PUBLICATION")); + assert.equal(pagedState.commit(pagedState.stage(4)), 4); + pagedState.destroy(); +}); + +test("failed paged seek keeps its source frame retryable until commit succeeds", async () => { + const built = buildDom(await syntheticPagedPlaybackChangesInput({ variants: false })); + const all = builtExternalResources(built); + const fake = fakeBrowserDocument(); + const mounted = { byId: new Map(built.document.tree.nodes.map((node) => { + const element = new FakeElement(fake.document, node.name); + Object.assign(element.style, node.styles ?? {}); + for (const className of node.classes) element.classList.add(className); + return [node.id, element]; + })) }; + const limits = { ...DEFAULT_LIMITS }; + const pagedState = createPolycssPagedState(built.document, mounted, limits, async (record) => all.get(record.id)); + await pagedState.prepareInitial(); + await pagedState.ensureFrame(4); + let failCommit = false; + const playback = createPolycssPlayback(materializePolycssState(built.document.state), built.document.bindings, mounted, { + publishAppearance() {}, + pagedState, + assertPagedFrameReady: (frame) => pagedState.assertFrameReady(frame), + compositorTiming: { + before(kind) { + if (kind === "seek" && failCommit) { + limits.maxAggregateDecodedBytes = 1; + failCommit = false; + } + }, + after() {}, + }, + }); + playback.publishInitial(); + const leaf = mounted.byId.get("synthetic/leaf"); + const initialTransform = leaf.style.transform; + fake.writes.splice(0); + failCommit = true; + + assert.throws(() => playback.seek(4), errorCode("STATE_PAGE_RESIDENCY_LIMIT")); + assert.equal(playback.sourceFrame, 1); + assert.equal(pagedState.frame, 1); + assert.equal(leaf.style.transform, initialTransform); + assert.deepEqual(fake.writes, []); + + limits.maxAggregateDecodedBytes = DEFAULT_LIMITS.maxAggregateDecodedBytes; + assert.equal(playback.seek(4), 4); + assert.equal(playback.sourceFrame, 4); + assert.equal(pagedState.frame, 4); + assert.equal(leaf.style.transform, pagedState.canonicalPlayback.leafTransforms[0]); pagedState.destroy(); }); +test("nonuniform runtime playback pages reject a larger next live row before canonical mutation", async () => { + const shapeCount = 2_048; + const identity = "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,1,0,0,1)"; + const components = new Array(16).fill("123456.789063"); + components[3] = "0"; + components[7] = "0"; + components[11] = "0"; + components[15] = "1"; + const largeTransform = `matrix3d(${components.join(",")})`; + const shapeIndices = new Array(shapeCount).fill(1); + const shapeTargets = Array.from({ length: shapeCount }, (_, index) => index); + const visibilityBits = Buffer.from(new Uint8Array(shapeCount / 8).fill(0xff)).toString("base64"); + const page = (frame, transform, changed) => { + const changeCount = changed ? shapeCount : 0; + const payload = { + version: 0, + codec: "polycss-paged-playback-page@0", + channel: "playback", + startFrame: frame, + endFrame: frame, + transforms: [null, transform], + keyframe: { + appearance: 0, + modelTransform: 0, + shapeTransformIndicesBase64: base64Integers(shapeIndices, 4), + shapeVisibilityBitsBase64: visibilityBits, + leafTransformIndicesBase64: "", + }, + sequential: { + appearanceIndicesBase64: base64Integers([0], 2), + modelTransformIndicesBase64: base64Integers([0xffffffff], 4), + shapeOffsetsBase64: base64Integers([0, changeCount], 4), + shapeTargetIndicesBase64: base64Integers(changed ? shapeTargets : [], 4), + shapeTransformIndicesBase64: base64Integers(changed ? shapeIndices : [], 4), + shapeVisibilityBase64: base64Integers(changed ? new Array(shapeCount).fill(1) : [], 1), + leafOffsetsBase64: base64Integers([0, 0], 4), + leafTargetIndicesBase64: "", + leafTransformIndicesBase64: "", + }, + }; + const bytes = encodeCanonicalJson(payload); + const transformBytes = 16 + transform.length * 2; + const materializedByteLength = transformBytes + shapeCount * 5 + changeCount * 9 + 22; + return { bytes, descriptor: { resource: `page-${frame}`, startFrame: frame, endFrame: frame, transformCount: 2, shapeChangeCount: changeCount, leafChangeCount: 0, materializedByteLength } }; + }; + const pages = [page(1, identity, false), page(2, largeTransform, true)]; + assert.notEqual(pages[0].descriptor.materializedByteLength, pages[1].descriptor.materializedByteLength); + const document = { + state: { channels: [{ id: "playback", codec: "polycss-paged-playback@0", data: { packet: { + shapeCount, + leafCount: 0, + appearances: [["default", 0, 0]], + initial: { sourceFrame: 1, appearance: 0 }, + pages: pages.map(({ descriptor }) => descriptor), + lookaheadPages: 1, + maxResidentPages: 2, + } } }] }, + bindings: { channels: [{ + id: "playback", + state: "playback", + interpreter: "polycss-paged-playback@0", + parameters: { baseSceneTransform: identity }, + targets: { model: "model", shapes: shapeTargets.map((index) => `shape-${index}`), leaves: [] }, + }] }, + resources: { resources: pages.map(({ bytes, descriptor }) => ({ id: descriptor.resource, kind: "state-page", decodedByteLength: bytes.length })) }, + }; + const bytesByResource = new Map(pages.map(({ bytes, descriptor }) => [descriptor.resource, bytes])); + const create = (limit) => { + const fake = fakeBrowserDocument(); + const model = new FakeElement(fake.document, "main"); + model.style.transform = identity; + const entries = [["model", model]]; + for (const index of shapeTargets) { + const shape = new FakeElement(fake.document, "i"); + shape.style.transform = identity; + shape.style.visibility = "visible"; + entries.push([`shape-${index}`, shape]); + } + const pagedState = createPolycssPagedState(document, { byId: new Map(entries) }, { ...DEFAULT_LIMITS, maxAggregateDecodedBytes: limit }, async (record) => bytesByResource.get(record.id)); + return { fake, pagedState }; + }; + const prepare = async (runtime) => { + await runtime.prepareInitial(); + await runtime.ensureFrame(2); + return runtime.stage(2); + }; + + const baseline = create(DEFAULT_LIMITS.maxAggregateDecodedBytes); + const baselineStage = await prepare(baseline.pagedState); + assert.equal(baselineStage.playback.kind, "range"); + const admittedPeak = baseline.pagedState.peakDocumentStateBytes; + baseline.pagedState.commit(baselineStage); + const projectedPeak = baseline.pagedState.peakDocumentStateBytes; + assert.ok(projectedPeak > admittedPeak); + baseline.pagedState.destroy(); + + const rejected = create(projectedPeak - 1); + const rejectedStage = await prepare(rejected.pagedState); + const canonical = rejected.pagedState.canonicalPlayback; + const shapeRow = canonical.shapeTransforms; + const peaksBeforeCommitRejection = pagedStatePeaks(rejected.pagedState); + rejected.fake.writes.splice(0); + assert.throws(() => rejected.pagedState.commit(rejectedStage), errorCode("STATE_PAGE_RESIDENCY_LIMIT")); + assert.equal(rejected.pagedState.frame, 1); + assert.equal(rejected.pagedState.canonicalPlayback, canonical); + assert.equal(canonical.shapeTransforms, shapeRow); + assert.equal(canonical.shapeTransforms[0], identity); + assert.deepEqual(rejected.fake.writes, []); + assert.deepEqual(pagedStatePeaks(rejected.pagedState), peaksBeforeCommitRejection); + rejected.pagedState.destroy(); +}); + test("already-aborted page requests reject even when the complete target window is resident", async () => { const built = buildDom(await syntheticPagedPlaybackInput({ variants: true })); const all = builtExternalResources(built); @@ -1195,7 +1779,7 @@ test("combined paged publication succeeds at its measured byte peak and rejects const below = create(peak - 1); await assert.rejects(exercise(below), errorCode("STATE_PAGE_RESIDENCY_LIMIT")); - assert.equal(below.peakDocumentStateBytes, peak); + assert.ok(below.peakDocumentStateBytes <= peak - 1); below.destroy(); }); diff --git a/packages/domformat/test/playback-publication.test.js b/packages/domformat/test/playback-publication.test.js index a2e91614..910c486d 100644 --- a/packages/domformat/test/playback-publication.test.js +++ b/packages/domformat/test/playback-publication.test.js @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createPolycssPlayback } from "../src/state/polycss.js"; +import { createPolycssPublicationDiagnostics } from "../src/state/paged-state.js"; function base64Integers(values, width) { const bytes = new Uint8Array(values.length * width); @@ -35,9 +36,27 @@ function target(id, writes) { return element; } +function failNextStyleWrite(element, writes, failedProperty = "backgroundPositionY") { + const style = { ...element.style }; + let armed = true; + element.style = new Proxy(style, { + set(styles, property, value) { + const outcome = armed && property === failedProperty ? "throw" : "set"; + writes.push([element.id, String(property), String(value), outcome]); + if (outcome === "throw") { + armed = false; + throw new Error(`injected ${String(property)} failure`); + } + styles[property] = value; + return true; + }, + }); +} + function createFixture(options = {}) { const writes = []; const model = target("model", writes); + const shapes = Array.from({ length: options.shapeCount ?? 0 }, (_, index) => target(`shape:${index}`, writes)); const leaves = [target("leaf:0", writes), target("leaf:1", writes)]; const frameCount = 4; const visibilityOffsets = options.visibilityOffsets ?? [0, 1, 1, 1, 2]; @@ -53,9 +72,9 @@ function createFixture(options = {}) { const materialized = { playback: { kind: "inline", - shapeCount: 0, + shapeCount: shapes.length, leafCount: 2, - appearances: [["default", 1, 0]], + appearances: options.appearances ?? [["default", 1, 0]], timeline: { introTicks: 0, loopTicks: options.timeline?.length ?? frameCount, @@ -68,7 +87,7 @@ function createFixture(options = {}) { sourceFrame: 1, appearance: 0, modelTransform: 0, - shapes: [], + shapes: options.initialShapes ?? [], leaves: [0, 1, 1, 4], }, frameRows: options.frameRows ?? [ @@ -77,9 +96,9 @@ function createFixture(options = {}) { [3, 0, -1, 0, 0, 2, 1], [4, 0, -1, 0, 0, 3, 0], ], - shapeChanges: [], + shapeChanges: options.shapeChanges ?? [], leafChanges: options.leafChanges ?? [0, 2, 1, 5, 0, 3], - transforms: [ + transforms: options.transforms ?? [ "", "leaf-0-frame-1", "leaf-0-frame-2", @@ -107,19 +126,36 @@ function createFixture(options = {}) { faceIndicesBase64: base64Integers(options.lightingFaces ?? [], 2), stateIndicesBase64: base64Integers(options.lightingStates ?? [], 2), }, - nonInteractiveJumps: [], + nonInteractiveJumps: options.lightingJumps ?? [], }, visibilityCulling: { initialFrame: 1, initialVisibleBitsBase64: Buffer.from([initialVisibleBits]).toString("base64"), sequential: { offsetsBase64: base64Integers(visibilityOffsets, 4), - faceIndicesBase64: base64Integers([0, 0], 2), + faceIndicesBase64: base64Integers(options.visibilityFaces ?? [0, 0], 2), }, - nonInteractiveJumps: [], + nonInteractiveJumps: options.visibilityJumps ?? [], }, }, }; + if (options.pagedPlayback) { + materialized.playback = { + kind: "paged", + shapeCount: shapes.length, + leafCount: 2, + appearances: options.appearances ?? [["default", 1, 0]], + timeline: { + introTicks: 0, + loopTicks: options.timeline?.length ?? frameCount, + frames: options.timeline ?? [1, 2, 3, 4], + }, + profileTimelines: options.profileTimelines, + initialBankId: options.initialBankId, + banks: options.banks, + initial: { sourceFrame: 1, appearance: 0 }, + }; + } if (options.variants) { leaves[0].classes.push("material-a"); materialized.variants = { @@ -139,10 +175,12 @@ function createFixture(options = {}) { profiles: [ { id: "mobile", - transformIndices: Uint16Array.of(0, 0), + transformIndices: options.profileTransformIndices ?? Uint16Array.of(0, 0), visible: Uint8Array.of(0, 1), visibilityOffsets: options.dynamicViewportProfiles ? Uint32Array.of(0, 1, 2, 2, 2) : null, - visibilityLeaves: options.dynamicViewportProfiles ? Uint16Array.of(0, 0) : null, + visibilityLeaves: options.dynamicViewportProfiles + ? options.profileVisibilityLeaves ?? Uint16Array.of(0, 0) + : null, responsiveAffine: null, }, { @@ -162,8 +200,8 @@ function createFixture(options = {}) { } const playbackBinding = { id: "playback", - interpreter: "polycss-playback@0", - targets: { model: "model", shapes: [], leaves: leaves.map((leaf) => leaf.id) }, + interpreter: options.pagedPlayback ? "polycss-paged-playback@0" : "polycss-playback@0", + targets: { model: "model", shapes: shapes.map((shape) => shape.id), leaves: leaves.map((leaf) => leaf.id) }, parameters: { baseSceneTransform: "base-scene", frameCount, tickRateHz: 30 }, }; const bindings = { @@ -173,15 +211,172 @@ function createFixture(options = {}) { ], }; if (options.variants) bindings.channels.push({ id: "variants", interpreter: "polycss-variants@0", targets: { nodes: [leaves[0].id] } }); + if (options.pagedState?.hasVariants) bindings.channels.push({ id: "paged-variants", interpreter: "polycss-paged-variants@0", targets: { effectNodes: [], nodes: [] } }); if (options.viewportProfiles) bindings.channels.push({ id: "viewport-profiles", interpreter: "polycss-viewport-profiles@0", targets: { leaves: leaves.map((leaf) => leaf.id) } }); - const mounted = { byId: new Map([[model.id, model], ...leaves.map((leaf) => [leaf.id, leaf])]) }; - const playback = createPolycssPlayback(materialized, bindings, mounted, { publishAppearance() {} }); + const mounted = { byId: new Map([[model.id, model], ...shapes.map((shape) => [shape.id, shape]), ...leaves.map((leaf) => [leaf.id, leaf])]) }; + const playback = createPolycssPlayback(materialized, bindings, mounted, { publishAppearance: options.publishAppearance ?? (() => {}), diagnostics: options.diagnostics, pagedState: options.pagedState }); playback.publishInitial(); if (options.viewportProfiles) playback.applyViewportProfile(320, 240, "mobile"); writes.splice(0); - return { leaves, playback, writes }; + return { leaves, model, playback, shapes, writes }; +} + +function pagedStateFixture({ hasPlayback, hasVariants, onCommit }) { + const canonicalPlayback = hasPlayback ? { + frame: 1, + appearance: 0, + modelTransform: "", + shapeTransforms: [], + shapeVisibility: new Uint8Array(0), + leafTransforms: ["leaf-0-frame-1", "leaf-1-frame-1"], + } : null; + const playbackStage = (frame) => ({ + frame, + kind: "materialized", + appearance: 0, + shapeTargets: new Uint32Array(0), + shapeTransforms: [], + shapeVisibility: new Uint8Array(0), + leafTargets: frame === 2 ? Uint32Array.of(0, 1) : frame === 3 ? Uint32Array.of(0) : new Uint32Array(0), + leafTransforms: frame === 2 ? ["leaf-0-frame-2", "leaf-1-frame-2"] : frame === 3 ? ["leaf-0-frame-3"] : [], + }); + return { + hasPlayback, + hasVariants, + canonicalPlayback, + stage(frame, includePlayback = true) { + return { + frame, + playback: hasPlayback && includePlayback ? playbackStage(frame) : null, + variants: hasVariants ? { frame, kind: "complete", row: Uint16Array.of(frame & 1) } : null, + }; + }, + commit(stage, publishVariants = true) { + onCommit?.(stage, publishVariants); + if (stage.playback) { + canonicalPlayback.frame = stage.frame; + for (let index = 0; index < stage.playback.leafTargets.length; index += 1) canonicalPlayback.leafTransforms[stage.playback.leafTargets[index]] = stage.playback.leafTransforms[index]; + } + return stage.frame; + }, + publishVariants(frame) { return frame; }, + }; } +test("sequential surface publication visits only scheduled lighting and visibility targets", () => { + const diagnostics = createPolycssPublicationDiagnostics(); + const { playback } = createFixture({ + diagnostics, + initialVisibleBits: 3, + visibilityOffsets: [0, 0, 1, 1, 2], + lightingOffsets: [0, 0, 1, 1, 1], + lightingFaces: [0], + lightingStates: [1], + surfaceFaces: [ + { stateOffset: 0, stateCount: 2 }, + { stateOffset: 2, stateCount: 1 }, + ], + surfaceSourceFrames: [0, 1, 0], + surfacePositions: ["0px", "-16px", "0px"], + }); + for (const key of Object.keys(diagnostics)) diagnostics[key] = 0; + const OriginalMap = globalThis.Map; + const OriginalSet = globalThis.Set; + globalThis.Map = class extends OriginalMap { constructor() { throw new Error("sequential surface publication allocated a Map"); } }; + globalThis.Set = class extends OriginalSet { constructor() { throw new Error("sequential surface publication allocated a Set"); } }; + try { + assert.equal(playback.applySurfaceFrame(2), 2); + } finally { + globalThis.Map = OriginalMap; + globalThis.Set = OriginalSet; + } + assert.equal(diagnostics.surfaceLightingTargetVisits, 1); + assert.equal(diagnostics.surfaceVisibilityTargetVisits, 1); + assert.equal(diagnostics.surfaceFullReconstructions, 0); +}); + +test("sequential surface publication supports a forced uint16-maximum leaf outside the scheduled range", () => { + const leafCount = 0x10000; + const diagnostics = createPolycssPublicationDiagnostics(); + const writes = []; + const classList = { add() {}, remove() {} }; + const model = { id: "model", style: {}, classList }; + const leaves = Array.from({ length: leafCount }, (_, index) => ({ id: `leaf:${index}`, style: {}, classList })); + leaves[0xffff].style = new Proxy({}, { + set(styles, property, value) { + styles[property] = value; + writes.push([String(property), String(value)]); + return true; + }, + }); + const initialLeaves = new Uint32Array(leafCount * 2); + const surfaceFaces = new Array(leafCount); + for (let index = 0; index < leafCount; index += 1) { + initialLeaves[index * 2] = index; + initialLeaves[index * 2 + 1] = 1; + surfaceFaces[index] = { stateOffset: index, stateCount: index === 0xffff ? 2 : 1 }; + } + const sourceFrames = new Uint16Array(leafCount + 1); + sourceFrames[0x10000] = 1; + const offsets = base64Integers([0, 0, 0], 4); + const materialized = { + playback: { + kind: "inline", + shapeCount: 0, + leafCount, + appearances: [["default", 1, 0]], + timeline: { introTicks: 0, loopTicks: 2, frames: [1, 2] }, + initial: { sourceFrame: 1, appearance: 0, modelTransform: 0, shapes: [], leaves: initialLeaves }, + frameRows: [[1, 0, -1, 0, 0, 0, 0], [2, 0, -1, 0, 0, 0, 0]], + shapeChanges: [], + leafChanges: [], + transforms: ["", "leaf-transform"], + }, + lighting: { + surface: { + faces: surfaceFaces, + statePacking: { + stateCount: leafCount + 1, + sourceFramesBase64: base64Integers(sourceFrames, 2), + positionProperty: "backgroundPositionY", + positions: [...new Array(leafCount).fill("0px"), "-16px"], + }, + }, + transitions: { + initialFrame: 1, + sequential: { offsetsBase64: offsets, faceIndicesBase64: "", stateIndicesBase64: "" }, + nonInteractiveJumps: [], + }, + visibilityCulling: { + initialFrame: 1, + initialVisibleBitsBase64: Buffer.alloc(leafCount / 8).toString("base64"), + sequential: { offsetsBase64: offsets, faceIndicesBase64: "" }, + nonInteractiveJumps: [], + }, + }, + }; + const leafIds = leaves.map((leaf) => leaf.id); + const bindings = { + channels: [ + { id: "playback", interpreter: "polycss-playback@0", targets: { model: model.id, shapes: [], leaves: leafIds }, parameters: { baseSceneTransform: "", frameCount: 2, tickRateHz: 30 } }, + { id: "surface", interpreter: "polycss-surface@0", targets: { leaves: leafIds } }, + ], + }; + const mounted = { byId: new Map([[model.id, model], ...leaves.map((leaf) => [leaf.id, leaf])]) }; + const playback = createPolycssPlayback(materialized, bindings, mounted, { publishAppearance() {}, diagnostics }); + playback.publishInitial(); + playback.forceVisible([0xffff]); + for (const key of Object.keys(diagnostics)) diagnostics[key] = 0; + writes.splice(0); + + assert.equal(playback.advance(), 2); + assert.deepEqual(writes, [["backgroundPositionY", "-16px"]]); + assert.equal(leaves[0xffff].style.visibility, "visible"); + assert.equal(diagnostics.surfaceLightingTargetVisits, 1); + assert.equal(diagnostics.surfaceVisibilityTargetVisits, 0); + assert.equal(diagnostics.surfaceFullReconstructions, 0); +}); + test("profile timeline selection uses prepared overrides with canonical baseline fallback", () => { const { playback } = createFixture({ profileTimelines: [ @@ -235,6 +430,170 @@ test("host-selected prepared banks restart canonical timelines without replacing assert.throws(() => playback.selectBank("missing"), { code: "UNKNOWN_PREPARED_BANK" }); }); +test("paged seek commits before engine-local playback and preserves the old frame on commit failure", () => { + let playback; + let rejectCommit = true; + const observations = []; + const pagedState = pagedStateFixture({ + hasPlayback: true, + hasVariants: false, + onCommit(stage) { + observations.push({ frame: stage.frame, sourceFrame: playback.sourceFrame }); + if (rejectCommit) throw new Error("injected page commit failure"); + }, + }); + const fixture = createFixture({ pagedPlayback: true, pagedState, initialVisibleBits: 3, visibilityOffsets: [0, 0, 0, 0, 0] }); + playback = fixture.playback; + + assert.throws(() => playback.seek(2), /injected page commit failure/u); + assert.deepEqual(observations, [{ frame: 2, sourceFrame: 1 }]); + assert.equal(playback.sourceFrame, 1); + fixture.writes.splice(0); + playback.restoreInteraction([], [0]); + assert.equal(fixture.leaves[0].style.transform, "leaf-0-frame-1"); + + rejectCommit = false; + assert.equal(playback.seek(2), 2); + assert.equal(fixture.leaves[0].style.transform, "leaf-0-frame-2"); +}); + +test("failed paged advance and collapsed advance retain their prior tick and frame", () => { + for (const [label, invoke, expectedFrame] of [ + ["advance", (playback) => playback.advance(), 2], + ["collapsed advance", (playback) => playback.advanceCollapsed(2), 3], + ]) { + let rejectCommit = true; + const pagedState = pagedStateFixture({ + hasPlayback: true, + hasVariants: false, + onCommit() { + if (rejectCommit) throw new Error(`injected ${label} commit failure`); + }, + }); + const { leaves, playback } = createFixture({ pagedPlayback: true, pagedState, initialVisibleBits: 3, visibilityOffsets: [0, 0, 0, 0, 0] }); + + assert.throws(() => invoke(playback), new RegExp(`injected ${label} commit failure`, "u")); + assert.equal(playback.sourceFrame, 1); + assert.equal(playback.tick, 0); + playback.restoreInteraction([], [0]); + assert.equal(leaves[0].style.transform, "leaf-0-frame-1"); + + rejectCommit = false; + assert.equal(invoke(playback), expectedFrame); + assert.equal(playback.sourceFrame, expectedFrame); + assert.equal(playback.tick, label === "advance" ? 1 : 2); + } +}); + +test("inline seek commits paged variants before local playback and stages profile visibility once afterward", () => { + let playback; + let rejectCommit = true; + let profileTargetReads = 0; + const profileVisibilityLeaves = new Proxy([0, 0], { + get(values, property) { + if (property === "1") profileTargetReads += 1; + return Reflect.get(values, property); + }, + }); + const observations = []; + const pagedState = pagedStateFixture({ + hasPlayback: false, + hasVariants: true, + onCommit(stage) { + observations.push({ frame: stage.frame, sourceFrame: playback.sourceFrame, profileTargetReads }); + if (rejectCommit) throw new Error("injected variant commit failure"); + }, + }); + const fixture = createFixture({ + pagedState, + viewportProfiles: true, + dynamicViewportProfiles: true, + profileVisibilityLeaves, + initialVisibleBits: 3, + visibilityOffsets: [0, 0, 0, 0, 0], + }); + playback = fixture.playback; + + assert.throws(() => playback.seek(2), /injected variant commit failure/u); + assert.deepEqual(observations, [{ frame: 2, sourceFrame: 1, profileTargetReads: 0 }]); + assert.equal(playback.sourceFrame, 1); + assert.equal(profileTargetReads, 0); + + rejectCommit = false; + assert.equal(playback.seek(2), 2); + assert.equal(profileTargetReads, 1); + assert.equal(fixture.leaves[0].style.transform, "profile-transform"); + assert.equal(fixture.leaves[0].style.visibility, "visible"); +}); + +test("failed paged bank entry leaves the prior bank, timeline, frame, tick, and transforms coherent", () => { + const banks = [ + { id: "alpha", entryFrame: 1, timeline: { introTicks: 0, loopTicks: 2, frames: [1, 2] } }, + { id: "beta", entryFrame: 3, timeline: { introTicks: 0, loopTicks: 1, frames: [3] } }, + ]; + let rejectCommit = true; + const pagedState = pagedStateFixture({ + hasPlayback: true, + hasVariants: false, + onCommit() { + if (rejectCommit) throw new Error("injected bank commit failure"); + }, + }); + const { leaves, playback } = createFixture({ pagedPlayback: true, pagedState, initialBankId: "alpha", banks, timeline: [1, 2], initialVisibleBits: 3, visibilityOffsets: [0, 0, 0, 0, 0] }); + + assert.throws(() => playback.selectBank("beta"), /injected bank commit failure/u); + assert.equal(playback.bankId, "alpha"); + assert.equal(playback.sourceFrame, 1); + assert.equal(playback.tick, 0); + playback.restoreInteraction([], [0]); + assert.equal(leaves[0].style.transform, "leaf-0-frame-1"); + + rejectCommit = false; + assert.equal(playback.advance(), 2); + assert.equal(playback.bankId, "alpha"); + assert.equal(leaves[0].style.transform, "leaf-0-frame-2"); +}); + +test("post-commit bank surface failure retains the committed bank and recovers its entry frame", () => { + const banks = [ + { id: "alpha", entryFrame: 1, timeline: { introTicks: 0, loopTicks: 1, frames: [1] } }, + { id: "beta", entryFrame: 3, timeline: { introTicks: 0, loopTicks: 1, frames: [3] } }, + ]; + const pagedState = pagedStateFixture({ hasPlayback: true, hasVariants: false }); + const { leaves, playback, writes } = createFixture({ + pagedPlayback: true, + pagedState, + initialBankId: "alpha", + banks, + timeline: [1], + initialVisibleBits: 3, + visibilityOffsets: [0, 0, 0, 0, 0], + lightingOffsets: [0, 0, 0, 1, 1], + lightingFaces: [0], + lightingStates: [1], + surfaceFaces: [ + { stateOffset: 0, stateCount: 2 }, + { stateOffset: 2, stateCount: 1 }, + ], + surfaceSourceFrames: [0, 2, 0], + surfacePositions: ["0px", "-16px", "0px"], + }); + failNextStyleWrite(leaves[0], writes); + writes.splice(0); + + assert.throws(() => playback.selectBank("beta"), /injected backgroundPositionY failure/u); + assert.equal(playback.bankId, "beta"); + assert.equal(playback.sourceFrame, 3); + assert.equal(playback.tick, 0); + assert.equal(leaves[0].style.transform, "leaf-0-frame-3"); + + assert.equal(playback.restart(), 3); + assert.equal(playback.bankId, "beta"); + assert.equal(playback.sourceFrame, 3); + assert.equal(leaves[0].style.backgroundPositionY, "-16px"); + assert.equal(leaves[0].style.visibility, "visible"); +}); + test("viewport profile publication composes transforms and visibility with the reveal barrier", () => { const { leaves, playback, writes } = createFixture({ viewportProfiles: true, @@ -344,6 +703,136 @@ test("playback defers hidden transforms and flushes the latest value before reve assert.ok(address >= 0 && reveal > address); }); + test("frame publication orders classes, transforms, atlas addresses, and visibility", () => { + const { playback, writes } = createFixture({ + variants: true, + initialVisibleBits: 2, + visibilityOffsets: [0, 0, 1, 1, 1], + lightingOffsets: [0, 0, 1, 1, 1], + lightingFaces: [0], + lightingStates: [1], + surfaceFaces: [ + { stateOffset: 0, stateCount: 2 }, + { stateOffset: 2, stateCount: 1 }, + ], + surfaceSourceFrames: [0, 1, 0], + surfacePositions: ["0px", "-16px", "0px"], + }); + + assert.equal(playback.advance(), 2); + assert.deepEqual(writes.filter(([id]) => id === "leaf:0"), [ + ["leaf:0", "class:remove", "material-a"], + ["leaf:0", "class:add", "material-b"], + ["leaf:0", "transform", "leaf-0-frame-2"], + ["leaf:0", "backgroundPositionY", "-16px"], + ["leaf:0", "visibility", "visible"], + ]); + }); + + test("same-frame seek retries appearance and every skipped transform after a paint failure", () => { + let rejectAppearance = true; + const appearanceAttempts = []; + const fixture = createFixture({ + appearances: [["frame-1", 1, 0], ["frame-2", 1, 0]], + frameRows: [ + [1, 0, -1, 0, 0, 0, 0], + [2, 1, -1, 0, 0, 0, 2], + [3, 1, -1, 0, 0, 2, 1], + [4, 1, -1, 0, 0, 3, 0], + ], + initialVisibleBits: 3, + visibilityOffsets: [0, 0, 0, 0, 0], + viewportProfiles: true, + dynamicViewportProfiles: true, + profileTransformIndices: Uint16Array.of(0xffff, 0xffff), + publishAppearance(appearance) { + appearanceAttempts.push(appearance[0]); + if (appearance[0] === "frame-2" && rejectAppearance) { + rejectAppearance = false; + throw new Error("injected appearance failure"); + } + }, + }); + const identities = [...fixture.leaves]; + + assert.throws(() => fixture.playback.seek(2), /injected appearance failure/u); + assert.equal(fixture.playback.sourceFrame, 2); + assert.equal(fixture.leaves[0].style.transform, "leaf-0-frame-1"); + assert.equal(fixture.leaves[1].style.transform, "leaf-1-frame-1"); + assert.equal(fixture.leaves[0].style.visibility, "hidden"); + + assert.equal(fixture.playback.seek(2), 2); + assert.deepEqual(appearanceAttempts, ["frame-1", "frame-2", "frame-2"]); + assert.equal(fixture.leaves[0].style.transform, "leaf-0-frame-2"); + assert.equal(fixture.leaves[1].style.transform, "leaf-1-frame-2"); + assert.equal(fixture.leaves[0].style.visibility, "visible"); + assert.deepEqual(fixture.leaves, identities); + }); + + test("same-frame seek retries the complete declared transform set after a transform write fails", () => { + const fixture = createFixture(); + const identities = [...fixture.leaves]; + failNextStyleWrite(fixture.leaves[0], fixture.writes, "transform"); + + assert.throws(() => fixture.playback.seek(2), /injected transform failure/u); + assert.equal(fixture.playback.sourceFrame, 2); + assert.equal(fixture.leaves[0].style.transform, "leaf-0-frame-1"); + assert.equal(fixture.leaves[1].style.transform, "leaf-1-frame-1"); + + fixture.writes.splice(0); + assert.equal(fixture.playback.seek(2), 2); + assert.equal(fixture.leaves[0].style.transform, "leaf-0-frame-2"); + assert.equal(fixture.leaves[1].style.transform, "leaf-1-frame-2"); + assert.deepEqual(fixture.writes.filter(([, property]) => property === "transform"), [ + ["leaf:0", "transform", "leaf-0-frame-2", "set"], + ["leaf:1", "transform", "leaf-1-frame-2"], + ]); + assert.deepEqual(fixture.leaves, identities); + }); + + test("same-frame seek retries a model transform after its style write fails", () => { + const fixture = createFixture({ + frameRows: [ + [1, 0, -1, 0, 0, 0, 0], + [2, 0, 6, 0, 0, 0, 0], + [3, 0, -1, 0, 0, 0, 0], + [4, 0, -1, 0, 0, 0, 0], + ], + transforms: [ + "", + "leaf-0-frame-1", + "leaf-0-frame-2", + "leaf-0-frame-3", + "leaf-1-frame-1", + "leaf-1-frame-2", + "model-frame-2", + ], + }); + failNextStyleWrite(fixture.model, fixture.writes, "transform"); + + assert.throws(() => fixture.playback.seek(2), /injected transform failure/u); + assert.equal(fixture.playback.sourceFrame, 2); + assert.equal(fixture.model.style.transform, "base-scene"); + + assert.equal(fixture.playback.seek(2), 2); + assert.equal(fixture.model.style.transform, "base-scene model-frame-2"); + }); + + test("the next advance repairs skipped transforms from a failed committed frame", () => { + const fixture = createFixture({ initialVisibleBits: 3, visibilityOffsets: [0, 0, 0, 0, 0] }); + failNextStyleWrite(fixture.leaves[0], fixture.writes, "transform"); + + assert.throws(() => fixture.playback.advance(), /injected transform failure/u); + assert.equal(fixture.playback.sourceFrame, 2); + assert.equal(fixture.playback.tick, 1); + + assert.equal(fixture.playback.advance(), 3); + assert.equal(fixture.playback.sourceFrame, 3); + assert.equal(fixture.playback.tick, 2); + assert.equal(fixture.leaves[0].style.transform, "leaf-0-frame-3"); + assert.equal(fixture.leaves[1].style.transform, "leaf-1-frame-2"); + }); + test("same-frame seek restores canonical variant and surface state", () => { const { leaves, playback, writes } = createFixture({ variants: true, @@ -373,6 +862,225 @@ test("playback defers hidden transforms and flushes the latest value before reve ]); }); + test("surface publication retry does not invert an already-staged visibility transition", () => { + const { leaves, playback, writes } = createFixture({ + initialVisibleBits: 2, + visibilityOffsets: [0, 0, 1, 1, 1], + lightingOffsets: [0, 0, 1, 1, 1], + lightingFaces: [0], + lightingStates: [1], + surfaceFaces: [ + { stateOffset: 0, stateCount: 2 }, + { stateOffset: 2, stateCount: 1 }, + ], + surfaceSourceFrames: [0, 1, 0], + surfacePositions: ["0px", "-16px", "0px"], + }); + const style = { ...leaves[0].style }; + let failAddressWrite = true; + leaves[0].style = new Proxy(style, { + set(styles, property, value) { + const outcome = failAddressWrite && property === "backgroundPositionY" ? "throw" : "set"; + writes.push(["leaf:0", String(property), String(value), outcome]); + if (outcome === "throw") { + failAddressWrite = false; + throw new Error("injected address failure"); + } + styles[property] = value; + return true; + }, + }); + writes.splice(0); + + assert.throws(() => playback.applySurfaceFrame(2), /injected address failure/u); + assert.deepEqual(writes, [["leaf:0", "backgroundPositionY", "-16px", "throw"]]); + writes.splice(0); + + assert.equal(playback.applySurfaceFrame(2), 2); + assert.deepEqual(writes, [ + ["leaf:0", "backgroundPositionY", "-16px", "set"], + ["leaf:0", "visibility", "visible", "set"], + ]); + assert.equal(leaves[0].style.visibility, "visible"); + }); + + test("failed advance keeps the committed frame and the next advance repairs partial surface DOM", () => { + const diagnostics = createPolycssPublicationDiagnostics(); + const { leaves, playback, writes } = createFixture({ + diagnostics, + initialVisibleBits: 2, + visibilityOffsets: [0, 0, 1, 2, 2], + lightingOffsets: [0, 0, 1, 2, 2], + lightingFaces: [0, 0], + lightingStates: [1, 2], + surfaceFaces: [ + { stateOffset: 0, stateCount: 3 }, + { stateOffset: 3, stateCount: 1 }, + ], + surfaceSourceFrames: [0, 1, 2, 0], + surfacePositions: ["0px", "-16px", "-32px", "0px"], + }); + failNextStyleWrite(leaves[0], writes); + writes.splice(0); + diagnostics.surfaceFullReconstructions = 0; + diagnostics.surfaceLightingTargetVisits = 0; + diagnostics.surfaceVisibilityTargetVisits = 0; + + assert.throws(() => playback.advance(), /injected backgroundPositionY failure/u); + assert.equal(playback.sourceFrame, 2); + assert.equal(playback.tick, 1); + + assert.equal(playback.advance(), 3); + assert.equal(playback.sourceFrame, 3); + assert.equal(playback.tick, 2); + assert.equal(leaves[0].style.visibility, "hidden"); + playback.forceVisible([0]); + assert.equal(leaves[0].style.backgroundPositionY, "-32px"); + assert.equal(leaves[0].style.visibility, "visible"); + assert.equal(diagnostics.surfaceFullReconstructions, 1); + assert.equal(diagnostics.surfaceLightingTargetVisits, 3); + assert.equal(diagnostics.surfaceVisibilityTargetVisits, 2); + }); + + test("a held timeline frame repairs pending surface publication before returning", () => { + const { leaves, playback, writes } = createFixture({ + timeline: [1, 2, 2, 3], + viewportProfiles: true, + dynamicViewportProfiles: true, + profileTransformIndices: Uint16Array.of(0xffff, 0xffff), + initialVisibleBits: 3, + visibilityOffsets: [0, 0, 0, 0, 0], + lightingOffsets: [0, 0, 1, 1, 1], + lightingFaces: [0], + lightingStates: [1], + surfaceFaces: [ + { stateOffset: 0, stateCount: 2 }, + { stateOffset: 2, stateCount: 1 }, + ], + surfaceSourceFrames: [0, 1, 0], + surfacePositions: ["0px", "-16px", "0px"], + }); + failNextStyleWrite(leaves[0], writes); + writes.splice(0); + + assert.throws(() => playback.advance(), /injected backgroundPositionY failure/u); + assert.equal(playback.sourceFrame, 2); + assert.equal(playback.tick, 1); + assert.deepEqual(writes.filter(([id]) => id === "leaf:0"), [ + ["leaf:0", "transform", "leaf-0-frame-2", "set"], + ["leaf:0", "backgroundPositionY", "-16px", "throw"], + ]); + + writes.splice(0); + assert.equal(playback.advance(), 2); + assert.equal(playback.sourceFrame, 2); + assert.equal(playback.tick, 2); + assert.deepEqual(writes, [ + ["leaf:0", "backgroundPositionY", "-16px", "set"], + ["leaf:0", "visibility", "visible", "set"], + ]); + assert.equal(leaves[0].style.backgroundPositionY, "-16px"); + assert.equal(leaves[0].style.visibility, "visible"); + }); + + for (const [label, recover] of [ + ["same-frame seek", (playback) => playback.seek(2)], + ["later-frame advance", (playback) => playback.advance()], + ]) test(`surface failure preserves deferred shape visibility for ${label} recovery`, () => { + const transforms = [ + "", + "leaf-0-frame-1", + "leaf-0-frame-2", + "leaf-0-frame-3", + "leaf-1-frame-1", + "leaf-1-frame-2", + "shape-frame-1", + "shape-frame-2", + ]; + const { leaves, playback, shapes, writes } = createFixture({ + shapeCount: 1, + initialShapes: [0, 6, 0], + shapeChanges: [0, 7, 1], + transforms, + frameRows: [ + [1, 0, -1, 0, 0, 0, 0], + [2, 0, -1, 0, 1, 0, 2], + [3, 0, -1, 1, 0, 2, 1], + [4, 0, -1, 1, 0, 3, 0], + ], + initialVisibleBits: 3, + visibilityOffsets: [0, 0, 0, 0, 0], + lightingOffsets: [0, 0, 1, 1, 1], + lightingFaces: [0], + lightingStates: [1], + surfaceFaces: [ + { stateOffset: 0, stateCount: 2 }, + { stateOffset: 2, stateCount: 1 }, + ], + surfaceSourceFrames: [0, 1, 0], + surfacePositions: ["0px", "-16px", "0px"], + }); + failNextStyleWrite(leaves[0], writes); + writes.splice(0); + + assert.throws(() => playback.advance(), /injected backgroundPositionY failure/u); + assert.equal(playback.sourceFrame, 2); + assert.equal(shapes[0].style.transform, "shape-frame-2"); + assert.equal(shapes[0].style.visibility, "hidden"); + + writes.splice(0); + const recoveredFrame = recover(playback); + assert.equal(recoveredFrame, label === "same-frame seek" ? 2 : 3); + assert.equal(shapes[0].style.visibility, "visible"); + assert.deepEqual(writes.filter(([id, property]) => (id === "leaf:0" && property === "backgroundPositionY") || (id === "shape:0" && property === "visibility")), [ + ["leaf:0", "backgroundPositionY", "-16px", "set"], + ["shape:0", "visibility", "visible"], + ]); + }); + + for (const [label, jumps] of [ + ["multi-segment fallback", {}], + ["declared jump", { + lightingJumps: [{ fromFrame: 1, toFrame: 4, faceIndicesBase64: base64Integers([0], 2), stateIndicesBase64: base64Integers([3], 2) }], + visibilityJumps: [{ fromFrame: 1, toFrame: 4, faceIndicesBase64: base64Integers([0], 2) }], + }], + ]) test(`failed ${label} surface seek can recover directly to another frame and seek back`, () => { + const { leaves, playback, writes } = createFixture({ + initialVisibleBits: 2, + visibilityOffsets: [0, 1, 2, 3, 4], + visibilityFaces: [0, 0, 0, 0], + lightingOffsets: [0, 1, 2, 3, 4], + lightingFaces: [0, 0, 0, 0], + lightingStates: [0, 1, 2, 3], + surfaceFaces: [ + { stateOffset: 0, stateCount: 4 }, + { stateOffset: 4, stateCount: 1 }, + ], + surfaceSourceFrames: [0, 1, 2, 3, 0], + surfacePositions: ["0px", "-16px", "-32px", "-48px", "0px"], + ...jumps, + }); + failNextStyleWrite(leaves[0], writes); + writes.splice(0); + + assert.throws(() => playback.seek(4), /injected backgroundPositionY failure/u); + assert.equal(playback.sourceFrame, 4); + + assert.equal(playback.applySurfaceFrame(3), 3); + assert.equal(leaves[0].style.visibility, "hidden"); + playback.forceVisible([0]); + assert.equal(leaves[0].style.backgroundPositionY, "-32px"); + assert.equal(leaves[0].style.visibility, "visible"); + playback.forceVisible([]); + assert.equal(leaves[0].style.visibility, "hidden"); + + assert.equal(playback.seek(1), 1); + assert.equal(leaves[0].style.visibility, "hidden"); + playback.forceVisible([0]); + assert.equal(leaves[0].style.backgroundPositionY, "0px"); + assert.equal(leaves[0].style.visibility, "visible"); + }); + test("skipped variant frames coalesce each touched target to one final class write", () => { const { leaves, playback, writes } = createFixture({ variants: true, initialVisibleBits: 3 }); const identity = leaves[0]; @@ -438,6 +1146,46 @@ test("playback defers hidden transforms and flushes the latest value before reve ]); }); + test("randomized forced-visible histories match a fresh canonical surface publication", () => { + const fixture = () => createFixture({ + initialVisibleBits: 2, + visibilityOffsets: [0, 1, 1, 2, 2], + lightingOffsets: [0, 1, 1, 3, 4], + lightingFaces: [1, 0, 1, 0], + lightingStates: [0, 2, 1, 3], + surfaceFaces: [ + { stateOffset: 0, stateCount: 4 }, + { stateOffset: 4, stateCount: 2 }, + ], + surfaceSourceFrames: [0, 1, 2, 3, 0, 2], + surfacePositions: ["0px", "-16px", "-32px", "-48px", "0px", "-16px"], + }); + const actual = fixture(); + let frame = 1; + let forced = []; + let random = 0x5eed1234; + for (let step = 0; step < 128; step += 1) { + random = (Math.imul(random, 1664525) + 1013904223) >>> 0; + if ((random & 1) === 0) { + frame = (random >>> 8) % 4 + 1; + actual.playback.applySurfaceFrame(frame); + } else { + forced = (random & 2) === 0 ? [] : [0]; + actual.playback.forceVisible(forced); + } + + const expected = fixture(); + expected.playback.forceVisible(forced); + expected.playback.applySurfaceFrame(frame); + for (let leaf = 0; leaf < actual.leaves.length; leaf += 1) { + assert.equal(actual.leaves[leaf].style.visibility, expected.leaves[leaf].style.visibility, `step ${step} leaf ${leaf} visibility`); + if (actual.leaves[leaf].style.visibility === "visible") { + assert.equal(actual.leaves[leaf].style.backgroundPositionY, expected.leaves[leaf].style.backgroundPositionY, `step ${step} leaf ${leaf} address`); + } + } + } + }); + test("forced reveal flushes a deferred address after its transform and before visibility", () => { const { playback, writes } = createFixture({ surfaceFaces: [ @@ -539,6 +1287,40 @@ test("playback defers hidden transforms and flushes the latest value before reve assert.deepEqual(writes, [["leaf:0", "visibility", "hidden"]]); }); + test("restart validates every interaction index before changing playback or DOM state", () => { + const fixture = createFixture({ initialVisibleBits: 3, visibilityOffsets: [0, 0, 0, 0, 0] }); + assert.equal(fixture.playback.advance(), 2); + fixture.writes.splice(0); + + assert.throws(() => fixture.playback.restart([], [0, 2]), (error) => error?.code === "INVALID_INTERACTION_PUBLICATION"); + assert.equal(fixture.playback.sourceFrame, 2); + assert.equal(fixture.playback.tick, 1); + assert.deepEqual(fixture.writes, []); + assert.equal(fixture.leaves[0].style.transform, "leaf-0-frame-2"); + assert.equal(fixture.leaves[1].style.transform, "leaf-1-frame-2"); + }); + + test("a post-commit restart paint failure keeps the restarted tick and remains retryable", () => { + let rejectRestartAppearance = false; + const fixture = createFixture({ + publishAppearance() { + if (rejectRestartAppearance) { + rejectRestartAppearance = false; + throw new Error("injected restart appearance failure"); + } + }, + }); + assert.equal(fixture.playback.advance(), 2); + assert.equal(fixture.playback.tick, 1); + rejectRestartAppearance = true; + + assert.throws(() => fixture.playback.restart(), /injected restart appearance failure/u); + assert.equal(fixture.playback.sourceFrame, 1); + assert.equal(fixture.playback.tick, 0); + assert.equal(fixture.playback.restart(), 1); + assert.equal(fixture.playback.tick, 0); + }); + test("forced reveal flushes prepared state and restore clears hidden dirt", () => { const forced = createFixture(); assert.equal(forced.playback.advance(), 2); @@ -559,6 +1341,136 @@ test("playback defers hidden transforms and flushes the latest value before reve assert.deepEqual(restored.writes, [["leaf:0", "visibility", "visible"]]); }); + test("advanceMany publishes its committed prefix coherently before an intermediate commit failure", () => { + let rejectFrame3 = true; + let variantLeaf = null; + let variantFrame = 1; + let variantValue = 0; + const canonicalPlayback = { + frame: 1, + appearance: 0, + modelTransform: "", + shapeTransforms: ["shape-frame-1"], + shapeVisibility: Uint8Array.of(0), + leafTransforms: ["leaf-0-frame-1", "leaf-1-frame-1"], + }; + const playbackStage = (frame) => ({ + frame, + kind: "materialized", + appearance: 0, + ...(frame === 2 ? { modelTransform: "model-frame-2" } : {}), + shapeTargets: frame === 2 ? Uint32Array.of(0) : new Uint32Array(0), + shapeTransforms: frame === 2 ? ["shape-frame-2"] : [], + shapeVisibility: frame === 2 ? Uint8Array.of(1) : new Uint8Array(0), + leafTargets: frame === 2 ? Uint32Array.of(0, 1) : new Uint32Array(0), + leafTransforms: frame === 2 ? ["leaf-0-frame-2", "leaf-1-frame-2"] : [], + }); + const publishVariants = (frame) => { + assert.equal(frame, variantFrame); + if (!variantLeaf || variantValue === 0) return frame; + variantLeaf.classList.remove("material-a"); + variantLeaf.classList.add("material-b"); + return frame; + }; + const pagedState = { + hasPlayback: true, + hasVariants: true, + canonicalPlayback, + stage(frame, includePlayback = true) { + return { frame, playback: includePlayback ? playbackStage(frame) : null, variants: { frame, kind: "complete", row: Uint16Array.of(frame === 1 ? 0 : 1) } }; + }, + commit(stage, publish = true) { + if (stage.frame === 3 && rejectFrame3) throw new Error("injected frame 3 commit failure"); + if (stage.playback) { + const next = stage.playback; + canonicalPlayback.frame = stage.frame; + if (next.modelTransform !== undefined) canonicalPlayback.modelTransform = next.modelTransform; + for (let index = 0; index < next.shapeTargets.length; index += 1) { + const targetIndex = next.shapeTargets[index]; + canonicalPlayback.shapeTransforms[targetIndex] = next.shapeTransforms[index]; + canonicalPlayback.shapeVisibility[targetIndex] = next.shapeVisibility[index]; + } + for (let index = 0; index < next.leafTargets.length; index += 1) canonicalPlayback.leafTransforms[next.leafTargets[index]] = next.leafTransforms[index]; + } + variantFrame = stage.frame; + variantValue = stage.frame === 1 ? 0 : 1; + if (publish) publishVariants(stage.frame); + return stage.frame; + }, + publishVariants, + }; + const fixture = createFixture({ + pagedPlayback: true, + pagedState, + shapeCount: 1, + viewportProfiles: true, + dynamicViewportProfiles: true, + profileTransformIndices: Uint16Array.of(0xffff, 0xffff), + initialVisibleBits: 2, + visibilityOffsets: [0, 0, 1, 1, 1], + lightingOffsets: [0, 0, 1, 1, 1], + lightingFaces: [0], + lightingStates: [1], + surfaceFaces: [ + { stateOffset: 0, stateCount: 2 }, + { stateOffset: 2, stateCount: 1 }, + ], + surfaceSourceFrames: [0, 1, 0], + surfacePositions: ["0px", "-16px", "0px"], + }); + variantLeaf = fixture.leaves[0]; + variantLeaf.classes.push("material-a"); + fixture.writes.splice(0); + + assert.throws(() => fixture.playback.advanceMany(3), /injected frame 3 commit failure/u); + assert.equal(fixture.playback.sourceFrame, 2); + assert.equal(fixture.playback.tick, 1); + assert.equal(canonicalPlayback.frame, 2); + assert.deepEqual(fixture.writes, [ + ["leaf:0", "class:remove", "material-a"], + ["leaf:0", "class:add", "material-b"], + ["model", "transform", "base-scene model-frame-2"], + ["shape:0", "transform", "shape-frame-2"], + ["leaf:1", "transform", "leaf-1-frame-2"], + ["leaf:0", "transform", "leaf-0-frame-2"], + ["leaf:0", "backgroundPositionY", "-16px"], + ["leaf:0", "visibility", "visible"], + ["shape:0", "visibility", "visible"], + ]); + + rejectFrame3 = false; + fixture.writes.splice(0); + assert.deepEqual(fixture.playback.advanceMany(2), [3, 4]); + assert.equal(fixture.playback.sourceFrame, 4); + assert.equal(fixture.playback.tick, 3); + assert.equal(fixture.leaves[0].style.transform, "leaf-0-frame-2"); + assert.equal(fixture.leaves[1].style.transform, "leaf-1-frame-2"); + assert.equal(fixture.shapes[0].style.transform, "shape-frame-2"); + assert.equal(fixture.shapes[0].style.visibility, "visible"); + assert.deepEqual(fixture.leaves[0].classes, ["material-b"]); + }); + + test("a catch-up failure plus publication recovery failure stays in the DomFormatError taxonomy", () => { + const commitFailure = new Error("injected frame 3 commit failure"); + const pagedState = pagedStateFixture({ + hasPlayback: true, + hasVariants: false, + onCommit(stage) { + if (stage.frame === 3) throw commitFailure; + }, + }); + const fixture = createFixture({ pagedPlayback: true, pagedState }); + failNextStyleWrite(fixture.leaves[1], fixture.writes, "transform"); + + assert.throws(() => fixture.playback.advanceMany(2), (error) => { + assert.equal(error?.name, "DomFormatError"); + assert.equal(error?.code, "PLAYBACK_PUBLICATION_RECOVERY_FAILED"); + assert.equal(error?.details?.publicationError, commitFailure); + assert.match(String(error?.details?.recoveryError), /injected transform failure/u); + return true; + }); + }); + test("playback catch-up advances every tick and publishes only the final paint state", () => { const sequential = createFixture(); const batched = createFixture(); diff --git a/packages/domformat/test/publication-allocation-guard.test.js b/packages/domformat/test/publication-allocation-guard.test.js new file mode 100644 index 00000000..b362bdec --- /dev/null +++ b/packages/domformat/test/publication-allocation-guard.test.js @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; +import { auditSequentialPagedPublicationSources } from "../scripts/publication-allocation-guard.js"; +import { assertSingleCycleTraceDuration, publicationFrameAdvances, publicationPageBoundariesCrossed } from "../scripts/publication-trace-window.js"; + +const pagedPath = new URL("../src/state/paged-state.ts", import.meta.url); +const polycssPath = new URL("../src/state/polycss.ts", import.meta.url); +const statePagesPath = new URL("../src/state-pages.ts", import.meta.url); +const publicationReportPath = new URL("../scripts/check-publication-performance.js", import.meta.url); +const browserPath = new URL("../src/browser.ts", import.meta.url); +const viewerPath = new URL("../viewer/viewer.js", import.meta.url); +const diagnosticViewerPath = new URL("../scripts/publication-diagnostics-viewer.js", import.meta.url); +const packagePath = new URL("../package.json", import.meta.url); +const [pagedSource, polycssSource, statePagesSource, publicationReportSource, browserSource, viewerSource, diagnosticViewerSource, packageSource] = await Promise.all([ + readFile(pagedPath, "utf8"), + readFile(polycssPath, "utf8"), + readFile(statePagesPath, "utf8"), + readFile(publicationReportPath, "utf8"), + readFile(browserPath, "utf8"), + readFile(viewerPath, "utf8"), + readFile(diagnosticViewerPath, "utf8"), + readFile(packagePath, "utf8"), +]); + +function audit(overrides = {}) { + return auditSequentialPagedPublicationSources({ pagedSource, polycssSource, statePagesSource, ...overrides }); +} + +function injectIntoFunction(source, name, statement) { + const declaration = `function ${name}(`; + const marker = source.includes(declaration) ? declaration : `const ${name} = (`; + const start = source.indexOf(marker); + assert.notEqual(start, -1, `missing ${name}`); + const body = source.indexOf("{", start); + assert.notEqual(body, -1, `missing ${name} body`); + return `${source.slice(0, body + 1)}\n ${statement}\n${source.slice(body + 1)}`; +} + +function injectIntoBranch(source, functionName, branchMarker, statement) { + const functionStart = source.indexOf(`const ${functionName} = (`); + assert.notEqual(functionStart, -1, `missing ${functionName}`); + const branch = source.indexOf(branchMarker, functionStart); + assert.notEqual(branch, -1, `missing ${functionName} ${branchMarker}`); + const body = source.indexOf("{", branch); + assert.notEqual(body, -1, `missing ${functionName} branch body`); + return `${source.slice(0, body + 1)}\n ${statement}\n${source.slice(body + 1)}`; +} + +test("sequential paged publication guard accepts the range-backed implementation", () => { + const result = audit(); + assert.equal(result.pass, true); + assert.equal(result.measuredHeapAllocations, false); + assert.equal(result.pagedDispatchBeforeInlineMaterialization, true); + assert.equal(result.pageBoundaryValidationCalled, true); + assert.deepEqual(result.missingScopes, []); + assert.deepEqual(result.violations, []); +}); + +for (const [operation, statement] of [ + ["slice-copy", "void page.shapeTargets.slice(shapeStart, shapeEnd);"], + ["array-from-copy", "void Array.from(page.shapeTargets.subarray(shapeStart, shapeEnd));"], + ["array-constructor", "void new Uint32Array(shapeEnd - shapeStart);"], + ["spread-array-clone", "void [...page.shapeTargets.subarray(shapeStart, shapeEnd)];"], +]) { + test(`sequential paged publication guard rejects ${operation}`, () => { + const mutated = injectIntoFunction(pagedSource, "playbackSparseStage", statement); + const result = audit({ pagedSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === "playbackSparseStage" && entry.operation === operation)); + }); +} + +test("sequential paged publication guard requires paged dispatch before inline materialization", () => { + const mutated = injectIntoFunction(polycssSource, "stageFrame", "void frame;"); + const result = audit({ polycssSource: mutated }); + assert.equal(result.pass, false); + assert.equal(result.pagedDispatchBeforeInlineMaterialization, false); +}); + +for (const [collection, statement] of [ + ["Set", "void new Set([stage.frame]);"], + ["Map", "void new Map([[stage.frame, stage]]);"], +]) { + test(`sequential paged publication guard rejects ${collection} construction while installing an active stage`, () => { + const mutated = injectIntoFunction(pagedSource, "installActiveStage", statement); + const result = audit({ pagedSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === "installActiveStage" && entry.operation === "set-map-constructor")); + }); +} + +test("sequential paged publication guard rejects target arrays in the applyStage range branch", () => { + const mutated = injectIntoBranch(polycssSource, "applyStage", 'if (next.kind === "range")', "void new Uint32Array(next.shapeEnd - next.shapeStart);"); + const result = audit({ polycssSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === "applyStage:range" && entry.operation === "array-constructor")); +}); + +for (const [scope, sourceName] of [ + ["publishVariantTarget", "pagedSource"], + ["publishStageShapeVisibility", "polycssSource"], + ["publishSurfaceTarget", "polycssSource"], + ["recoverSurface", "polycssSource"], + ["recoverPendingTransforms", "polycssSource"], + ["publishProfileVisibility", "polycssSource"], + ["publishRecoveredShapeVisibility", "polycssSource"], +]) { + test(`sequential paged publication guard rejects Set construction in ${scope}`, () => { + const source = sourceName === "pagedSource" ? pagedSource : polycssSource; + const mutated = injectIntoFunction(source, scope, "void new Set();"); + const result = audit({ [sourceName]: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === scope && entry.operation === "set-map-constructor")); + }); +} + +test("sequential paged publication guard fails closed when an adjacent helper is renamed", () => { + const mutated = polycssSource.replace("const publishStageShapeVisibility = (", "const renamedStageShapeVisibility = ("); + const result = audit({ polycssSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.missingScopes.includes("publishStageShapeVisibility")); +}); + +test("sequential paged publication guard rejects sorting in forced surface range publication", () => { + const mutated = injectIntoFunction(polycssSource, "publishSurfaceRangeWithForced", "void [start, end].sort();"); + const result = audit({ polycssSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === "publishSurfaceRangeWithForced" && entry.operation === "sort-call")); +}); + +test("sequential paged publication guard rejects Map construction in adjacent surface publication", () => { + const mutated = injectIntoBranch(polycssSource, "applySurface", "if (sequential)", "void new Map();"); + const result = audit({ polycssSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === "applySurface" && entry.operation === "set-map-constructor")); +}); + +test("sequential paged publication guard rejects Set construction in adjacent profile visibility staging", () => { + const mutated = injectIntoBranch(polycssSource, "stageProfileVisibility", "if (offsets && targets && frame ===", "void new Set();"); + const result = audit({ polycssSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === "stageProfileVisibility" && entry.operation === "set-map-constructor")); +}); + +test("sequential paged publication guard rejects generic arrays in adjacent profile visibility staging", () => { + const mutated = injectIntoBranch(polycssSource, "stageProfileVisibility", "if (offsets && targets && frame ===", "void [frame];"); + const result = audit({ polycssSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === "stageProfileVisibility" && entry.operation === "array-literal")); +}); + +test("sequential paged publication guard rejects local closures in surface application", () => { + const mutated = injectIntoFunction(polycssSource, "applySurface", "void (() => frame);"); + const result = audit({ polycssSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === "applySurface" && entry.operation === "nested-closure")); +}); + +test("sequential paged publication guard rejects sorting in surface application", () => { + const mutated = injectIntoFunction(polycssSource, "applySurface", "void [frame].sort();"); + const result = audit({ polycssSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === "applySurface" && entry.operation === "sort-call")); +}); + +test("sequential paged publication guard directly audits page-boundary validation", () => { + const mutated = injectIntoFunction(statePagesSource, "validatePagedPlaybackBoundaryFromCanonical", "void Array.from(target.leafTargets);"); + const result = audit({ statePagesSource: mutated }); + assert.equal(result.pass, false); + assert.ok(result.violations.some((entry) => entry.scope === "validatePagedPlaybackBoundaryFromCanonical" && entry.operation === "array-from-copy")); + assert.match(result.limitation, /does not traverse the call graph/u); +}); + +test("publication report keeps timing and visit windows distinct and identity-bound", () => { + assert.match(publicationReportSource, /traceStartFrame: entry\.startFrame/u); + assert.match(publicationReportSource, /traceEndFrame: entry\.endFrame/u); + assert.match(publicationReportSource, /const visitStartFrame = diagnostic\?\.startFrame \?\? entry\.startFrame/u); + assert.match(publicationReportSource, /const visitEndFrame = diagnostic\?\.endFrame \?\? entry\.endFrame/u); + assert.match(publicationReportSource, /tracePageBoundariesCrossed: pageBoundariesCrossed\(entry\.startFrame, entry\.endFrame\)/u); + assert.match(publicationReportSource, /visitPageBoundariesCrossed: pageBoundariesCrossed\(visitStartFrame, visitEndFrame\)/u); + assert.match(publicationReportSource, /startFrame: frame\.contentWindow\.domformatProof\.sourceFrame/u); + assert.match(publicationReportSource, /startFrame: win\.domformatDiagnosticProof\.sourceFrame/u); + assert.match(publicationReportSource, /minimumAdvances: framesPerPage \* 2/u); + assert.match(publicationReportSource, /Raw visit totals from different endpoints must not be compared without normalization/u); + assert.match(publicationReportSource, /runtimeGitRevision/u); + assert.match(publicationReportSource, /runtimeGitDirty/u); + assert.match(publicationReportSource, /createHash\("sha256"\)/u); + assert.match(publicationReportSource, /playbackBoundaryShapeVisits: boundaries \* 3/u); + assert.match(publicationReportSource, /playbackBoundaryLeafVisits: boundaries \* \(workload\.leafCount \* 2 \+ \(workload\.denseTransformCount \+ workload\.sparseTransformCount\) \* 3\)/u); + assert.match(publicationReportSource, /DOMFORMAT_CSSGRAPHICS_ROOT/u); + assert.match(publicationReportSource, /manifestVerified/u); + assert.doesNotMatch(publicationReportSource, /verifiedByTraceRun/u); + assert.match(publicationReportSource, /8da516167305a1a653523ef3cad4e5c5ee11ac3b/u); +}); + +test("publication trace windows count one cyclic wrap and reject ambiguous multi-cycle durations", () => { + assert.equal(publicationFrameAdvances(1, 1_261, 1_440), 1_260); + assert.equal(publicationPageBoundariesCrossed(1, 1_261, 1_440, 60), 21); + assert.equal(publicationFrameAdvances(1_400, 100, 1_440), 140); + assert.equal(publicationPageBoundariesCrossed(1_400, 100, 1_440, 60), 2); + assert.doesNotThrow(() => assertSingleCycleTraceDuration(42_000, 1_440, 30)); + assert.doesNotThrow(() => assertSingleCycleTraceDuration(45_000, 1_440, 30)); + assert.throws(() => assertSingleCycleTraceDuration(45_001, 1_440, 30), (error) => error?.code === "PUBLICATION_TRACE_DURATION"); +}); + +test("publication diagnostics stay outside production mount and shipped viewer surfaces", () => { + assert.doesNotMatch(browserSource, /PolycssPublicationDiagnostics|readonly diagnostics\?|\bdiagnostics,/u); + assert.doesNotMatch(viewerSource, /diagnostics|internal-conformance/u); + assert.match(diagnosticViewerSource, /createPolycssPublicationDiagnostics/u); + assert.match(diagnosticViewerSource, /mountConformanceDom/u); + assert.match(diagnosticViewerSource, /domformatDiagnosticProof/u); + assert.doesNotMatch(JSON.stringify(JSON.parse(packageSource).exports), /internal-conformance/u); +}); + +test("publication trace binds lossless capture, raw preservation, and the page-preparation policy", () => { + assert.match(publicationReportSource, /requestAnimationFrame\(\(\) => requestAnimationFrame/u); + assert.match(publicationReportSource, /performance\.mark\("domformat-publication:flush"\)/u); + assert.match(publicationReportSource, /cdp\.send\("Tracing\.start", PUBLICATION_TRACE_START_CONFIG\)/u); + assert.match(publicationReportSource, /catch \(error\) \{[\s\S]*traceCompletion = await stopTrace\(cdp\)[\s\S]*writeRawTrace\(rawTrace, events, traceCompletion\)/u); + assert.match(publicationReportSource, /writeRawTrace\(rawTrace, events, traceCompletion\);[\s\S]*assertPublicationTraceComplete\(traceCompletion\)[\s\S]*summarizeTrace/u); + assert.match(publicationReportSource, /assertPublicationTraceComplete\(traceCompletion\)/u); + assert.match(publicationReportSource, /assertPublicationPagePreparationGate\(trace\)/u); + assert.match(publicationReportSource, /pagePreparationTaskMaxMs: PUBLICATION_PAGE_PREPARATION_MAX_TASK_MS/u); + assert.match(publicationReportSource, /General renderer scheduler tasks, cadence, and relative-speed observations have no hard gate/u); + assert.match(publicationReportSource, /attribution: PUBLICATION_PAGE_PREPARATION_ATTRIBUTION/u); + assert.match(publicationReportSource, /idleCallbackCount: idle\.length/u); +}); diff --git a/packages/domformat/test/publication-trace-policy.test.js b/packages/domformat/test/publication-trace-policy.test.js new file mode 100644 index 00000000..7eb69cc7 --- /dev/null +++ b/packages/domformat/test/publication-trace-policy.test.js @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + assertPublicationPagePreparationGate, + assertPublicationTraceComplete, + PUBLICATION_MAIN_TASK_EVENT, + PUBLICATION_PAGE_PREPARATION_ATTRIBUTION, + PUBLICATION_PAGE_PREPARATION_MAX_TASK_MS, + PUBLICATION_TRACE_START_CONFIG, +} from "../scripts/publication-trace-policy.js"; + +test("publication tracing records as much as possible with the bounded category set", () => { + assert.deepEqual(PUBLICATION_TRACE_START_CONFIG, { + transferMode: "ReportEvents", + traceConfig: { + recordMode: "recordAsMuchAsPossible", + includedCategories: [ + "blink.user_timing", + "devtools.timeline", + "toplevel", + ], + }, + }); + assert.equal(PUBLICATION_MAIN_TASK_EVENT, "ThreadControllerImpl::RunTask"); +}); + +test("publication trace completion rejects reported or unknown data loss", () => { + assert.doesNotThrow(() => assertPublicationTraceComplete({ dataLossOccurred: false })); + for (const completion of [{ dataLossOccurred: true }, {}, null]) { + assert.throws(() => assertPublicationTraceComplete(completion), (error) => error?.code === "PUBLICATION_TRACE_DATA_LOSS"); + } +}); + +test("publication page-preparation gate requires positive attribution and retains 50 ms", () => { + const passing = { + pagePreparation: { + attribution: PUBLICATION_PAGE_PREPARATION_ATTRIBUTION, + idleCallbackCount: 2, + taskCount: 2, + maxTaskMs: PUBLICATION_PAGE_PREPARATION_MAX_TASK_MS, + }, + }; + assert.doesNotThrow(() => assertPublicationPagePreparationGate(passing)); + for (const pagePreparation of [ + { ...passing.pagePreparation, idleCallbackCount: 0 }, + { ...passing.pagePreparation, taskCount: 0 }, + { ...passing.pagePreparation, attribution: "unattributed" }, + ]) { + assert.throws(() => assertPublicationPagePreparationGate({ pagePreparation }), (error) => error?.code === "PAGE_PREPARATION_ATTRIBUTION_MISSING"); + } + assert.throws( + () => assertPublicationPagePreparationGate({ pagePreparation: { ...passing.pagePreparation, maxTaskMs: PUBLICATION_PAGE_PREPARATION_MAX_TASK_MS + 0.001 } }), + (error) => error?.code === "PAGE_PREPARATION_LONG_TASK", + ); +});