docs/REVIEW-2026-09-06.md: fix all ten review findings; RUST.md/IRIS_TODO.md: DragGesture merge checks

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>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-06 13:16:16 -04:00
1 parent 2e00e71552
commit 1f379e8384
10 files changed
+679 -38

No files matched your search

+9 -2
View File
@@ -221,8 +221,15 @@ fn battery_line(samples: &[i32]) -> String {
return " battery current: unavailable on this device".to_string();
}
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
let min = samples.iter().min().unwrap();
let max = samples.iter().max().unwrap();
// `min`/`max` are guarded by the `is_empty` check above, three lines
// up -- pairing the `Option` unwraps with the emptiness check right
// here (rather than two statements apart, with `mean` in between
// reading the same slice) is what keeps a future reorder from
// separating the guard from what it protects (docs/
// REVIEW-2026-09-06.md finding 7).
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
unreachable!("samples is non-empty, checked above");
};
format!(
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
samples.len()
+9
View File
@@ -245,6 +245,15 @@ impl FrameReport {
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
/// can slice one whole run's frames by what was happening during each.
pub fn mark_phase(&mut self, name: &str) {
// `phase_stats`'s slicing (`idx >= phase.start_index && idx <
// end_index`) silently produces an empty or nonsensical slice for
// a phase pushed out of order rather than surfacing the misuse
// (docs/REVIEW-2026-09-06.md finding 5).
debug_assert!(
self.phases
.last()
.is_none_or(|p| self.total_frames >= p.start_index)
);
self.phases.push(PhaseMark {
name: name.to_string(),
start_index: self.total_frames,
+12
View File
@@ -781,6 +781,12 @@ impl VelocityTracker {
/// Record one frame's motion. `delta` is this frame's movement since
/// the last sample, not a cumulative position.
pub fn add_sample(&mut self, delta: f32, at: Instant) {
// A caller that samples out of order (a restored/replayed
// gesture, a test) would silently produce a negative `span` in
// `velocity`, handled only by its `span <= 0.0 => 0.0` catch-all
// -- masking the bug that produced it rather than surfacing it
// (docs/REVIEW-2026-09-06.md finding 4).
debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last));
self.samples.push_back((at, delta));
while let Some(&(when, _)) = self.samples.front() {
if at.duration_since(when) > VELOCITY_WINDOW {
@@ -956,6 +962,10 @@ impl FlingCalculator {
/// Total signed distance the fling travels before settling, in the
/// same pixel units `velocity` was given in.
pub fn distance(&self, velocity: f32) -> f32 {
// See `List::fling`'s matching assertion -- a non-finite velocity
// here silently produces a NaN distance rather than surfacing the
// bug that produced it (docs/REVIEW-2026-09-06.md finding 3).
debug_assert!(velocity.is_finite());
if velocity == 0.0 {
return 0.0;
}
@@ -968,6 +978,8 @@ impl FlingCalculator {
/// How long the fling takes to settle.
pub fn duration(&self, velocity: f32) -> Duration {
// See `distance`'s matching assertion, above.
debug_assert!(velocity.is_finite());
if velocity == 0.0 {
return Duration::ZERO;
}
+129
View File
@@ -424,6 +424,13 @@ impl List {
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
/// it is the correct density for a self-consistent unit system.
pub fn fling(&mut self, velocity_px_per_s: f32) {
// A NaN/inf velocity (a `VelocityTracker::velocity()` divide-by-
// near-zero span, or a caller passing a raw device value straight
// through) would propagate silently into `deceleration_for`'s
// `.ln()` -- the fling either never settles or jumps to NaN
// positions with nothing on screen saying why (docs/
// REVIEW-2026-09-06.md finding 3).
debug_assert!(velocity_px_per_s.is_finite());
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
self.fling = None;
return;
@@ -763,6 +770,17 @@ impl List {
/// one-frame lag `Scroll`'s own content-length cache accepts, per
/// LAYOUT.md.
fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) {
// Every current caller derives `slot` from `repair_anchor`/
// `prev_slot`/`next_slot`, which already check existence -- but
// that invariant is enforced by convention across three call
// sites, not by this function, which would otherwise fail with a
// bare "index out of bounds" and no context (docs/
// REVIEW-2026-09-06.md finding 2). `slot_widget`, called from
// here, is what actually indexes/`.expect`s on it.
debug_assert!(
self.slot_exists(slot),
"place() called with a slot that doesn't exist: {slot:?}"
);
let axis = self.axis;
let output_len = painter.output_size().axis(axis);
let container_len = painter.region().axis(axis).len();
@@ -1287,6 +1305,52 @@ mod tests {
);
}
/// Neither `replacing_the_last_row_stays_pinned_to_the_bottom` nor
/// its sibling below ever asserts the *evicted* key's own bookkeeping
/// is actually gone -- both replace row 4 with another row also keyed
/// `4`, so `heights.remove(&old.key)` removing and re-inserting the
/// same key would pass either test even if it did nothing (docs/
/// REVIEW-2026-09-06.md finding 10; this is `Selection`'s finding 1
/// class of bug -- a stale handle outliving what it points to --
/// production-tested from `List`'s own side). Replacing with a
/// **different** key is what actually exercises the removal.
#[test]
fn replace_back_forgets_the_evicted_keys_own_height() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let mut list = List::new(Axis::Y);
push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0);
let (list_weak, root) = add_list(&mut rsc, list);
let mut render = UiRenderState::new();
render.resize((100.0, 60.0));
render.update(&root, &mut rsc);
assert!(
rsc.ui
.widgets
.get(&list_weak)
.unwrap()
.heights
.contains_key(&4)
);
let (_weak, new_row) = fixed_row(&mut rsc, 40.0);
let old = rsc
.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.replace_back(ListRow::new(100, new_row));
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
assert_eq!(old.map(|o| o.key), Some(4));
assert!(
!list_ref.heights.contains_key(&4),
"the evicted key's cached height must not outlive the row it measured"
);
}
/// The other half of the same fix's contract: replacing a row that is
/// *not* on screen must not move anything that is. `replace_back` only
/// touches the last slot's own widget and this file's own `heights`/
@@ -1480,6 +1544,71 @@ mod tests {
}
}
/// `fling_moves_the_list_and_then_settles`/
/// `fling_distance_is_positive_toward_the_end` only check that a fling
/// started, moved the right way and eventually stopped -- both
/// unaffected by *how* the interior ticks split up the total travel
/// (docs/REVIEW-2026-09-06.md finding 9). A regression that made
/// `tick_fling` apply the whole spline distance every tick instead of
/// just this tick's incremental slice would still pass both, while
/// being wildly wrong every intermediate frame -- this pins the
/// per-tick delta to a decelerating curve (`FlingCalculator::
/// position_at`'s own monotonic-and-clamped property, one level
/// down, already covers the calculator alone; this is the same
/// property through `List::tick_fling`'s `scroll`/`extents`
/// accumulation).
#[test]
fn tick_fling_applies_shrinking_incremental_deltas() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
let start = Instant::now();
let mut prev_top = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0].top;
let mut deltas = Vec::new();
for step in 1..600 {
let now = start + std::time::Duration::from_millis(step * 16);
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
render.update(&root, &mut rsc);
let Some(top) = rsc
.ui
.widgets
.get(&list_weak)
.unwrap()
.extents
.get(&0)
.map(|e| e.top)
else {
break; // row 0 scrolled out of the loaded extents
};
deltas.push((prev_top - top).abs());
prev_top = top;
if !still {
break;
}
}
assert!(
deltas.len() >= 3,
"fling settled or left row 0's extent before collecting enough samples"
);
// Skip the first tick (the slop-transition jump the arbiter
// applies is a `List::fling`-adjacent concern, not this curve,
// but the very first frame can still carry rounding noise from
// `jump_to_start`'s own layout settling).
for w in deltas[1..].windows(2) {
assert!(
w[1] <= w[0] + 0.01,
"fling's per-tick delta grew instead of decelerating: {:?} then {:?}",
w[0],
w[1]
);
}
}
#[test]
fn cancel_fling_stops_it_with_no_further_movement() {
let mut rsc = TestRsc {
+130 -1
View File
@@ -151,8 +151,16 @@ impl TranscriptScreen {
}
RowDiff::Rebuild => {
// A row before the tail changed (a regroup) -- nothing
// short of a full rebuild expresses that.
// short of a full rebuild expresses that. `Selection`
// gets cleared the same way `List` does, right before the
// rows it was pointing at go with it -- `push_row` below
// re-`register`s whatever survives as it rebuilds each
// row (docs/REVIEW-2026-09-06.md finding 1: a key that
// `group_tool_runs` regrouped away used to stay in
// `Selection` pointing at a widget this `clear()` had
// just freed, panicking the next long-press anywhere).
self.rebuilds.set(self.rebuilds.get() + 1);
self.selection.borrow_mut().clear();
(self.list)(rsc).clear();
for row in &new_rows {
self.push_row(rsc, row);
@@ -414,3 +422,124 @@ mod diff_tests {
assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild);
}
}
/// Exercises `TranscriptScreen::apply`'s `Rebuild` arm through a real
/// `Selection`, the gap docs/REVIEW-2026-09-06.md finding 8 named: the
/// pure `diff_rows` decision above and `selection.rs`'s own registration
/// tests each pass in isolation, and neither alone catches finding 1 (a
/// regrouped-away row's key surviving in `Selection` after `List::clear()`
/// has already freed its widget). This fails before `Selection::clear()`
/// existed and the `Rebuild` arm called it, with a panic from
/// `TextEditable::edit` resolving the freed slot.
#[cfg(test)]
mod apply_tests {
use super::*;
use client_core::transcript_fold::TranscriptItem;
struct TestFocus {
focus: Option<WeakWidget<TextEdit>>,
}
impl FocusHost for TestFocus {
fn recent_click(&mut self) -> bool {
false
}
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
self.focus = id;
}
fn focus_gained(&mut self, _region: Option<PixelRegion>) {}
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.focus == Some(id)
}
}
struct TestRsc {
ui: UiData,
events: EventManager<TestRsc>,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
}
}
impl HasState for TestRsc {
type State = TestFocus;
}
impl HasEvents for TestRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
fn user(seq: u64, text: &str) -> TranscriptItem {
TranscriptItem::UserMsg {
seq,
text: text.to_string(),
attachments: Vec::new(),
}
}
fn tool(seq: u64, run_id: &str) -> TranscriptItem {
TranscriptItem::ToolRun {
seq,
id: format!("id{seq}"),
run_id: run_id.to_string(),
tool: "grep".to_string(),
input: "x".to_string(),
output: String::new(),
done: false,
asks: Vec::new(),
images: Vec::new(),
}
}
#[test]
fn a_row_dropped_by_a_regroup_does_not_outlive_itself_in_selection() {
use client_core::transcript_fold::group_tool_runs;
let mut rsc = TestRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// Same regroup shape as diff_tests' regroup case, plus a trailing
// row (seq 4) that survives unchanged -- what a reader would tap
// on right after the regroup lands.
let old_items = vec![tool(1, "run-a"), user(2, "meanwhile"), user(4, "stable")];
let new_items = vec![tool(1, "run-a"), tool(3, "run-a"), user(4, "stable")];
assert_eq!(
diff_rows(&group_tool_runs(&old_items), &group_tool_runs(&new_items)),
RowDiff::Rebuild,
"test setup must actually exercise the Rebuild arm"
);
let (screen, _tree) = build_tree(&mut rsc, group_tool_runs(&old_items));
screen.apply(&mut rsc, &old_items, &new_items);
// The surviving row (seq 4) is what a reader's long-press would
// land on; `begin` deselects every *other* registered row first,
// which is exactly what used to resolve a stale `WeakWidget` left
// by the regrouped-away rows and panic.
let surviving_key = row::row_key(&client_core::transcript_fold::ItemKey::Seq(4));
screen.selection.borrow_mut().begin(
&mut rsc,
surviving_key,
Vec2::ZERO,
Vec2::new(10.0, 10.0),
);
}
}
+22 -3
View File
@@ -63,13 +63,32 @@ impl Selection {
}
/// A row's selectable text became visible/known. Every addition here
/// needs its removal (`unregister`) -- called when `List` evicts the
/// row (`pop_front`/`pop_back`), so this map never outgrows however
/// many rows are actually loaded.
/// needs its removal (`unregister`, or `clear` for all of them at
/// once) -- called when `List` evicts the row (`pop_front`/
/// `pop_back`/`clear`), so this map never outgrows however many rows
/// are actually loaded. `List::place` guards the twin of this same
/// class of bug on the list's own side (`list.rs`'s `slot_exists`
/// assertion) -- a derived handle that silently outlives what it
/// points to; the next caller adding a third row-keyed side table
/// should read both.
pub fn register(&mut self, key: RowKey, text: WeakWidget<TextEdit>) {
self.rows.insert(key, text);
}
/// Drops every registration at once -- the same shape `List::clear()`
/// clears the list, and what `TranscriptScreen::apply`'s `Rebuild` arm
/// calls right before it, since a full rebuild drops every row's old
/// widget and `push_row` re-`register`s each surviving key's new one
/// as it goes (review docs/REVIEW-2026-09-06.md finding 1: the
/// `Rebuild` arm used to call only `List::clear()`, leaving any key
/// dropped by the regroup -- present in the old rows, absent from the
/// new ones -- pointing at a widget the list had just freed, so the
/// next long-press anywhere panicked in `begin`'s deselect loop).
pub fn clear(&mut self) {
self.rows.clear();
self.anchor = None;
}
pub fn unregister(&mut self, key: RowKey) {
self.rows.remove(&key);
if self.anchor.map(|(k, _)| k) == Some(key) {