fix(ggcanvas): reuse canvases and track window size - #496
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
kolkov
left a comment
There was a problem hiding this comment.
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)thenNew(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:
-
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.
-
Test isolation — The
canvasCacheis a persistent global. Add aresetCanvasCache()test helper witht.Cleanupto prevent cross-test state leakage. Tests likeTestFlushAndFlushPixmapConsistencythat create multiple canvases with the same provider now get the same cached canvas — the test semantics changed silently. -
Thread-safety doc — Canvas struct doc says "NOT safe for concurrent use" but
New(),Close(),Draw(),Resize()now acquirelifecycleMu. 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.
7b72d53 to
f4a898f
Compare
kolkov
left a comment
There was a problem hiding this comment.
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):
reflectimport adds ~100KB binary size for the zero-handle fallback path incacheKeyFor- No cache eviction policy for dimension-proliferation scenarios (cache grows unbounded if many unique dimension combinations used)
- Typo:
TestNew_ConcurrentDuplicateConstruction→Construction
Summary
ggcanvas.New/NewWithScaleidempotent for the same GPU identity, logical geometry, and device scaleWindowProvider.Size()before drawingWhy
Applications commonly obtain a fresh
GPUContextProviderwrapper every frame. Reconstructingggcanvas.Canvasfor 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/ggcanvasgo vet ./integration/ggcanvasgo build ./integration/ggcanvasgofmtandgit diff --checkFixes #484
Review follow-up (2026-08-12)
provider + size), matching the implementation.resetCanvasCache(t)with cleanup to isolate every package-global cache test.Canvascontract: cache-visible lifecycle operations are serialized, while drawing and general concurrent use still require caller synchronization.