iris: a dirty widget redrawn by its ancestor never freed its old primitives

`draw_inner` read `needs_redraw` without consuming it, and used it to skip
the whole `if let Some(active)` block -- including the `remove(id, false)`
that frees a redrawn widget's previous primitives. So a widget that was
both already active and marked dirty, and was reached by an *ancestor's*
draw rather than by `redraw_updates` picking it first, drew a second full
set of primitives and then had `active.insert` overwrite the only handles
that could ever have freed the first set. Those primitives stay in the
layer's instance buffer for the life of the process, with a leaked move
slot and leaked mask refs, drawn every frame at whatever region they last
had -- and `List` sets no mask, so a row measured at `GENEROUS_PADDING`
leaves its ghost outside the list's own box.

That is the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md:
overlapping copies inside the transcript and one more below the composer.

Fixed by consuming the mark (`needs_redraw.remove`) at the top of
`draw_inner` -- this call *is* the redraw it asked for -- and freeing the
old primitives on the dirty path too.

Guarded so it cannot come back silently: `UiRenderState::orphaned_primitives`
walks every layer's live instances and names any whose owner is no longer
active or no longer holds a handle to them, and `update` `debug_assert!`s it
empty every frame (debug builds only). New regression test
`an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy` in list.rs fails on
the pre-fix code with "1 primitive(s) survived their own widget's redraw".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-06 13:59:53 -04:00
1 parent 3e72a4ef19
commit 76b1f99277
3 files changed
+170 -2

No files matched your search

+18
View File
@@ -6,6 +6,7 @@ use crate::{
ArrBuf,
data::{MaskIdx, MoveIdx, PrimitiveInstance},
},
util::HashSet,
};
use bytemuck::Pod;
use wgpu::*;
@@ -277,6 +278,23 @@ impl Primitives {
}
}
/// Every instance that is still bound for the GPU, as `(inst_idx,
/// owner, is_image)` -- everything except the slots already handed to
/// [`Self::free`] and waiting for [`Self::apply_free`] to compact them
/// away. Only [`crate::UiRenderState::orphaned_primitives`] uses this,
/// to check that every drawn primitive still belongs to a live widget.
pub fn live_instances(&self) -> impl Iterator<Item = (usize, WidgetId, bool)> + '_ {
let free: HashSet<usize> = self.free.iter().copied().collect();
let image_free: HashSet<usize> = self.image_free.iter().copied().collect();
let rects = (0..self.instances.len())
.filter(move |i| !free.contains(i))
.map(|i| (i, self.assoc[i], false));
let images = (0..self.images.len())
.filter(move |i| !image_free.contains(i))
.map(|i| (i, self.image_assoc[i], true));
rects.chain(images)
}
pub fn data(&self) -> &PrimitiveData {
&self.data
}
+89 -2
View File
@@ -1,7 +1,7 @@
use crate::{
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
render::MoveOffset,
render::{IMAGE_BINDING, MoveOffset},
util::{HashMap, HashSet, Id, Vec2},
};
@@ -139,6 +139,12 @@ impl UiRenderState {
} else if rsc.widgets().has_updates() {
self.redraw_updates(rsc);
}
#[cfg(debug_assertions)]
debug_assert!(
self.orphaned_primitives().is_empty(),
"{}",
self.orphan_report(rsc),
);
}
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
@@ -192,8 +198,21 @@ impl UiRenderState {
) {
let mut old_children = old_children.unwrap_or_default();
let mut old_move_slot = old_move_slot;
// Consumed here, not merely read: this call *is* the redraw the mark
// asked for, and leaving the mark set is what stranded a widget's
// primitives. `Painter::draw_twice` calls this twice for the same id
// in one frame (`List::place`'s measurement pass), and on the second
// call the still-set mark took the whole `if let` below -- including
// the `remove` that frees the first draw's primitives -- out of play,
// so `active.insert` at the end overwrote the only handles that could
// ever have freed them. The result is a full second copy of the row,
// drawn every frame from then on at the oversized measurement region
// and, with `List` setting no mask, outside the list's own bounds:
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
// The same shape reaches any dirty widget an ancestor redraws first.
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
if let Some(active) = self.active.get_mut(&id)
&& !rsc.widgets().needs_redraw.contains(&id)
&& !dirty
{
// check to see if we can skip drawing first
if active.region == region {
@@ -227,6 +246,14 @@ impl UiRenderState {
let active = self.remove(id, false, rsc).unwrap();
old_children = active.children;
old_move_slot = Some(active.move_slot);
} else if dirty && self.active.contains_key(&id) {
// Dirty and already drawn: none of the fast paths above may be
// taken (the widget's own content changed, so its old primitives
// say nothing about its new ones), but they are also the only
// thing that frees them. Same two lines, reached the other way.
let active = self.remove(id, false, rsc).unwrap();
old_children = active.children;
old_move_slot = Some(active.move_slot);
}
// draw widget
@@ -475,6 +502,66 @@ impl UiRenderState {
self.active.len()
}
/// Primitive instances still bound for the GPU whose owner is no
/// longer in `active`, or whose owner's `ActiveData` no longer names
/// them: a copy nothing can move, clip, resize or free, redrawn every
/// frame at whatever position it last had. `(layer, inst_idx, owner)`
/// each.
///
/// Asserted empty at the end of every [`Self::update`], because this
/// is exactly the shape of the duplicated transcript row on Iris's
/// phone (`docs/bench/iris-phone-v2-2026-09-06.md`): counting
/// `active` alone cannot see it, since the orphan's owner is very
/// much alive -- it is the *earlier* set of primitives that got
/// stranded when the widget was drawn a second time without the first
/// draw being freed. O(primitives), debug builds only.
pub fn orphaned_primitives(&self) -> Vec<(usize, usize, WidgetId)> {
let mut orphans = Vec::new();
for (layer, primitives) in self.layers.iter() {
for (inst_idx, owner, is_image) in primitives.live_instances() {
let owned = self.active.get(&owner).is_some_and(|a| {
a.primitives.iter().any(|h| {
h.layer == layer
&& h.inst_idx == inst_idx
&& (h.binding == IMAGE_BINDING) == is_image
})
});
if !owned {
orphans.push((layer, inst_idx, owner));
}
}
}
orphans
}
/// The message [`Self::update`]'s orphan assert prints -- built here
/// rather than inline so the (allocating, O(primitives)) work only
/// happens on the failing path.
#[cfg(debug_assertions)]
fn orphan_report(&self, rsc: &dyn UiRsc) -> String {
let orphans = self.orphaned_primitives();
let mut lines: Vec<String> = orphans
.iter()
.take(8)
.map(|(layer, idx, owner)| {
let alive = self.active.contains_key(owner);
format!(
" layer {layer} instance {idx}: owner '{}' ({owner:?}), owner still active: {alive}",
rsc.widgets().label(*owner),
)
})
.collect();
if orphans.len() > lines.len() {
lines.push(format!(" ... and {} more", orphans.len() - lines.len()));
}
format!(
"{} primitive(s) are drawn but owned by nobody -- a stale copy \
nothing will ever move or free:\n{}",
orphans.len(),
lines.join("\n"),
)
}
/// Give `id` exclusive pointer input from the next `run_sensors` call
/// on -- see `captured`'s field doc. Overwrites any previous capture
/// (a gesture that starts a new one has already decided the old one