diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts index 9cccf8de..4d324804 100644 --- a/apps/benchmarks/src/benchmark/package-size-budgets.ts +++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts @@ -26,10 +26,11 @@ export const packageSizeBudgets = { // Complete Rust shaping, layout, policy, and command-plan publication. Gzip and Brotli rose for the decoration // rendering feature (D-248): decorating-box cascade state, per-cluster run aggregation, resource-free plan rows, // and paint-order gather passes, at roughly +0.6 KB gzip and +1.1 KB Brotli against a raw total that stayed - // inside the golfed ceiling. + // inside the golfed ceiling. Raw rose ~50 bytes for the shaping-run topology guard in prepare_shape (reshape + // when a metric-only restyle changes run-merge boundaries); compressed sizes stayed inside their budgets. 'text-shaper-wasm': { - rawBytes: 1_107_000, - minifiedBytes: 1_107_000, + rawBytes: 1_107_500, + minifiedBytes: 1_107_500, gzipBytes: 429_000, brotliBytes: 339_500, }, diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 39c5c1c1..3da458cc 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -21,11 +21,11 @@ "label": "Shaper Wasm", "status": "measured", "format": "wasm", - "sha256": "0d5e4ffc85d8e8c94a58216e816a3af5a4cacbeaf23aa4bcea887c0cd6f9e76b", - "rawBytes": 1106941, - "minifiedBytes": 1106941, - "gzipBytes": 428615, - "brotliBytes": 339142 + "sha256": "682f902937b32b6cd5954829a586a764b875b45d18fc25c37f6b6bd2fa1ed1a8", + "rawBytes": 1107042, + "minifiedBytes": 1107042, + "gzipBytes": 428641, + "brotliBytes": 338830 }, { "id": "three-runtime-js", diff --git a/docs/log.md b/docs/log.md index c35334ee..b22fd191 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,18 @@ ## 2026-08-11 +- **Shaping-run topology guard** — Fixed a session-poisoning engine defect the presentation soak exposed: a + metrics-only restyle (fontSize) rebuilds the pending shaping-run table, and because run merging compares layout + scalars including `font_size`, a span crossing value-equality with its neighbor changes the run topology without + any shaping invalidation. `prepare_shape` retained the stale merged shape against the rebuilt table, glyphless + run ownership rejected the mismatch as `invalidRequest`, and the session never recovered. The fix compares + committed and pending run boundaries in `prepare_shape` and reshapes on any mismatch. Proven by a red-green + integration regression (animated span fontSize passing through root equality), byte-exact replay of the four + captured production frames including the failing one, and a clean full presentation soak. Debugging missteps + recorded: `Option::ok_or` evaluates its error argument eagerly, which silently broke a poisoned-site tracker + until switched to `ok_or_else`, and the first regression attempt passed because a fontSize-only span at equality + collapses to one style segment — the span needs a paint difference to keep segments split while runs merge. + - **Decoration rendering (D-248)** — Underline, overline, and line-through render end-to-end from baked font metrics: public span `decoration` styles, decorating-box cascade stamping, continuous merged lines across nested spans, CSS paint order via record placement, resource-free decoration draws through both planners, and one shared diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 627fe7e9..b607eed1 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:28a3fe3c5278e7412cdf26894a208dca2dfecd856724bdf2fbdc1c46a7b50f70' +source_digest: 'sha256:142b0672f567a4110d280f68b48ed058e6398fbe73d07ff2b5eebcaa305e1458' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/text.md b/docs/packages/text.md index d9abb259..c549d4aa 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:d7d4c376a07d2e237b35c6923ef22b7556c1c042618eb7e8a33b400e31028797' +source_digest: 'sha256:1a3b456d665fb179fb974d08ee23842d3a71527519e60f3d0173e9a16fcc2fbb' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index eeaf8265..02c383f7 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1914,8 +1914,20 @@ impl ParagraphState { // Metric-only styles must refresh the retained run values consumed by cluster aggregation, but the underlying // HarfRust result remains valid. Keeping those two invalidations distinct avoids reshaping on size, tracking, // word-spacing, line-height, or baseline changes while still rebuilding advances from the new run styles. - if !self.shaping_runs_prepared - || (!self.text_prepared && !self.style_invalidation.shaping && !self.bidi_prepared) + // + // Retention is only sound while the rebuilt run table matches the committed one. The + // shaping-run merge compares layout scalars, so a metric value crossing equality with + // its neighbor changes run topology without shaping invalidation — for example a span + // whose animated font size passes through the root's size while its paint keeps the + // style segment alive. Retaining the old shape against the new table desyncs cluster + // ownership and poisons the session, so a topology change falls through to a reshape. + if !self.shaping_runs_prepared { + return Ok(()); + } + if !self.text_prepared + && !self.style_invalidation.shaping + && !self.bidi_prepared + && same_run_boundaries(self.shaping_runs.runs(), self.pending_shaping_runs.runs()) { return Ok(()); } @@ -2963,6 +2975,16 @@ fn unicode_error(error: UnicodeError) -> EngineError { } } +/// Whether two run tables agree on run count and text ranges. Within a metrics-only +/// invalidation every shaping-affecting property is already known equal, so boundaries +/// are the only axis on which the rebuilt table can drift from the committed one. +fn same_run_boundaries(committed: &[ShapingRun], pending: &[ShapingRun]) -> bool { + committed.len() == pending.len() + && committed.iter().zip(pending).all(|(committed, pending)| { + committed.text_start == pending.text_start && committed.text_end == pending.text_end + }) +} + fn same_shaping_properties(left: ShapingRun, right: ShapingRun) -> bool { left.script == right.script && left.direction == right.direction diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 60560c52..471e3472 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -1043,6 +1043,123 @@ test('Three coordinator shares shaping data across technique bindings and refere decorationTarget.dispose(); decorationSession.dispose(); + // Regression: a colored span whose fontSize animates through value-equality with + // its root keeps its style segment (the paint differs) while the shaping-run table + // merges across it (layout scalars match). When the next tick moves the size off + // equality the table splits again under metrics-only invalidation; the engine + // previously retained the merged shape against the rebuilt table and rejected + // every later frame with invalidRequest, poisoning the session. + const topologySession = coordinator.createSession({ + requestCapacity: 8_192, + resultCapacity: 1024 * 1024, + textCapacity: 64, + }); + const topologyStyles = (spanSize) => [ + { + opcode: 'upsert', + paragraphId: 1, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 24, + root: true, + value: { + fontStackHandle: first.handle, + materialId: primaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + }, + { + opcode: 'upsert', + paragraphId: 1, + styleId: 2, + cascadeOrder: 1, + start: 8, + end: 16, + value: { fontSize: spanSize, foregroundRgba: 0xff44_22ff }, + }, + ]; + const topologyFrame = (previous, spanSize, extra = {}) => + compileTextEngineFrameUpdate({ + sessionId: topologySession.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: previous?.engineRevision ?? 0, + consumedPlanRevision: previous?.planRevision ?? 0, + acknowledgedPublicationGeneration: previous?.publicationGeneration ?? 0, + limits: { + maxParagraphs: 1, + maxClusters: 32, + maxLines: 8, + maxRegions: 1, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + styleMutations: topologyStyles(spanSize), + ...extra, + }); + // Frame 1: the span size equals the root — shaping runs merge across the span. + const topologyFirst = topologySession.update( + topologyFrame(undefined, 16, { + paragraphMutations: [{ opcode: 'upsert', paragraphId: 1, order: 0 }], + textMutations: [{ paragraphId: 1, start: 0, deleteCount: 0, insert: 'alpha beta gamma epsilon' }], + constraints: [ + { + paragraphId: 1, + flowThreadId: 1, + geometryRevision: 1, + width: 512, + height: 128, + viewportBlockStart: 0, + viewportBlockEnd: 128, + resumeBlockOffset: 0, + maxLines: 8, + regionStart: 0, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: 'at-most', + heightMode: 'at-most', + wrap: 'word', + align: 'start', + overflow: 'visible', + blockAlign: 'start', + }, + ], + regions: [ + { + id: 1, + geometryRevision: 1, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd: 512, + blockEnd: 128, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 512, + clipBlockEnd: 128, + }, + ], + }), + ); + // Frame 2: the span size moves off equality — the run table splits again and the + // retained shape must be rebuilt, not reused. + const topologySecond = topologySession.update(topologyFrame(topologyFirst, 17.5)); + assert.equal(topologySecond.engineRevision, topologyFirst.engineRevision + 1); + // Frame 3: and the session must remain healthy afterwards. + const topologyThird = topologySession.update(topologyFrame(topologySecond, 18.25)); + assert.equal(topologyThird.engineRevision, topologySecond.engineRevision + 1); + topologySession.dispose(); + target.dispose(); session.dispose(); first.release();