Skip to content

Window resize releases ALL boundary textures and force-dirties every widget — full-tree GPU texture recreation burst on every resize tick #176

Description

@AnyCPU

Summary

Any window size change makes the desktop render loop throw away every cached RepaintBoundary texture and force-dirty every widget in the tree. The next frame then re-records and re-renders a GPU offscreen texture for every boundary — not just the ones whose size actually changed. A drag-resize fires this per tick, and since gogpu gained live-resize rendering (frames rendered inside the OS resize loop), that is many full-tree texture recreation + submit bursts per second.

On most platforms this is "just" wasted GPU/CPU work that defeats the whole per-boundary caching design. On Apple Intel Metal drivers it is catastrophic: each command-buffer submit leaks ~22 KB at the driver level, so N-boundaries × ticks amplifies that floor into multi-GB RSS within minutes of drag-resizing. Observed symptom that led here: RSS grows fast while resizing but slowly while scrolling (scrolling touches only 1–2 boundaries per frame; one session reached 2.86 GB RSS).

Root cause

Three stacked mechanisms, all verified by reading main (v0.1.44-era, a961458):

  1. app/window.go:484-492Window.HandleResize calls widget.MarkRedrawInTree(w.root) on every resize event. That unconditionally sets every widget's (and therefore every RepaintBoundary's) dirty flag regardless of whether that widget's own layout constraints changed. Every boundary re-record bumps WidgetBase.SceneCacheVersion unconditionally, so the desktop loop's isBoundaryClean (desktop/desktop.go:676) sees every boundary as changed on every tick. This is the dominant cause: fixing only desktop.go would change nothing, because nothing is ever reported clean on resize.
  2. desktop/desktop.go:166-174 — the resize branch calls releaseBoundaryTextures() (:172) and sets fullRedrawNeeded (:173). releaseBoundaryTextures (desktop/desktop.go:896-904) releases every texture and nils both the texture map and the persistent layer tree; next frame ensureBoundaryTexture (desktop/desktop.go:660-674) finds nil for every boundary and calls CreateOffscreenTexture for each — N allocations and N FlushGPUWithView submits in one frame, even for boundaries whose pixel size is unchanged.
  3. desktop/desktop.go:670ensureBoundaryTexture sets the shared rl.fullRedrawNeeded flag as a side effect whenever any single boundary is (re)allocated. The root boundary always resizes with the window, so this one flag forces every other boundary walked later in the same frame to be treated as dirty too.

No resize coalescing exists anywhere (HandleResize is wired per-event in app/event_bridge.go), so all of the above runs at OS resize-tick frequency.

Fix

Texture lifetime is tied to boundary size, not to canvas events; a resize never releases resources it is not forced to:

  1. Window.HandleResize no longer calls widget.MarkRedrawInTree. It still sets needsLayout/needsRedraw/needsFullRepaint, so layout re-runs — and widget.LayoutChild's constraint cache (ADR-032) already marks exactly the widgets whose incoming constraints changed, propagating to their nearest boundary. A fixed-size sidebar or a virtualized list row that receives identical constraints after the resize now cache-hits and stays clean; the root and any actually-reflowed content correctly miss and re-record.
  2. The desktop.go resize branch no longer wholesale-releases textures or forces a full redraw. The texture map and persistent layer tree stay intact; ensureBoundaryTexture's existing per-boundary physical-size check (entry.width != pw || entry.height != ph) already does the targeted release/recreate for exactly the boundaries whose size changed (UpdateLayerTree re-syncs every boundary's size from layout each frame regardless).
  3. ensureBoundaryTexture no longer sets the shared fullRedrawNeeded flag: a fresh entry's zero-value sceneVersion already makes isBoundaryClean report that boundary dirty on its own, without contaminating the rest of the walk.
  4. One exposed gap closed: Window.DrawTo now force-invalidates the root boundary's cached scene when a full repaint is pending. The root has no parent to catch its constraint change via LayoutChild's cache-miss path, so without this the offscreen/DrawTo render path (no intervening Frame()) could serve a stale root scene — caught by the existing TestDrawTo_ResizeTriggersFullRepaint. This is deliberately root-only; all other boundaries rely on their own dirty tracking.

No compositor/damage-blit changes are needed: the existing pipeline already composites clean boundaries from cached textures — the bug was that nothing was ever correctly reported clean on resize. docs/RENDER-PIPELINE.md gains a note documenting the invariant this relies on (Draw must be a pure function of bounds + widget state; the old tree-wide force-dirty masked violations).

Verification

New test app/resize_dirty_test.go (included in the patch):

  • TestHandleResize_FixedSizeChildStaysCleanAcrossResize — a fixed-size RepaintBoundary child measured through widget.LayoutChild must stay scene-clean across HandleResize + Frame() with a changed window size, while the root (whose constraints genuinely changed) must become dirty — so the test also proves this is not a "nothing ever redraws" regression. It fails on unpatched code exactly as intended (fixed-size child became dirty after resize ...) — re-verified against the unpatched base. The test simulates a completed render pass (ClearRedrawInTree + ClearSceneDirty) before resizing; without that, SetNeedsRedraw's already-dirty fast path masks the bug and the test would pass vacuously.

Measured on hardware (the platform where this is worst): Retina MacBook Pro (Intel, macOS), 120 s scripted drag-resize loop vs 120 s scripted ListView-scroll loop on the gallery example, RSS sampled 1/s, linear-fit slope. Pre-fix, resize leaked dramatically faster than scroll (the fast-on-resize / slow-on-scroll signature above). Post-fix: resize ≈ 70 MB/min vs scroll ≈ 106 MB/min — resize now sits below the scroll baseline, i.e. the resize-specific amplification is gone; the residual slope common to both modes is the underlying Apple-Intel Metal per-submit driver leak, which is a separate pre-existing issue this patch does not (and cannot) address. Those numbers were measured against slightly older deps (gogpu v0.43.4 / wgpu v0.30.9); newer gogpu/wgpu live-resize improvements shift the absolute values, but not the relative conclusion.

go build ./..., full go test ./... (including TestDrawTo_ResizeTriggersFullRepaint and the rest of the resize/draw suites), go vet, gofmt -l, and golangci-lint run are all clean. Visually, live resize shows no artifacts, and with GOGPU_DEBUG_DAMAGE=1 only size-changed boundaries re-render during a resize. The fix has been running in a production tree since 2026-07-06.

Patch

Base: pristine main (a961458).

Note if you are also applying the separately submitted cross-display device-scale sync patch: apply that one first (it inserts code adjacent to the resize branch this patch rewrites, and its context assumes the pre-fix lines). This patch is generated so it applies cleanly either on pristine main or on top of that patch.

git apply-able against current main (v0.1.44), 255 lines. Note: if applying together with the device-scale patch from #172, apply that one first (this patch's context overlaps its insertion in desktop/desktop.go).

