Skip to content

fix/incremental restyle run topology - #66

Closed
thejustinwalsh wants to merge 1 commit into
mainfrom
fix/incremental-restyle-run-topology
Closed

fix/incremental restyle run topology#66
thejustinwalsh wants to merge 1 commit into
mainfrom
fix/incremental-restyle-run-topology

Conversation

@thejustinwalsh

@thejustinwalsh thejustinwalsh commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

What

A metrics-only restyle could permanently poison an engine session. The presentation soak surfaced it: paragraph 5's animated emphasis fontSize passes through the root's value, every subsequent frame fails with invalidRequest, and the session never recovers.

Mechanism

fontSize invalidates only metrics, so prepare_shape retained the committed shape. But shaping-run merging compares layout scalars including font_size — when a span's animated size crosses value-equality with its neighbor, the rebuilt pending run table merges (or splits) runs the committed shape doesn't have. Glyphless run ownership then rejects the shape/table mismatch, and because the failure happens after the pending table is built but before commit, every retry hits the same wall.

frame N   span fontSize == root  → pending runs merge   (13 runs)  → shaped + committed
frame N+1 span fontSize != root  → pending runs split   (15 runs)
          metrics-only invalidation → shape retained     (13 runs)
          fill_glyphless_run_ownership: shaped run ∉ source run → invalidRequest, forever

Fix

prepare_shape's retention early-return now also requires the committed and pending run tables to agree on run boundaries; any topology change reshapes.

Evidence

  • Red-green regression in three-engine-runtime.test.mjs: animated span fontSize passing through root equality, with a paint difference keeping style segments split while runs merge. Fails with invalidRequest before the fix, passes after.
  • Byte-exact replay of the four captured production frames (including the originally failing frame 11): all apply cleanly, revisions advance.
  • Full presentation soak (bitmap/WebGPU, the originally failing cell): exit 0, zero engine errors, all 8 workloads settled.
  • 172 Rust lib tests pass; clippy adds no warnings for the touched file; size gate green after a ~50-byte raw wasm re-price for the comparator (compressed budgets unchanged).

Debugging notes

Two missteps recorded in the log for posterity: Option::ok_or evaluates its error argument eagerly (a poisoned-site tracker silently lied until switched to ok_or_else), and the first regression attempt passed because a fontSize-only span at value-equality collapses to a single style segment — full invalidation hides the bug unless paint keeps the segments split.

…ology

A fontSize-only restyle invalidates metrics without shaping, but run
merging compares layout scalars including font_size: a span crossing
value-equality with its neighbor changes the pending run-table topology
while prepare_shape retains the stale merged shape. Glyphless run
ownership then rejects the mismatch as invalidRequest and the session
never recovers.

Guard the shape-retention early return with a committed-vs-pending run
boundary comparison so any topology change reshapes. Proven by a
red-green integration regression (animated span fontSize passing through
root equality with a paint difference keeping style segments split),
byte-exact replay of the captured production frames including the
failing one, and a clean full presentation soak. Raw wasm budget rises
~50 bytes for the comparator; compressed budgets unchanged.
@thejustinwalsh
thejustinwalsh marked this pull request as ready for review August 12, 2026 03:14
@github-actions

Copy link
Copy Markdown

size-limit report 📦

Path Size
Core JS (gzip) 18.37 KB (0%)
Shaper Wasm (gzip) 418.79 KB (+0.01% 🔺)
Three.js adapter JS (gzip) 57.7 KB (0%)
Inter font · Bitmap (gzip) 545.26 KB (0%)
Inter font · MTSDF (gzip) 6.48 MB (0%)
Inter font · Slug (gzip) 604.06 KB (0%)
Font Awesome icons · Bitmap (gzip) 439.53 KB (0%)
Font Awesome icons · MTSDF (gzip) 6.89 MB (0%)
Font Awesome icons · Slug (gzip) 642.64 KB (0%)
Font validator JS (gzip) 134.24 KB (0%)
Runtime bake host JS (gzip) 5.38 KB (0%)
Runtime bake Worker JS (gzip) 142.45 KB (0%)
Font baker JS (gzip) 2.41 KB (0%)
Font baker Wasm (gzip) 379.63 KB (0%)
Bitmap baker JS (gzip) 4.66 KB (0%)
Bitmap baker Wasm (gzip) 228.3 KB (0%)
MTSDF baker JS (gzip) 5.39 KB (0%)
MTSDF baker Wasm (gzip) 209.22 KB (0%)
Slug baker JS (gzip) 4.01 KB (0%)
Slug baker Wasm (gzip) 181.24 KB (0%)

thejustinwalsh added a commit that referenced this pull request Aug 13, 2026
…rement

Community feedback root-caused: feeding a raw f32 contentWidth back as
the next exact constraint flips line counts at knife-edge widths (39 of
811 swept), producing high-frequency break/unbreak flapping and CPU
churn in reactive layout engines; rounding the fed-back width up to the
point scale flips zero. The rule lived only in the conformance
fixture's roundUpToPointScale; the integration document now states it
with the stable Yoga callback pattern and notes the pre-#66 session
storms and the 11.17 synchronous-measure outlook.
thejustinwalsh added a commit that referenced this pull request Aug 20, 2026
Ports the regression scenario from #66, which was written against the
pre-rename `packages/text` tree and so could never merge. A colored span
whose fontSize animates through value-equality with its root keeps its
style segment while the shaping-run table merges across it -- runs
coalesce on `same_layout_style`, which compares layout scalars and
ignores paint, and `font_size` is one of those scalars. The next tick
moves the size off equality and the table splits again under
metrics-only invalidation.

The engine once retained the merged shape against the rebuilt table and
rejected every later frame with invalidRequest, poisoning the session;
`shaping_run_topology_stable` was added to stop that and is already on
main.

The test does not isolate that guard. Forcing
`shaping_run_topology_stable` to return `true` -- the pre-fix behaviour
-- and rebuilding the shaper leaves the whole suite green, so a later
path absorbs the split as well. It lands as a canary over the
retained-shape path, and the comment says so rather than claiming a
proof it does not deliver. That guard has no other coverage: no Rust
test exercises it.
@thejustinwalsh

Copy link
Copy Markdown
Collaborator Author

Superseded — both halves of this PR are now on main.

The fix (shaping_run_topology_stable) landed with the integer-layout-units stack in #76 (squashed to db1cd335), at packages/glyph/rust/shaper/src/engine/state.rs:1246. The regression test landed in #81 (squashed to 7e85b27d). This PR could not merge on its own because it targets the pre-rename packages/text/… tree, so the test was ported by hand rather than rebased.

One finding from the port, recorded in #81 and in the test's own comment: forcing shaping_run_topology_stable to return true — the pre-fix behaviour — and rebuilding the shaper leaves the whole three-engine-runtime suite green, including this scenario. Some later path absorbs the split as well, so the ported test stands as a canary over the retained-shape path rather than as proof of that one guard. Whether the guard and its two call sites are still load-bearing is worth a separate look; it has no other test coverage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant