fix(components): stop five painters aborting on schema-valid input - #144
Merged
LeadcodeDev merged 1 commit intoAug 8, 2026
Merged
Conversation
Each of these renders a scenario that `rustmotion validate` accepts, and each one killed the encode mid-frame. A painter that panics takes the whole render with it, so the guard belongs in the painter rather than upstream. - shape: skia asserts `pos.len() == colors.len()` inside the gradient shader, so a `stops` list of a different length than `colors` aborted the process. Drop stops we cannot honour and let skia space the colours. - table: `"row_colors": []` deserializes to Some(vec![]), not None, so the default palette was never substituted and the modulo guard still indexed an empty slice. - tag_cloud: same shape — `palette()` handed back the caller's empty vec and the painter took `index % len()` on it. - dot_map: `dot_spacing: 0` makes the division +inf, and `inf as u32` saturates to u32::MAX, scheduling ~1.8e19 iterations. The geometry pass rejects 0.01 but not 0, so the floor has to live in the painter. Also caps the grid per axis so a large box cannot schedule unbounded work. - codeblock diff: `col`, `delete` and `insert` all counted bytes while the reveal interpolates a fraction of that total, so mid-animation offsets landed inside a multi-byte glyph and `replace_range` aborted. Switch the accounting to characters and convert to byte offsets only when slicing. This also fixes the animation itself: a CJK glyph used to take three reveal steps instead of one. The new integration test drives all five through the real pipeline — serde, box_builder, run_layout, paint_tree — so a fix that guarded the painter but left the component undeserialisable would still fail. dot_map paints on a worker with a deadline, because a runaway loop would otherwise wedge CI instead of reporting.
11 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #142 — Components workstream.
Five painters aborted or hung on input that
rustmotion validatereports as "Valid scenario". A panic inside a painter takes the entire encode with it, so the guard belongs in the painter, not upstream in the schema.All five reproductions come from the audit and are preserved verbatim as tests. They were run red first: each one reproduced the exact original failure before any fix landed.
shapestopsof a different length thancolorsassertion failed: pos.is_none() || (pos.unwrap().len() == colors.len())in skia's gradient shadertable"row_colors": []index out of bounds: the len is 0 but the index is 0tag_cloud"colors": []attempt to calculate the remainder with a divisor of zerodot_map"dot_spacing": 0codeblockdiffbyte index 20 is not a char boundary; it is inside 'ス'Notes on two of them
dot_map—(w - 0) / 0is+inf, andinf as u32saturates tou32::MAXin Rust rather than wrapping, so the nested loop was scheduled for roughly 1.8e19 iterations. The geometry pass catchesdot_spacing: 0.01but not0, which is why the floor has to live in the painter. The grid is also capped per axis so a legal spacing on a very large box cannot schedule unbounded work either.codeblockdiff — the audit called this a char-boundary slip; it is broader than that.col,deleteandinsertall counted bytes (String::len), while the reveal interpolatesprogress * total_work, so a fraction of a byte count lands part-way through a glyph. Snapping offsets to boundaries would stop the panic but leave the real defect: a CJK glyph consumed three reveal steps instead of one. The accounting is now in characters throughout, converted to byte offsets only at slice sites viabyte_at.cursor_colmoved to the same unit, including its measurement site.Test design
The new
tests/degenerate_inputs.rsdrives every case through the real pipeline — serde,build_scene_with_anim,run_layout,paint_tree— mirroring the existingcaption_presets.rsharness. Going through serde matters: a fix that guarded the painter while leaving the component undeserialisable would still fail the test.dot_mappaints on a worker thread with a deadline. A runaway loop would otherwise wedge CI rather than report, and a regression test that hangs is not a regression test.The
codeblockcase sweeps 21 points across the reveal, because the panic only fires on the frames where progress lands inside a glyph — a single mid-point sample would have missed it.Out of scope
Tightening the schema so these inputs are rejected up front (
stopslength,dot_spacing > 0) belongs to the Schema & serde workstream. Making the painters total is the right layer regardless:validateis advisory, and a panic must not be reachable from a deserialisable component.Verification
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace(692 passed, 0 failed).