Skip to content

fix(ggcanvas): reuse canvases and track window size - #496

Open
besmpl wants to merge 4 commits into
gogpu:mainfrom
besmpl:agent/reuse-ggcanvas
Open

fix(ggcanvas): reuse canvases and track window size#496
besmpl wants to merge 4 commits into
gogpu:mainfrom
besmpl:agent/reuse-ggcanvas

Conversation

@besmpl

@besmpl besmpl commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • make ggcanvas.New / NewWithScale idempotent for the same GPU identity, logical geometry, and device scale
  • key fresh provider wrappers by stable device/queue/adapter handles, with a pointer fallback for zero-handle providers
  • keep cache publication, close eviction, resize/scale rekeying, and tracker shutdown lifecycle-safe
  • synchronize canvas dimensions from WindowProvider.Size() before drawing
  • document cache/close semantics and add deterministic concurrency, wrapper, resize, tracker, and rollback regressions

Why

Applications commonly obtain a fresh GPUContextProvider wrapper every frame. Reconstructing ggcanvas.Canvas for each wrapper repeatedly allocates rendering state and misses window-size changes unless callers manage both lifecycles manually. Reusing the existing canvas by stable GPU identity fixes that pattern while preserving distinct canvases for distinct geometry or scale.

Verification

  • go test -race -tags nogpu ./integration/ggcanvas
  • cache/tracker/concurrency/rollback regressions repeated 20× under the race detector
  • go vet ./integration/ggcanvas
  • go build ./integration/ggcanvas
  • all modified coverable statements hit in the local CI-equivalent coverage profile
  • gofmt and git diff --check

Fixes #484

Review follow-up (2026-08-12)

  • ADR-064 / issue fix(ggcanvas): idempotent New() + auto-resize Draw() — prevent per-frame allocation #484 now explicitly describes dimension-aware cache identity (provider + size), matching the implementation.
  • Added resetCanvasCache(t) with cleanup to isolate every package-global cache test.
  • Clarified the Canvas contract: cache-visible lifecycle operations are serialized, while drawing and general concurrent use still require caller synchronization.

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@besmpl
besmpl marked this pull request as ready for review August 10, 2026 17:12
@besmpl
besmpl requested a review from kolkov as a code owner August 10, 2026 17:12

@kolkov kolkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this thorough implementation — the concurrency handling (barrier tests, lock ordering, winner/loser protocol) is excellent.

Architectural concern: We have an accepted ADR-064 (issue #484) that specifies ggcanvas lifecycle semantics. This PR's design differs from ADR-064 in a key way:

  • ADR-064: New(provider, w1, h1) then New(provider, w2, h2) returns the same canvas with an internal resize
  • This PR: Different dimensions create different cached canvases (keyed by provider + dimensions)

Your approach may be more correct (different dimensions = genuinely different logical canvases), but we need to align on the ADR before merging.

Required changes:

  1. ADR-064 alignment — Either update ADR-064 to match your cache semantics, or adjust the cache key to match ADR-064 (provider identity only, with auto-resize on dimension mismatch). We prefer option A (update ADR-064) since your design is architecturally sounder — but this needs explicit agreement.

  2. Test isolation — The canvasCache is a persistent global. Add a resetCanvasCache() test helper with t.Cleanup to prevent cross-test state leakage. Tests like TestFlushAndFlushPixmapConsistency that create multiple canvases with the same provider now get the same cached canvas — the test semantics changed silently.

  3. Thread-safety doc — Canvas struct doc says "NOT safe for concurrent use" but New(), Close(), Draw(), Resize() now acquire lifecycleMu. Update the doc to clarify which operations are lifecycle-safe vs which need external synchronization.

The implementation quality is high — we just need the ADR alignment resolved first.

@besmpl
besmpl force-pushed the agent/reuse-ggcanvas branch from 7b72d53 to f4a898f Compare August 12, 2026 06:57

@kolkov kolkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrency design is excellent — lock ordering, barrier tests, winner/loser protocol are among the highest quality contributor code in this project. Three blocking issues remain before merge:

1. ADR-064 not updated (BLOCKING)

PR body states: "ADR-064 / issue #484 now explicitly describes dimension-aware cache identity" — but the diff does not modify docs/dev/architecture/ADR-064-GGCANVAS-LIFECYCLE.md. The ADR on disk still specifies provider-only keying. Either:

  • (A) Update ADR-064 in this PR to document the dimension-aware cache key design with rationale, OR
  • (B) Change cache key to provider-only (matching accepted ADR-064) with auto-resize on dimension mismatch

2. windowProvider data race (BLOCKING)

lookupCachedCanvas writes cached.windowProvider under lifecycleMu, but Draw() reads c.windowProvider without holding any lock:

// cache.go — write under lock:
cached.windowProvider = wp

// canvas.go Draw() — read without lock:
if c.windowProvider != nil {
    if width, height := c.windowProvider.Size(); ...

Since New() is advertised as concurrency-safe (acquires locks), and New() can update windowProvider while Draw() reads it from another goroutine, this is a data race. The race detector doesn't catch it in tests because test patterns are sequential.

Fix: read windowProvider under lifecycleMu in Draw(), or use atomic.Pointer[gpucontext.WindowProvider].

3. Existing tests lack cache isolation (BLOCKING)

resetCanvasCache(t) is only added to new cache_test.go. Existing tests in canvas_test.go and canvas_render_test.go use newMockProvider() with same dimensions — they now silently share cached canvases across test boundaries. This changes test semantics without updating test intent.

Fix: add resetCanvasCache(t) to all existing test functions in canvas_test.go and canvas_render_test.go, or add a package-level TestMain that resets between tests.


Non-blocking notes (no action required now):

  • reflect import adds ~100KB binary size for the zero-handle fallback path in cacheKeyFor
  • No cache eviction policy for dimension-proliferation scenarios (cache grows unbounded if many unique dimension combinations used)
  • Typo: TestNew_ConcurrentDuplicateConstructionConstruction

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ggcanvas): idempotent New() + auto-resize Draw() — prevent per-frame allocation

2 participants