Finding 1 (the real crash): Selection::clear() drops rows and anchor, called from TranscriptScreen::apply's Rebuild arm right before List::clear() -- push_row re-registers survivors as it rebuilds each row. Fixes a WeakWidget outliving the row group_tool_runs regrouped away, which panicked the next long-press anywhere. New apply_tests test builds a real TranscriptScreen, forces the regroup, and confirms no panic. Findings 2-5: debug_assert!s on List::place's slot, List::fling and FlingCalculator's velocity finiteness, VelocityTracker::add_sample's chronological order, and FrameReport::mark_phase's non-decreasing start_index. Finding 7: bench_client.rs's battery_line guard restructured so the empty check can't be separated from its unwraps by a future edit. Findings 9/10: new List tests pinning tick_fling's per-tick deceleration and replace_back's evicted-key cleanup with a different key than the existing tests use. IRIS.md's replace_back/clear/apply entry gained the side-table-clearing note the Docs finding asked for. Also records this pass's DragGesture-merge verification in RUST.md (tap stays vs swipe doesn't, a real fling keeps moving after release, keyboard cycles confirmed via on_insets_changed) and annotates the two IRIS_TODO.md phone-report items it targets. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12 KiB
Review: iris changes since 0e46293
Scope: git diff 0e46293..HEAD -- iris/ client-core/ (58 files, +5224/-226).
Read-only review; no source changed. Ordered likely-bug, then invariant
guards, then rules, then tests/docs.
Likely bugs
iris/transcript-ui/src/lib.rs:152-160(RowDiff::Rebuildarm ofTranscriptScreen::apply) never unregisters the rows it drops fromSelection, so a staleWeakWidget<TextEdit>outlives the widget it points to and the next touch on any row panics.Selection::rows: BTreeMap<RowKey, WeakWidget<TextEdit>>documents its own contract atselection.rs:69-71: "every addition here needs its removal ... called whenListevicts the row." TheReplaceLastarm above it honours this (lib.rs:143-145,self.selection.borrow_mut() .unregister(old_key)when the key changes). TheRebuildarm calls(self.list)(rsc).clear()and rebuilds every row fromnew_rows, but never touchesself.selection— any key present inold_rowsand absent fromnew_rows(exactly whatgroup_tool_runsregrouping two separate tool-call rows into one produces — seediff_tests:: a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback, which tests the diff decision but notapplyitself) is left inself.rowspointing at a widgetList::clear()just freed.TextEditable::edit(iris/src/widget/text/edit.rs:582-587) resolves that handle withui.widgets.get_mut(self).unwrap()— an unconditional panic on the freed slot.Selection::begin(selection.rs:88-101) iterates every registered row (w.edit(ui).deselect()) on an ordinary fresh press, so the crash fires on the next tap anywhere in the transcript after a regroup, not only on a tap targeting the orphaned row. Fix: giveSelectiona way to reconcile against the row set that survived a rebuild (e.g.Selection::retain(&self, keys: &BTreeSet<RowKey>)removing everything else, called from theRebuildarm before rebuilding), or simplest — callself.selection.borrow_mut()cleared the same wayList::clear()clears the list, then let the rebuild'spush_rowcalls re-registereverything as they already do.
Guarded invariants missing
iris/src/widget/list.rs:751(List::place) indexes/expects onslotwith no assertion that it exists.slot_widget(:563-575) panics via.expect(...)for a sentinel with no widget set, and does an unchecked&self.items[s as usize]for a real index — a bare "index out of bounds" with no context ifplaceis ever reached with a stale slot. Every current caller happens to deriveslotfromrepair_anchor/prev_slot/next_slot, which already check existence, but that invariant is enforced by convention across three call sites, not by the function that depends on it. Adddebug_assert!(self.slot_exists(slot), "place() called with a slot that doesn't exist: {slot:?}");at the top ofplace.iris/src/widget/list.rs:426(List::fling) andsense.rs'sFlingCalculator::distance/duration/position_atnever check that the incoming velocity is finite. ANaN/infvelocity (aVelocityTracker::velocity()divide-by-near-zero span, or a caller passing a raw device value straight through) propagates throughdeceleration_for's.ln()silently — the fling either never settles (settled_on_schedulecompares against aNaNduration(), which is alwaysfalse) or jumps toNaNpositions with nothing on screen saying why. Adddebug_assert!(velocity_px_per_s.is_finite())inList::flingandFlingCalculator::new/distance.iris/src/sense.rs:592-604(VelocityTracker::velocity) has no assertion that samples are chronological.add_sampletrusts its caller'sInstantordering; a caller that samples out of order (a restored/replayed gesture, a test) would silently produce a negativespanhandled only by thespan <= 0.0 => 0.0catch-all, masking the bug that produced it rather than surfacing it. Adddebug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last))inadd_sample.iris/core/src/render/frame_report.rs:247-252(mark_phase) has no assertion that phases are pushed in non-decreasingstart_indexorder.phase_stats's slicing (:274,idx >= phase.start_index && idx < end_index) silently produces an empty or nonsensical slice for an out-of-order phase rather than surfacing the misuse — cheap to add givenself.phases.last()is already in scope:debug_assert!(self.phases.last().is_none_or(|p| self.total_frames >= p.start_index));
Rules
- Two mechanisms answer "what row selection points at, still valid?"
Selectionrelies on callers remembering tounregister(finding 1);Listrelies on callers deriving slots only from already-checked sources (finding 2). Both are the same class of problem — a derived handle that silently outlives what it points to — solved ad hoc twice rather than once. Not asking for a shared abstraction here, but the two should at minimum cross-reference each other's doc comment so the next caller who adds a third handle-into-List-rows type (the code rules' "a rule that governs a set belongs to the set") finds both existing examples. iris/android-app/src/bench_client.rs:224-225(battery_line) calls.min().unwrap()/.max().unwrap()onsamplesguarded three lines above byif samples.is_empty(), which is fine — but the guard and the two unwraps are two statements apart with alet mean = ...in between reading the same slice; a future edit reordering those lines loses the guard's protection silently. Low severity (this is the bench tool, not the app), but worth a one-line comment tying the unwraps back to the guard, or restructuring aslet (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max())pattern so the empty case can't be separated from the check by a future edit.
Tests
- No test exercises
TranscriptScreen::apply'sRebuildarm throughSelection.lib.rs'sdiff_testsmodule (:284-379) tests only the purediff_rowsdecision function, neverapplyitself wired to a realSelection;selection.rs's own tests (a_missed_press_start_ recovers_on_the_next_pressing_frame,unregister_forgets_the_row_and_ clears_a_matching_anchor) never go throughapply/List::cleareither. This is exactly the gap that let finding 1 through: the two pieces (apply's fallback,Selection's registration contract) are each tested in isolation and never together. Add: build aTranscriptScreen, force aRowDiff::Rebuild(two adjacent tool-call rows regrouping, per the existingdiff_testscase), then callselected_text/simulate a fresh press on a surviving row and assert no panic. iris/src/widget/list.rs's fling tests check total distance and the start/end clamp but not the speed profile in between.fling_moves_the_list_and_then_settles/fling_distance_is_positive_ toward_the_endonly assert the fling started, moved in the right direction, and eventually stopped — none checks thattick_fling's per-tick delta is monotonically decreasing once past the fling's peak (the propertyfling_calculator_tests::position_at_ is_monotonic_and_clamped_past_the_endalready checks one level down, forFlingCalculatoralone, but never throughList::tick_fling's ownscroll/anchor.offsetaccumulation). A regression that madetick_flingapply the total distance every tick instead of the incremental one, for instance, would still pass both existing tests (final position and direction are unaffected by how the interior ticks split it up) while being wildly wrong every intermediate frame.iris/src/widget/list.rs::replacing_the_last_row_stays_pinned_to_ the_bottomand its sibling testreplace_back's effect on the displayed row, never that the row it evicted is actually gone fromheights/extents. Both tests assert the new row's position; neither assertsold.keyis absent fromlist_ref.heights/extentsafter the replace (the "stale primitive" class finding 1 is a production instance of). A cheap addition: assert!list_ref.heights.contains_key(&old.key)afterreplace_backin the existing test, sinceold.keyis already returned to the test asevicted... (lib.rscalls it that way; thelist.rstest would need to capture the key fromoldsimilarly.)
Docs
No missing IRIS.md entry found for a public API change in this diff —
List::fling/VelocityTracker/FlingCalculator, List:: anchor_position_display, FrameReport::mark_phase/phase_stats/
late_at_hz, UiRenderNode::new's Result change, Len::dp, and
List::replace_back/clear/TranscriptScreen::apply all have entries.
The List::replace_back/clear/TranscriptScreen::apply entry
(docs/IRIS.md:526) predates this review's finding 1 and does not mention
Selection's registration contract at all — once finding 1 is fixed,
that entry should gain a line noting what the fix requires of a caller
that keeps its own row-keyed side table (the same shape Selection is),
so the next such table doesn't reproduce the same gap.
Fixed, 2026-09-06
All ten findings addressed after the DragGesture merge (selection.rs
was rewritten by that merge, but finding 1's shape and location were
unchanged — TranscriptScreen::apply's Rebuild arm, iris/transcript-ui/ src/lib.rs).
- Fixed.
Selection::clear()(selection.rs) dropsrowsandanchor, called fromapply'sRebuildarm right beforeList::clear()—push_rowre-registers whatever survives as it rebuilds each row, the "simplest" fix option the finding named. - Fixed.
debug_assert!(self.slot_exists(slot), ...)at the top ofList::place(iris/src/widget/list.rs). - Fixed.
debug_assert!(velocity_px_per_s.is_finite())inList::fling, anddebug_assert!(velocity.is_finite())inFlingCalculator::distance/duration(iris/src/sense.rs).position_atcalls both, so it inherits the guard rather than needing its own. - Fixed.
debug_assert!on chronological sample order inVelocityTracker::add_sample(iris/src/sense.rs). - Fixed.
debug_assert!on non-decreasingstart_indexinFrameReport::mark_phase(iris/core/src/render/frame_report.rs). - Fixed (doc cross-reference only, as asked).
Selection::register's doc now points atList::place'sslot_existsassertion and vice versa isn't needed since finding 2's fix already cites this file in its own comment; both are grep-able on "docs/REVIEW-2026-09-06.md" and on each other's type names. - Fixed.
bench_client.rs::battery_linerestructured tolet (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()), so the empty-guard and the two lookups can no longer be separated by a future edit. - Fixed.
transcript-ui's newapply_tests:: a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection(lib.rs) builds a realTranscriptScreen, forces the same regroup shapediff_testsalready covers at the pure-diff level, callsapply, and thenSelection::beginon a surviving row — which panicked before fix 1, resolving aWeakWidgetList::clear()had just freed. - Fixed.
list.rs's newtick_fling_applies_shrinking_incremental_ deltasflings toward the end fromjump_to_startand asserts each tick'sextents[&0]delta is no larger than the previous one — would fail against atick_flingthat applied the total spline distance every tick instead of the incremental slice, which the two pre-existing fling tests cannot catch. - Fixed.
list.rs's newreplace_back_forgets_the_evicted_keys_own_ heightreplaces row 4 with a row keyed100(the two existingreplace_backtests always reuse the same key, so neither actually exercises the removal) and assertsheightsno longer contains the evicted key.
Docs: docs/IRIS.md's 2026-09-05 List::replace_back/clear/
TranscriptScreen::apply entry now has a line on what the fix requires of
a caller with its own row-keyed side table, naming Selection as the
example and dating the fix.
Verification run alongside the rest of this pass's checks: cargo fmt --all, cargo clippy --workspace --all-targets, cargo test --workspace
from iris/ — see docs/RUST.md's plan box for the pass/fail and any
caveats from this same session.