resize-boundary-texture-retention.diff
diff --git a/app/resize_dirty_test.go b/app/resize_dirty_test.go
new file mode 100644
index 0000000..e673b2d
--- /dev/null
+++ b/app/resize_dirty_test.go
@@ -0,0 +1,118 @@
+package app
+
+import (
+	"testing"
+
+	"github.com/gogpu/ui/event"
+	"github.com/gogpu/ui/geometry"
+	"github.com/gogpu/ui/widget"
+)
+
+// Resize over-invalidation regression tests: HandleResize must not force
+// every boundary in the tree to re-record its scene. Only boundaries whose
+// own layout constraints (and therefore measured size/content) actually
+// changed should become dirty — everything else should cache-hit via
+// widget.LayoutChild (ADR-032) and stay clean.
+//
+// Before this fix, HandleResize called widget.MarkRedrawInTree(w.root),
+// which set every widget's (and therefore every RepaintBoundary's) dirty
+// flag unconditionally. Every re-record unconditionally bumps
+// WidgetBase.SceneCacheVersion (see widget/boundary.go's ClearSceneDirty),
+// so this made the desktop render loop's per-boundary GPU texture
+// pipeline treat every boundary as changed on every resize tick — a
+// texture-recreation/re-submit storm. Fixing desktop.go alone would not
+// have been enough: this widget-level over-invalidation happens upstream
+// of anything the desktop package controls.
+
+// fixedSizeChild is a RepaintBoundary whose Layout always returns the
+// same size regardless of incoming constraints — simulating a
+// fixed-width sidebar or a virtualized list row whose intrinsic size does
+// not depend on the window.
+type fixedSizeChild struct {
+	widget.WidgetBase
+	layoutCalls int
+}
+
+func newFixedSizeChild() *fixedSizeChild {
+	c := &fixedSizeChild{}
+	c.SetVisible(true)
+	c.SetEnabled(true)
+	c.SetRepaintBoundary(true)
+	return c
+}
+
+func (c *fixedSizeChild) Layout(_ widget.Context, _ geometry.Constraints) geometry.Size {
+	c.layoutCalls++
+	return geometry.Sz(50, 50)
+}
+func (c *fixedSizeChild) Draw(_ widget.Context, _ widget.Canvas)     {}
+func (c *fixedSizeChild) Event(_ widget.Context, _ event.Event) bool { return false }
+
+// resizeTestRoot is a container RepaintBoundary that measures a
+// fixedSizeChild through widget.LayoutChild — the same ADR-032 cache
+// path every real container widget uses.
+type resizeTestRoot struct {
+	widget.WidgetBase
+	child *fixedSizeChild
+}
+
+func newResizeTestRoot() *resizeTestRoot {
+	r := &resizeTestRoot{child: newFixedSizeChild()}
+	r.SetVisible(true)
+	r.SetEnabled(true)
+	r.AddChild(r.child)
+	return r
+}
+
+func (r *resizeTestRoot) Layout(ctx widget.Context, c geometry.Constraints) geometry.Size {
+	// The child's constraints never depend on the parent's — it always
+	// asks for the same fixed box, so LayoutChild should cache-hit across
+	// resizes that don't otherwise affect it.
+	widget.LayoutChild(r.child, ctx, geometry.Tight(geometry.Sz(50, 50)))
+	return c.Constrain(geometry.Sz(800, 600))
+}
+func (r *resizeTestRoot) Draw(_ widget.Context, _ widget.Canvas)     {}
+func (r *resizeTestRoot) Event(_ widget.Context, _ event.Event) bool { return false }
+
+func TestHandleResize_FixedSizeChildStaysCleanAcrossResize(t *testing.T) {
+	wp := &mockWindowProvider{width: 800, height: 600, scale: 1.0}
+	a := New(WithWindowProvider(wp))
+	w := a.Window()
+	root := newResizeTestRoot()
+	w.SetRoot(root)
+	w.Frame() // initial layout — populates the child's layout cache.
+
+	// Simulate a real render pass having just completed: recordBoundary
+	// (app/layer_tree.go) clears BOTH the boundary's scene-dirty flag and
+	// the tree-wide per-widget needsRedraw flag before Draw runs — mirror
+	// both here. SetNeedsRedraw has an "already dirty" O(1) guard
+	// (widget/base.go), so leaving needsRedraw=true from the initial
+	// layout pass would mask MarkRedrawInTree's second-call side effect
+	// and make this test pass vacuously regardless of the fix.
+	widget.ClearRedrawInTree(root)
+	root.child.ClearSceneDirty()
+	if root.child.IsSceneDirty() || root.child.NeedsRedraw() {
+		t.Fatal("test setup: child should start clean")
+	}
+	root.child.layoutCalls = 0
+
+	wp.width, wp.height = 1024, 768
+	w.HandleResize(1024, 768)
+	w.Frame() // re-run layout with the new window size
+
+	if root.child.layoutCalls != 0 {
+		t.Errorf("child.Layout called %d times after resize, want 0 (LayoutChild should cache-hit on identical constraints)",
+			root.child.layoutCalls)
+	}
+	if root.child.IsSceneDirty() {
+		t.Error("fixed-size child became dirty after resize even though its own layout constraints did not change " +
+			"(HandleResize must not force every boundary dirty via widget.MarkRedrawInTree)")
+	}
+
+	// The root itself DID get different constraints (window grew), so its
+	// own boundary must correctly become dirty — this is not a "nothing
+	// ever redraws" regression, only unrelated boundaries should stay clean.
+	if !root.IsSceneDirty() {
+		t.Error("root should be dirty after a genuine window-size change (its own constraints changed)")
+	}
+}
diff --git a/app/window.go b/app/window.go
index 2cdf106..85d4611 100644
--- a/app/window.go
+++ b/app/window.go
@@ -481,14 +481,30 @@ func (w *Window) HandleEvent(e event.Event) {
 // HandleResize processes a window resize.
 //
 // This updates the window size and marks layout as needing recalculation.
+//
+// This used to also call widget.MarkRedrawInTree(w.root), which
+// force-sets every widget's (and therefore every RepaintBoundary's) dirty
+// flag regardless of whether that widget's own layout constraints — and
+// therefore its measured size and content — actually changed. Every
+// re-record bumps WidgetBase.SceneCacheVersion unconditionally
+// (boundary.go's ClearSceneDirty), so this made the desktop render loop's
+// per-boundary GPU texture pipeline treat literally every boundary as
+// changed on every resize tick, not just the ones whose size changed —
+// the root cause of the resize texture-recreation/re-submit burst.
+//
+// Removing it is safe: LayoutChild (ADR-032) already marks exactly the
+// widgets whose incoming constraints changed as needing redraw on a
+// cache miss, propagating to their nearest boundary — the same
+// already-established pattern ctx.SetOnInvalidate uses (see its comment
+// above) and updateWindowSize's poll-based resize path already follows.
+// A widget whose parent gives it identical constraints after a resize
+// (a fixed-size sidebar, a virtualized list row) cache-hits and stays
+// clean; the root and any resized/reflowed content correctly miss.
 func (w *Window) HandleResize(width, height int) {
 	w.windowSize = geometry.Sz(float32(width), float32(height))
 	w.needsLayout = true
 	w.needsRedraw = true
 	w.needsFullRepaint = true
-	if w.root != nil {
-		widget.MarkRedrawInTree(w.root)
-	}
 }
 
 // HandleFocusChange processes a window focus change.
@@ -884,6 +900,35 @@ func (w *Window) DrawTo(canvas widget.Canvas) bool {
 		return false
 	}
 
+	// Force root's own cached scene dirty when the window needs a full
+	// repaint (resize, theme change, SetRoot). Root has no parent to
+	// catch a constraint/content change via LayoutChild's cache-miss path
+	// (widget/layout_child.go), so without this nudge it can serve a stale
+	// cached scene if DrawTo is called without an intervening Frame()/
+	// layout pass. This mirrors the desktop package's own GPU render loop,
+	// which does the same for its own per-boundary pipeline — and is
+	// deliberately root-only: other boundaries correctly rely on their own
+	// per-widget dirty tracking. (HandleResize used to force this — and
+	// every OTHER boundary in the tree, unconditionally — via
+	// widget.MarkRedrawInTree; that overbroad tree-wide invalidation was
+	// the resize texture-recreation/re-submit storm this fixes.)
+	if w.needsRedraw || w.needsFullRepaint { //nolint:nestif // forced root invalidation with callback suppression requires nested type assertions
+		type sceneDirtier interface {
+			IsRepaintBoundary() bool
+			InvalidateScene()
+		}
+		if sd, ok := w.root.(sceneDirtier); ok && sd.IsRepaintBoundary() {
+			type dirtySuppressor interface{ SetSuppressDirtyCallback(bool) }
+			if ds, ok2 := w.root.(dirtySuppressor); ok2 {
+				ds.SetSuppressDirtyCallback(true)
+				sd.InvalidateScene()
+				ds.SetSuppressDirtyCallback(false)
+			} else {
+				sd.InvalidateScene()
+			}
+		}
+	}
+
 	// Collect dirty regions (always — for RepaintBoundary Intersects fast path).
 	w.dirtyTracker.Reset()
 	w.dirtyCollector.Collect(w.root)
diff --git a/docs/RENDER-PIPELINE.md b/docs/RENDER-PIPELINE.md
index 1fe8c99..80681a7 100644
--- a/docs/RENDER-PIPELINE.md
+++ b/docs/RENDER-PIPELINE.md
@@ -79,6 +79,8 @@ Recursively walks the widget tree (`paintBoundaryWithDepth`). Each dirty+visible
 
 **DrawChild skip pattern:** During recording, child boundaries are SKIPPED — they have their own GPU textures. The parent scene contains only non-boundary children (text, backgrounds, dividers).
 
+**Invariant:** `Draw` must be a pure function of bounds + widget state. A root-tree widget must read window size only via its layout constraints or a signal, never directly in `Draw` — otherwise it renders stale after a resize. Resize used to force-dirty every boundary's scene via `MarkRedrawInTree` regardless of this, masking any violation; now that resize only dirties boundaries whose own constraints changed, a widget breaking this invariant would visibly go stale on resize.
+
 ### Step 5: Paint Overlay Boundaries
 

diff --git a/desktop/desktop.go b/desktop/desktop.go
index 1b2e28d..e4fdae2 100644
--- a/desktop/desktop.go
+++ b/desktop/desktop.go
@@ -170,6 +170,18 @@ func (rl *renderLoop) draw(dc *gogpu.Context) { //nolint:gocyclo,cyclop,gocognit
}
cw, ch = w, h

  •   rl.releaseBoundaryTextures()
    
  •   rl.fullRedrawNeeded = true
    
  •   // Do NOT call releaseBoundaryTextures()/set fullRedrawNeeded
    
  •   // here. That wiped every boundary's texture and forced
    
  •   // isBoundaryClean false tree-wide, so a resize re-created and
    
  •   // re-submitted (FlushGPUWithView) every boundary's texture, not
    
  •   // just boundaries whose own size changed — each submit costs
    
  •   // ~22 KB on the affected Apple Intel Metal driver (per-submit
    
  •   // command-buffer leak), so this produced the resize-vs-scroll
    
  •   // leak-rate gap where drag-resizing grew RSS far faster than
    
  •   // scrolling. ensureBoundaryTexture already recreates a boundary's
    
  •   // texture exactly when ITS physical size differs from the cached
    
  •   // entry (entry.width != pw || entry.height != ph); leaving the
    
  •   // map and layerTree in place lets same-size boundaries correctly
    
  •   // keep their texture and skip re-render (UpdateLayerTree re-syncs
    
  •   // every boundary's size from layout each frame regardless).
    
    }

@@ -666,7 +678,17 @@ func (rl *renderLoop) ensureBoundaryTexture(key uint64, bw, bh int, cc *gg.Conte
}
tex, release := cc.CreateOffscreenTexture(pw, ph)

  •   // Do NOT set rl.fullRedrawNeeded here. It's a single flag
    
  •   // shared by every boundary's isBoundaryClean check for the rest of
    
  •   // this frame's tree walk — one boundary needing reallocation (e.g.
    
  •   // the root, which always resizes) would otherwise force every
    
  •   // OTHER boundary walked afterward to be treated as dirty too, even
    
  •   // when their own size and scene are unchanged. A fresh entry's
    
  •   // zero-value sceneVersion already makes isBoundaryClean detect
    
  •   // THIS boundary as dirty on its own (entry.sceneVersion(0) !=
    
  •   // pic.SceneVersion(), which is > 0 for any boundary painted
    
  •   // before, or freshly bumped to 1 this same frame for a
    
  •   // brand-new boundary — see WidgetBase.ClearSceneDirty).
      entry = &boundaryTexEntry{texture: tex, release: release, width: pw, height: ph}
      rl.boundaryTextures[key] = entry
    
  •   rl.fullRedrawNeeded = true
    
    }
    return entry

</details>

## Environment

- gogpu/ui v0.1.44 (current main); present since at least ~v0.1.38
- Go 1.26.x, `CGO_ENABLED=0`
- All platforms pay the full-tree texture churn; the memory-leak amplification is specific to macOS on Intel (Metal driver per-submit leak). gogpu ≥ v0.43.1 (live-resize rendering) raises the tick frequency during drags on macOS.
- Measurement hardware: MacBook Pro (Intel, Retina), macOS, `examples/gallery`

---

*Part of the fix registry: #170*

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions