Skip to content

fix(components): stop five painters aborting on schema-valid input - #144

Merged
LeadcodeDev merged 1 commit into
chantier/audit-remediationfrom
fix/audit-lot2-painter-panics
Aug 8, 2026
Merged

fix(components): stop five painters aborting on schema-valid input#144
LeadcodeDev merged 1 commit into
chantier/audit-remediationfrom
fix/audit-lot2-painter-panics

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Refs #142 — Components workstream.

Five painters aborted or hung on input that rustmotion validate reports 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.

Component Input Failure observed
shape stops of a different length than colors assertion failed: pos.is_none() || (pos.unwrap().len() == colors.len()) in skia's gradient shader
table "row_colors": [] index out of bounds: the len is 0 but the index is 0
tag_cloud "colors": [] attempt to calculate the remainder with a divisor of zero
dot_map "dot_spacing": 0 never returns
codeblock diff any non-ASCII text byte index 20 is not a char boundary; it is inside 'ス'

Notes on two of them

dot_map(w - 0) / 0 is +inf, and inf as u32 saturates to u32::MAX in Rust rather than wrapping, so the nested loop was scheduled for roughly 1.8e19 iterations. The geometry pass catches dot_spacing: 0.01 but not 0, 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.

codeblock diff — the audit called this a char-boundary slip; it is broader than that. col, delete and insert all counted bytes (String::len), while the reveal interpolates progress * 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 via byte_at. cursor_col moved to the same unit, including its measurement site.

Test design

The new tests/degenerate_inputs.rs drives every case through the real pipeline — serde, build_scene_with_anim, run_layout, paint_tree — mirroring the existing caption_presets.rs harness. Going through serde matters: a fix that guarded the painter while leaving the component undeserialisable would still fail the test.

dot_map paints 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 codeblock case 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 (stops length, dot_spacing > 0) belongs to the Schema & serde workstream. Making the painters total is the right layer regardless: validate is 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).

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.
@LeadcodeDev LeadcodeDev added the bug Something isn't working label Aug 8, 2026
@LeadcodeDev LeadcodeDev self-assigned this Aug 8, 2026
@LeadcodeDev
LeadcodeDev merged commit e6277d0 into chantier/audit-remediation Aug 8, 2026
3 checks passed
@LeadcodeDev
LeadcodeDev deleted the fix/audit-lot2-painter-panics branch August 8, 2026 12:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant