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:
1 parent
2e00e71552
commit
1f379e8384
10 files changed
+679
-38
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user