Repository navigation
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
kolkov
left a comment
There was a problem hiding this comment.
@besmpl, sorry this sat for three months while our time went into wgpu and naga, which everything here builds on.
Two corrections to my August reviews first:
- I asked you to update ADR-064. That document lives in our internal notes, not in this repo, so you couldn't have changed it. That one is on us. We're keeping your dimension-aware cache key (provider + size), as I said I preferred in the first review, and I'll update the ADR myself.
- I placed the
windowProviderrace inDraw().Draw()already reads it underlifecycleMu. The unlocked read is inRender()(canvas.go:940, theScaleFactor()check), which also checksc.closedwithout the lock.New()can rewritewindowProviderunderlifecycleMufrom another goroutine while that runs.
Needed before merge:
- Rebase onto current
main.integration/ggcanvas/canvas.gonow conflicts, and the old CI run can't be restarted, so the rebase push is also what gets CI going again. - Read
windowProviderandclosedinRender()underlifecycleMu, or snapshot them at the top ofRender()under the lock. - The existing tests in
canvas_test.goandcanvas_render_test.gostill share the package-global cache. CallresetCanvasCache(t)in them, or reset it once per test from aTestMain.
Also, ignore the "typo" note from August. TestNew_ConcurrentDuplicateConstruction is spelled correctly.
If you'd rather not spend more time on this, tell me and I'll do 1–3 on your branch. Your commits and the credit stay yours.
New hands a cached canvas to each fresh provider wrapper and rewrites its windowProvider under lifecycleMu, possibly from another goroutine, while Render read windowProvider and closed without the lock. Render now snapshots both under the lock; SetDeviceScale takes the lock itself. TestRender_ConcurrentCacheHitRewritesWindowProvider reports the race under -race without the fix. The test provider gains Features and DownlevelCapabilities, which gpucontext's DeviceProvider has required since the branch was opened. CHANGELOG entry for gogpu#496 credits besmpl. Refs gogpu#496
ggcanvas.New now caches canvases in a package-global map. The tests in canvas_test.go and canvas_render_test.go did not reset it, so a canvas one test left behind stayed in the cache for the next. Each test now calls resetCanvasCache(t), which also closes what it leaves. None of the package's tests run in parallel. Refs gogpu#496
f4a898f to
fbce2ed
Compare
|
@besmpl, I've gone ahead and done the changes on your branch, your authorship kept. Your four commits are rebased onto
I've updated our design note to the provider + size key you built, and the CHANGELOG credits you. Thanks for this one. |
kolkov
left a comment
There was a problem hiding this comment.
Rebased onto current main, CI green, merging for v0.52.7. Your commits stay yours; the two on top are the Render() lock fix with its race test and the cache reset in the older tests.
New hands a cached canvas to each fresh provider wrapper and rewrites its windowProvider under lifecycleMu, possibly from another goroutine, while Render read windowProvider and closed without the lock. Render now snapshots both under the lock; SetDeviceScale takes the lock itself. TestRender_ConcurrentCacheHitRewritesWindowProvider reports the race under -race without the fix. The test provider gains Features and DownlevelCapabilities, which gpucontext's DeviceProvider has required since the branch was opened. CHANGELOG entry for #496 credits besmpl. Refs #496
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.