detached 9577fee

Merge pull request 'fix(cef): recover all views after a GPU process relaunch' (#12) from fix/gpu-crash-recovery into main

Reviewed-on: https://git.bnema.dev/bnema/dumber/pulls/12
commit
9577fee1402c6904c38724cacd04bdb67fe48c49
author
bnema · · 2026-10-15 07:53 UTC
merge of
3cbb972 + ff5e58f · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.33.1
7 files · +1144 -39
M internal/infrastructure/cef/engine.go +7 -1
@@ -57,6 +57,8 @@ type Engine struct {
5757 alreadyRunningAppRelaunchMu sync.RWMutex
5858 alreadyRunningAppRelaunchHandler func(string)
5959 gpuRelaunchCount atomic.Int32
60+ // gpuRecoveryScheduled debounces GPU-relaunch recovery passes.
61+ gpuRecoveryScheduled atomic.Bool
6062
6163 // activeWebViews tracks all live webviews for CSS broadcast.
6264 activeWebViews sync.Map // map[port.WebViewID]*WebView
@@ -488,11 +490,12 @@ func (e *Engine) recordContextInitialized() {
488490 }
489491
490492 func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, renderNodeOverride, commandLine string) {
493+ var gpuLaunchCount uint64
491494 switch processType {
492495 case "renderer":
493496 e.childLaunchRenderer.Add(1)
494497 case "gpu-process":
495- e.childLaunchGPU.Add(1)
498+ gpuLaunchCount = e.childLaunchGPU.Add(1)
496499 default:
497500 e.childLaunchOther.Add(1)
498501 }
@@ -507,6 +510,9 @@ func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform,
507510 Uint64("gpu_launches", e.childLaunchGPU.Load()).
508511 Uint64("other_launches", e.childLaunchOther.Load()).
509512 Msg("cef: OnBeforeChildProcessLaunch")
513+
514+ // The first GPU launch is startup; any later one is a crash relaunch.
515+ e.noteGPUProcessLaunch(gpuLaunchCount)
510516 }
511517
512518 func (e *Engine) recordBrowserCreateRequest(width, height, result int32) {
@@ -57,6 +57,8 @@ type Engine struct {
57 alreadyRunningAppRelaunchMu sync.RWMutex57 alreadyRunningAppRelaunchMu sync.RWMutex
58 alreadyRunningAppRelaunchHandler func(string)58 alreadyRunningAppRelaunchHandler func(string)
59 gpuRelaunchCount atomic.Int3259 gpuRelaunchCount atomic.Int32
60+ // gpuRecoveryScheduled debounces GPU-relaunch recovery passes.
61+ gpuRecoveryScheduled atomic.Bool
60 62
61 // activeWebViews tracks all live webviews for CSS broadcast.63 // activeWebViews tracks all live webviews for CSS broadcast.
62 activeWebViews sync.Map // map[port.WebViewID]*WebView64 activeWebViews sync.Map // map[port.WebViewID]*WebView
@@ -488,11 +490,12 @@ func (e *Engine) recordContextInitialized() {
488 }490 }
489 491
490 func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, renderNodeOverride, commandLine string) {492 func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, renderNodeOverride, commandLine string) {
493+ var gpuLaunchCount uint64
491 switch processType {494 switch processType {
492 case "renderer":495 case "renderer":
493 e.childLaunchRenderer.Add(1)496 e.childLaunchRenderer.Add(1)
494 case "gpu-process":497 case "gpu-process":
495- e.childLaunchGPU.Add(1)498+ gpuLaunchCount = e.childLaunchGPU.Add(1)
496 default:499 default:
497 e.childLaunchOther.Add(1)500 e.childLaunchOther.Add(1)
498 }501 }
@@ -507,6 +510,9 @@ func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform,
507 Uint64("gpu_launches", e.childLaunchGPU.Load()).510 Uint64("gpu_launches", e.childLaunchGPU.Load()).
508 Uint64("other_launches", e.childLaunchOther.Load()).511 Uint64("other_launches", e.childLaunchOther.Load()).
509 Msg("cef: OnBeforeChildProcessLaunch")512 Msg("cef: OnBeforeChildProcessLaunch")
513+
514+ // The first GPU launch is startup; any later one is a crash relaunch.
515+ e.noteGPUProcessLaunch(gpuLaunchCount)
510 }516 }
511 517
512 func (e *Engine) recordBrowserCreateRequest(width, height, result int32) {518 func (e *Engine) recordBrowserCreateRequest(width, height, result int32) {
A internal/infrastructure/cef/gpu_recovery.go +230 -0
@@ -0,0 +1,230 @@
1+package cef
2+
3+import (
4+ "time"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ "github.com/rs/zerolog"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// GPU process crash recovery.
13+//
14+// When Chromium's GPU process dies and is relaunched, existing OSR browsers
15+// stop receiving OnAcceleratedPaint even though the CEF UI/IO threads and GTK
16+// stay alive. Recovery is two-staged per live view:
17+//
18+// 1. a render pulse (the same one used by the stall watchdog) on the CEF UI
19+// thread, which is cheap and often enough to re-establish the compositor
20+// output;
21+// 2. if no accelerated paint arrived within gpuRecoveryVerifyDelay, a page
22+// reload, which recreates the renderer's compositor frame sink.
23+//
24+// Unlike the stall watchdog, this path is always on and not env-gated.
25+//
26+// Concurrency model:
27+// - Every recovery attempt takes a per-view generation (gpuRecoveryGen). The
28+// pulse task and verify callback of an older attempt bail once a newer one
29+// started (a new crash, or a deferred view being shown), so a stale verify
30+// never reloads early and attempts never stack up.
31+// - Visibility (effectiveVisible), the deferral flag (gpuRecoveryPending) and
32+// the generation bump are all read/written under wv.mu. "Is the view hidden?
33+// then mark it pending" and "view became visible: consume pending" are
34+// therefore atomic with respect to each other: a deferral cannot be lost to
35+// a concurrent show.
36+// - The view bridge is cleared under wv.mu by teardown; recovery only reads
37+// it through renderPulseTarget/liveBridge snapshots taken under wv.mu.
38+// - The pulse runs on the CEF UI thread, the same thread that applies
39+// visibility changes, so the hidden check in the pulse task is also
40+// exact with respect to its WasHidden(0).
41+
42+const (
43+ // gpuRecoveryDelay lets the relaunched GPU process initialize (GL/Vulkan
44+ // context, GPU channel hand-off to the browser and renderers) before the
45+ // first pulse. OnBeforeChildProcessLaunch fires when the process is spawned,
46+ // not when it is ready; initialization takes a few hundred ms, so 1s leaves
47+ // margin without a visibly long freeze. It also acts as the debounce window:
48+ // every relaunch notification arriving while a pass is pending is absorbed.
49+ gpuRecoveryDelay = 1 * time.Second
50+ // gpuRecoveryVerifyDelay is how long a pulse gets to yield a new paint.
51+ gpuRecoveryVerifyDelay = renderRecoveryVerifyDelay
52+)
53+
54+// gpuRecoveryPaintCount reads the accelerated paint counter of a bridge. It is
55+// a variable so tests can simulate paints without a live GTK/GL bridge.
56+var gpuRecoveryPaintCount = func(bridge *Cef2gtkAdapter) int {
57+ return bridge.Diagnostics().AcceleratedPaints
58+}
59+
60+// noteGPUProcessLaunch is called for every GPU process launch with the
61+// running launch count. The first launch is normal startup; later ones mean a
62+// crash and relaunch. It only schedules work, so it is safe on the CEF
63+// browser-process launcher thread.
64+func (e *Engine) noteGPUProcessLaunch(launchCount uint64) {
65+ if e == nil || launchCount <= 1 {
66+ return
67+ }
68+ if !e.gpuRecoveryScheduled.CompareAndSwap(false, true) {
69+ return
70+ }
71+ logging.FromContext(e.currentContext()).Warn().
72+ Uint64("gpu_launches", launchCount).
73+ Dur("delay", gpuRecoveryDelay).
74+ Msg("cef: gpu process relaunched, scheduling render recovery")
75+ cefScheduleAfter(gpuRecoveryDelay, e.runGPURecoveryPass)
76+}
77+
78+// runGPURecoveryPass starts recovery for every live view.
79+func (e *Engine) runGPURecoveryPass() {
80+ e.gpuRecoveryScheduled.Store(false)
81+ e.activeWebViews.Range(func(_, value any) bool {
82+ if wv, ok := value.(*WebView); ok {
83+ wv.startGPURecovery()
84+ }
85+ return true
86+ })
87+}
88+
89+// startGPURecovery begins a new recovery attempt for the view (superseding any
90+// in-flight one): stage 1 posts the pulse, then stage 2 is scheduled.
91+//
92+// Hidden views legitimately produce no paints, so they can be neither pulsed
93+// (the pulse would leave CEF believing the view is visible) nor judged. They
94+// are marked pending instead and recovered when they become visible again.
95+func (wv *WebView) startGPURecovery() {
96+ target, ok := wv.renderPulseTarget()
97+ if !ok || !target.hasBrowser {
98+ return
99+ }
100+ gen, deferred := wv.claimGPURecovery()
101+ log := logging.FromContext(wv.ctx)
102+ if deferred {
103+ log.Warn().
104+ Uint64("webview_id", uint64(wv.id)).
105+ Int32("browser_id", target.browserID).
106+ Str("action", "defer").
107+ Msg("cef: gpu recovery deferred until view is visible")
108+ return
109+ }
110+ browserID := target.browserID
111+ const msg = "cef: gpu recovery pulse"
112+ wv.postRenderPulseTask(msg, func(host purecef.BrowserHost, bridge *Cef2gtkAdapter) {
113+ if wv.gpuRecoveryGen.Load() != gen {
114+ return // superseded by a newer attempt
115+ }
116+ // Runs on the CEF UI thread, where visibility changes are applied, so
117+ // the view cannot be shown or hidden between this check and the pulse.
118+ if wv.deferGPURecoveryIfHidden() {
119+ logging.FromContext(wv.ctx).Warn().
120+ Uint64("webview_id", uint64(wv.id)).
121+ Int32("browser_id", browserID).
122+ Str("action", "defer").
123+ Msg(msg + " skipped: view hidden, deferred until visible")
124+ return
125+ }
126+ // The baseline is taken here, right before the pulse, so paints that
127+ // landed while the task was queued do not count as pulse recovery.
128+ before := gpuRecoveryPaintCount(bridge)
129+ pulseRender(host)
130+ logging.FromContext(wv.ctx).Warn().
131+ Uint64("webview_id", uint64(wv.id)).
132+ Int32("browser_id", browserID).
133+ Int("accelerated_paints_before", before).
134+ Msg(msg + " executed")
135+ // The verify window starts after the pulse ran, and only for a pulse
136+ // that actually ran (not for a skipped or stale one).
137+ cefScheduleAfter(gpuRecoveryVerifyDelay, func() { wv.verifyGPURecovery(gen, browserID, before) })
138+ })
139+}
140+
141+// verifyGPURecovery is stage 2: reload the view if the pulse produced no paint.
142+func (wv *WebView) verifyGPURecovery(gen uint64, browserID int32, before int) {
143+ if wv.gpuRecoveryGen.Load() != gen {
144+ return // superseded by a newer attempt
145+ }
146+ bridge := wv.liveBridge()
147+ if bridge == nil || wv.ctx == nil {
148+ return
149+ }
150+ after := gpuRecoveryPaintCount(bridge)
151+ event := func() *zerolog.Event {
152+ return logging.FromContext(wv.ctx).Warn().
153+ Uint64("webview_id", uint64(wv.id)).
154+ Int32("browser_id", browserID).
155+ Int("accelerated_paints_before", before).
156+ Int("accelerated_paints_after", after)
157+ }
158+ if after > before {
159+ event().Str("action", "none").Msg("cef: gpu recovery pulse recovered view")
160+ return
161+ }
162+ // Reload waits on a synchronous GTK dispatch, during which the view can be
163+ // hidden, destroyed or superseded. The guard revalidates all of that on the
164+ // GTK thread right before navigating; no lock is held across the dispatch.
165+ reloaded := false
166+ deferred := false
167+ err := wv.reloadGuarded(func() bool {
168+ if wv.destroyed.Load() || wv.gpuRecoveryGen.Load() != gen {
169+ return false
170+ }
171+ if wv.deferGPURecoveryIfHidden() {
172+ deferred = true
173+ return false
174+ }
175+ reloaded = true
176+ return true
177+ })
178+ switch {
179+ case err != nil:
180+ logging.FromContext(wv.ctx).Warn().
181+ Err(err).
182+ Uint64("webview_id", uint64(wv.id)).
183+ Int32("browser_id", browserID).
184+ Msg("cef: gpu recovery reload failed")
185+ case reloaded:
186+ event().Str("action", "reload").Msg("cef: gpu recovery reload")
187+ case deferred:
188+ event().Str("action", "defer").Msg("cef: gpu recovery deferred until view is visible")
189+ }
190+}
191+
192+// claimGPURecovery starts a new attempt generation. If the view is hidden it
193+// marks recovery pending instead; otherwise it clears any stale pending flag.
194+// Everything happens under wv.mu so it is atomic with visibility changes.
195+func (wv *WebView) claimGPURecovery() (gen uint64, deferred bool) {
196+ wv.mu.Lock()
197+ defer wv.mu.Unlock()
198+ gen = wv.gpuRecoveryGen.Add(1)
199+ if wv.hiddenLocked() {
200+ wv.gpuRecoveryPending = true
201+ return gen, true
202+ }
203+ wv.gpuRecoveryPending = false
204+ return gen, false
205+}
206+
207+// deferGPURecoveryIfHidden atomically checks visibility and, when hidden,
208+// marks recovery pending so the next show resumes it.
209+func (wv *WebView) deferGPURecoveryIfHidden() bool {
210+ wv.mu.Lock()
211+ defer wv.mu.Unlock()
212+ if !wv.hiddenLocked() {
213+ return false
214+ }
215+ wv.gpuRecoveryPending = true
216+ return true
217+}
218+
219+// effectivelyHidden reports whether CEF was last told the view is hidden. A
220+// view whose visibility was never reported is treated as visible.
221+func (wv *WebView) effectivelyHidden() bool {
222+ wv.mu.RLock()
223+ defer wv.mu.RUnlock()
224+ return wv.hiddenLocked()
225+}
226+
227+// hiddenLocked is effectivelyHidden for callers already holding wv.mu.
228+func (wv *WebView) hiddenLocked() bool {
229+ return wv.effectiveVisibilityKnown && !wv.effectiveVisible
230+}
@@ -0,0 +1,230 @@
1+package cef
2+
3+import (
4+ "time"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+ "github.com/rs/zerolog"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// GPU process crash recovery.
13+//
14+// When Chromium's GPU process dies and is relaunched, existing OSR browsers
15+// stop receiving OnAcceleratedPaint even though the CEF UI/IO threads and GTK
16+// stay alive. Recovery is two-staged per live view:
17+//
18+// 1. a render pulse (the same one used by the stall watchdog) on the CEF UI
19+// thread, which is cheap and often enough to re-establish the compositor
20+// output;
21+// 2. if no accelerated paint arrived within gpuRecoveryVerifyDelay, a page
22+// reload, which recreates the renderer's compositor frame sink.
23+//
24+// Unlike the stall watchdog, this path is always on and not env-gated.
25+//
26+// Concurrency model:
27+// - Every recovery attempt takes a per-view generation (gpuRecoveryGen). The
28+// pulse task and verify callback of an older attempt bail once a newer one
29+// started (a new crash, or a deferred view being shown), so a stale verify
30+// never reloads early and attempts never stack up.
31+// - Visibility (effectiveVisible), the deferral flag (gpuRecoveryPending) and
32+// the generation bump are all read/written under wv.mu. "Is the view hidden?
33+// then mark it pending" and "view became visible: consume pending" are
34+// therefore atomic with respect to each other: a deferral cannot be lost to
35+// a concurrent show.
36+// - The view bridge is cleared under wv.mu by teardown; recovery only reads
37+// it through renderPulseTarget/liveBridge snapshots taken under wv.mu.
38+// - The pulse runs on the CEF UI thread, the same thread that applies
39+// visibility changes, so the hidden check in the pulse task is also
40+// exact with respect to its WasHidden(0).
41+
42+const (
43+ // gpuRecoveryDelay lets the relaunched GPU process initialize (GL/Vulkan
44+ // context, GPU channel hand-off to the browser and renderers) before the
45+ // first pulse. OnBeforeChildProcessLaunch fires when the process is spawned,
46+ // not when it is ready; initialization takes a few hundred ms, so 1s leaves
47+ // margin without a visibly long freeze. It also acts as the debounce window:
48+ // every relaunch notification arriving while a pass is pending is absorbed.
49+ gpuRecoveryDelay = 1 * time.Second
50+ // gpuRecoveryVerifyDelay is how long a pulse gets to yield a new paint.
51+ gpuRecoveryVerifyDelay = renderRecoveryVerifyDelay
52+)
53+
54+// gpuRecoveryPaintCount reads the accelerated paint counter of a bridge. It is
55+// a variable so tests can simulate paints without a live GTK/GL bridge.
56+var gpuRecoveryPaintCount = func(bridge *Cef2gtkAdapter) int {
57+ return bridge.Diagnostics().AcceleratedPaints
58+}
59+
60+// noteGPUProcessLaunch is called for every GPU process launch with the
61+// running launch count. The first launch is normal startup; later ones mean a
62+// crash and relaunch. It only schedules work, so it is safe on the CEF
63+// browser-process launcher thread.
64+func (e *Engine) noteGPUProcessLaunch(launchCount uint64) {
65+ if e == nil || launchCount <= 1 {
66+ return
67+ }
68+ if !e.gpuRecoveryScheduled.CompareAndSwap(false, true) {
69+ return
70+ }
71+ logging.FromContext(e.currentContext()).Warn().
72+ Uint64("gpu_launches", launchCount).
73+ Dur("delay", gpuRecoveryDelay).
74+ Msg("cef: gpu process relaunched, scheduling render recovery")
75+ cefScheduleAfter(gpuRecoveryDelay, e.runGPURecoveryPass)
76+}
77+
78+// runGPURecoveryPass starts recovery for every live view.
79+func (e *Engine) runGPURecoveryPass() {
80+ e.gpuRecoveryScheduled.Store(false)
81+ e.activeWebViews.Range(func(_, value any) bool {
82+ if wv, ok := value.(*WebView); ok {
83+ wv.startGPURecovery()
84+ }
85+ return true
86+ })
87+}
88+
89+// startGPURecovery begins a new recovery attempt for the view (superseding any
90+// in-flight one): stage 1 posts the pulse, then stage 2 is scheduled.
91+//
92+// Hidden views legitimately produce no paints, so they can be neither pulsed
93+// (the pulse would leave CEF believing the view is visible) nor judged. They
94+// are marked pending instead and recovered when they become visible again.
95+func (wv *WebView) startGPURecovery() {
96+ target, ok := wv.renderPulseTarget()
97+ if !ok || !target.hasBrowser {
98+ return
99+ }
100+ gen, deferred := wv.claimGPURecovery()
101+ log := logging.FromContext(wv.ctx)
102+ if deferred {
103+ log.Warn().
104+ Uint64("webview_id", uint64(wv.id)).
105+ Int32("browser_id", target.browserID).
106+ Str("action", "defer").
107+ Msg("cef: gpu recovery deferred until view is visible")
108+ return
109+ }
110+ browserID := target.browserID
111+ const msg = "cef: gpu recovery pulse"
112+ wv.postRenderPulseTask(msg, func(host purecef.BrowserHost, bridge *Cef2gtkAdapter) {
113+ if wv.gpuRecoveryGen.Load() != gen {
114+ return // superseded by a newer attempt
115+ }
116+ // Runs on the CEF UI thread, where visibility changes are applied, so
117+ // the view cannot be shown or hidden between this check and the pulse.
118+ if wv.deferGPURecoveryIfHidden() {
119+ logging.FromContext(wv.ctx).Warn().
120+ Uint64("webview_id", uint64(wv.id)).
121+ Int32("browser_id", browserID).
122+ Str("action", "defer").
123+ Msg(msg + " skipped: view hidden, deferred until visible")
124+ return
125+ }
126+ // The baseline is taken here, right before the pulse, so paints that
127+ // landed while the task was queued do not count as pulse recovery.
128+ before := gpuRecoveryPaintCount(bridge)
129+ pulseRender(host)
130+ logging.FromContext(wv.ctx).Warn().
131+ Uint64("webview_id", uint64(wv.id)).
132+ Int32("browser_id", browserID).
133+ Int("accelerated_paints_before", before).
134+ Msg(msg + " executed")
135+ // The verify window starts after the pulse ran, and only for a pulse
136+ // that actually ran (not for a skipped or stale one).
137+ cefScheduleAfter(gpuRecoveryVerifyDelay, func() { wv.verifyGPURecovery(gen, browserID, before) })
138+ })
139+}
140+
141+// verifyGPURecovery is stage 2: reload the view if the pulse produced no paint.
142+func (wv *WebView) verifyGPURecovery(gen uint64, browserID int32, before int) {
143+ if wv.gpuRecoveryGen.Load() != gen {
144+ return // superseded by a newer attempt
145+ }
146+ bridge := wv.liveBridge()
147+ if bridge == nil || wv.ctx == nil {
148+ return
149+ }
150+ after := gpuRecoveryPaintCount(bridge)
151+ event := func() *zerolog.Event {
152+ return logging.FromContext(wv.ctx).Warn().
153+ Uint64("webview_id", uint64(wv.id)).
154+ Int32("browser_id", browserID).
155+ Int("accelerated_paints_before", before).
156+ Int("accelerated_paints_after", after)
157+ }
158+ if after > before {
159+ event().Str("action", "none").Msg("cef: gpu recovery pulse recovered view")
160+ return
161+ }
162+ // Reload waits on a synchronous GTK dispatch, during which the view can be
163+ // hidden, destroyed or superseded. The guard revalidates all of that on the
164+ // GTK thread right before navigating; no lock is held across the dispatch.
165+ reloaded := false
166+ deferred := false
167+ err := wv.reloadGuarded(func() bool {
168+ if wv.destroyed.Load() || wv.gpuRecoveryGen.Load() != gen {
169+ return false
170+ }
171+ if wv.deferGPURecoveryIfHidden() {
172+ deferred = true
173+ return false
174+ }
175+ reloaded = true
176+ return true
177+ })
178+ switch {
179+ case err != nil:
180+ logging.FromContext(wv.ctx).Warn().
181+ Err(err).
182+ Uint64("webview_id", uint64(wv.id)).
183+ Int32("browser_id", browserID).
184+ Msg("cef: gpu recovery reload failed")
185+ case reloaded:
186+ event().Str("action", "reload").Msg("cef: gpu recovery reload")
187+ case deferred:
188+ event().Str("action", "defer").Msg("cef: gpu recovery deferred until view is visible")
189+ }
190+}
191+
192+// claimGPURecovery starts a new attempt generation. If the view is hidden it
193+// marks recovery pending instead; otherwise it clears any stale pending flag.
194+// Everything happens under wv.mu so it is atomic with visibility changes.
195+func (wv *WebView) claimGPURecovery() (gen uint64, deferred bool) {
196+ wv.mu.Lock()
197+ defer wv.mu.Unlock()
198+ gen = wv.gpuRecoveryGen.Add(1)
199+ if wv.hiddenLocked() {
200+ wv.gpuRecoveryPending = true
201+ return gen, true
202+ }
203+ wv.gpuRecoveryPending = false
204+ return gen, false
205+}
206+
207+// deferGPURecoveryIfHidden atomically checks visibility and, when hidden,
208+// marks recovery pending so the next show resumes it.
209+func (wv *WebView) deferGPURecoveryIfHidden() bool {
210+ wv.mu.Lock()
211+ defer wv.mu.Unlock()
212+ if !wv.hiddenLocked() {
213+ return false
214+ }
215+ wv.gpuRecoveryPending = true
216+ return true
217+}
218+
219+// effectivelyHidden reports whether CEF was last told the view is hidden. A
220+// view whose visibility was never reported is treated as visible.
221+func (wv *WebView) effectivelyHidden() bool {
222+ wv.mu.RLock()
223+ defer wv.mu.RUnlock()
224+ return wv.hiddenLocked()
225+}
226+
227+// hiddenLocked is effectivelyHidden for callers already holding wv.mu.
228+func (wv *WebView) hiddenLocked() bool {
229+ return wv.effectiveVisibilityKnown && !wv.effectiveVisible
230+}
A internal/infrastructure/cef/gpu_recovery_test.go +754 -0
@@ -0,0 +1,754 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+// gpuRecoveryHost records render-host calls. pulses counts Invalidate calls
16+// (two per pulse); ops records the full call sequence.
17+type gpuRecoveryHost struct {
18+ purecef.BrowserHost
19+ pulses int
20+ ops []string
21+}
22+
23+func (h *gpuRecoveryHost) Invalidate(purecef.PaintElementType) {
24+ h.pulses++
25+ h.ops = append(h.ops, "Invalidate")
26+}
27+
28+func (h *gpuRecoveryHost) WasHidden(hidden int32) {
29+ if hidden != 0 {
30+ h.ops = append(h.ops, "WasHidden(1)")
31+ return
32+ }
33+ h.ops = append(h.ops, "WasHidden(0)")
34+}
35+
36+func (h *gpuRecoveryHost) NotifyScreenInfoChanged() {
37+ h.ops = append(h.ops, "NotifyScreenInfoChanged")
38+}
39+
40+func (h *gpuRecoveryHost) WasResized() { h.ops = append(h.ops, "WasResized") }
41+
42+type gpuRecoveryBrowser struct {
43+ purecef.Browser
44+ id int32
45+ reloads int
46+}
47+
48+func (b *gpuRecoveryBrowser) GetIdentifier() int32 { return b.id }
49+func (b *gpuRecoveryBrowser) Reload() { b.reloads++ }
50+
51+type scheduledCall struct {
52+ delay time.Duration
53+ fn func()
54+}
55+
56+type gpuRecoveryHarness struct {
57+ mu sync.Mutex
58+ scheduled []scheduledCall
59+ paints map[*Cef2gtkAdapter]int
60+ // beforePost, when set, runs right before a posted UI task executes. It
61+ // lets tests change view state between scheduling and execution.
62+ beforePost func()
63+ // holdPosts queues posted UI tasks instead of running them; runHeld runs
64+ // them later, like the CEF UI thread draining its queue.
65+ holdPosts bool
66+ held []purecef.Task
67+}
68+
69+func (h *gpuRecoveryHarness) runHeld() {
70+ h.mu.Lock()
71+ tasks := h.held
72+ h.held = nil
73+ h.mu.Unlock()
74+ for _, task := range tasks {
75+ task.Execute()
76+ }
77+}
78+
79+func (h *gpuRecoveryHarness) setPaints(wv *WebView, n int) {
80+ h.mu.Lock()
81+ defer h.mu.Unlock()
82+ h.paints[wv.viewBridge] = n
83+}
84+
85+func (h *gpuRecoveryHarness) pending() []scheduledCall {
86+ h.mu.Lock()
87+ defer h.mu.Unlock()
88+ return append([]scheduledCall(nil), h.scheduled...)
89+}
90+
91+// newGPURecoveryHarness replaces the CEF task/scheduler seams. Posted UI
92+// tasks run inline; scheduled callbacks are queued until flush is called.
93+func newGPURecoveryHarness(t *testing.T) *gpuRecoveryHarness {
94+ t.Helper()
95+ h := &gpuRecoveryHarness{paints: map[*Cef2gtkAdapter]int{}}
96+ oldNew, oldPost, oldAfter, oldPaints := cefNewTask, cefPostTask, cefScheduleAfter, gpuRecoveryPaintCount
97+ t.Cleanup(func() {
98+ cefNewTask, cefPostTask, cefScheduleAfter, gpuRecoveryPaintCount = oldNew, oldPost, oldAfter, oldPaints
99+ })
100+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
101+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
102+ if h.holdPosts {
103+ h.mu.Lock()
104+ h.held = append(h.held, task)
105+ h.mu.Unlock()
106+ return 1
107+ }
108+ if h.beforePost != nil {
109+ h.beforePost()
110+ }
111+ task.Execute()
112+ return 1
113+ }
114+ cefScheduleAfter = func(delay time.Duration, fn func()) {
115+ h.mu.Lock()
116+ defer h.mu.Unlock()
117+ h.scheduled = append(h.scheduled, scheduledCall{delay: delay, fn: fn})
118+ }
119+ gpuRecoveryPaintCount = func(bridge *Cef2gtkAdapter) int {
120+ h.mu.Lock()
121+ defer h.mu.Unlock()
122+ return h.paints[bridge]
123+ }
124+ return h
125+}
126+
127+// flush runs and clears everything currently scheduled.
128+func (h *gpuRecoveryHarness) flush() {
129+ h.mu.Lock()
130+ calls := h.scheduled
131+ h.scheduled = nil
132+ h.mu.Unlock()
133+ for _, c := range calls {
134+ c.fn()
135+ }
136+}
137+
138+func newGPURecoveryView(id port.WebViewID) (*WebView, *gpuRecoveryHost, *gpuRecoveryBrowser) {
139+ host := &gpuRecoveryHost{}
140+ browser := &gpuRecoveryBrowser{id: int32(id)}
141+ wv := &WebView{
142+ id: id,
143+ ctx: context.Background(),
144+ host: host,
145+ browser: browser,
146+ viewBridge: &Cef2gtkAdapter{},
147+ }
148+ return wv, host, browser
149+}
150+
151+func newGPURecoveryEngine(views ...*WebView) *Engine {
152+ e := &Engine{ctx: context.Background()}
153+ for _, wv := range views {
154+ e.activeWebViews.Store(wv.id, wv)
155+ }
156+ return e
157+}
158+
159+func TestGPURecovery_FirstLaunchDoesNothing(t *testing.T) {
160+ h := newGPURecoveryHarness(t)
161+ e := newGPURecoveryEngine()
162+
163+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
164+
165+ require.Empty(t, h.pending())
166+}
167+
168+func TestGPURecovery_NonGPULaunchDoesNothing(t *testing.T) {
169+ h := newGPURecoveryHarness(t)
170+ e := newGPURecoveryEngine()
171+
172+ e.recordChildProcessLaunch("renderer", "", "", "", "")
173+ e.recordChildProcessLaunch("renderer", "", "", "", "")
174+
175+ require.Empty(t, h.pending())
176+}
177+
178+func TestGPURecovery_SecondLaunchSchedulesOnePass(t *testing.T) {
179+ h := newGPURecoveryHarness(t)
180+ e := newGPURecoveryEngine()
181+
182+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
183+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
184+
185+ require.Len(t, h.pending(), 1)
186+ require.Equal(t, gpuRecoveryDelay, h.pending()[0].delay)
187+}
188+
189+func TestGPURecovery_BurstIsDebouncedToOnePass(t *testing.T) {
190+ h := newGPURecoveryHarness(t)
191+ wv, host, _ := newGPURecoveryView(1)
192+ e := newGPURecoveryEngine(wv)
193+
194+ for i := 0; i < 5; i++ {
195+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
196+ }
197+ require.Len(t, h.pending(), 1, "launches 2..5 must share one pass")
198+
199+ h.flush() // run the pass: one pulse per view
200+ require.Equal(t, 2, host.pulses, "one pulse = two Invalidate calls")
201+
202+ // After the pass ran, a new crash is allowed to schedule another pass.
203+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
204+ require.Len(t, h.pending(), 2, "verify callback plus a new pass")
205+}
206+
207+func TestGPURecovery_ViewThatPaintsIsNotReloaded(t *testing.T) {
208+ h := newGPURecoveryHarness(t)
209+ wv, host, browser := newGPURecoveryView(1)
210+ e := newGPURecoveryEngine(wv)
211+ h.setPaints(wv, 10)
212+
213+ e.runGPURecoveryPass()
214+ require.Equal(t, []string{
215+ "Invalidate", "WasHidden(1)", "WasHidden(0)",
216+ "NotifyScreenInfoChanged", "WasResized", "Invalidate",
217+ }, host.ops, "pulse must reset visibility and screen info, then repaint")
218+ require.Len(t, h.pending(), 1)
219+ require.Equal(t, gpuRecoveryVerifyDelay, h.pending()[0].delay)
220+
221+ h.setPaints(wv, 11) // a frame arrived after the pulse
222+ h.flush()
223+
224+ require.Zero(t, browser.reloads)
225+}
226+
227+func TestGPURecovery_ViewThatDoesNotPaintIsReloaded(t *testing.T) {
228+ h := newGPURecoveryHarness(t)
229+ wv, _, browser := newGPURecoveryView(1)
230+ e := newGPURecoveryEngine(wv)
231+ h.setPaints(wv, 10)
232+
233+ e.runGPURecoveryPass()
234+ h.flush() // paints unchanged
235+
236+ require.Equal(t, 1, browser.reloads)
237+}
238+
239+func TestGPURecovery_OnlyStalledViewsAreReloaded(t *testing.T) {
240+ h := newGPURecoveryHarness(t)
241+ healthy, _, healthyBrowser := newGPURecoveryView(1)
242+ frozen, _, frozenBrowser := newGPURecoveryView(2)
243+ e := newGPURecoveryEngine(healthy, frozen)
244+
245+ e.runGPURecoveryPass()
246+ h.setPaints(healthy, 1)
247+ h.flush()
248+
249+ require.Zero(t, healthyBrowser.reloads)
250+ require.Equal(t, 1, frozenBrowser.reloads)
251+}
252+
253+func TestGPURecovery_DestroyedViewsAreSkipped(t *testing.T) {
254+ h := newGPURecoveryHarness(t)
255+ wv, host, browser := newGPURecoveryView(1)
256+ wv.destroyed.Store(true)
257+ e := newGPURecoveryEngine(wv)
258+
259+ e.runGPURecoveryPass()
260+ h.flush()
261+
262+ require.Zero(t, host.pulses)
263+ require.Zero(t, browser.reloads)
264+}
265+
266+func TestGPURecovery_ViewDestroyedBeforeVerifyIsNotReloaded(t *testing.T) {
267+ h := newGPURecoveryHarness(t)
268+ wv, _, browser := newGPURecoveryView(1)
269+ e := newGPURecoveryEngine(wv)
270+
271+ e.runGPURecoveryPass()
272+ wv.destroyed.Store(true)
273+ h.flush()
274+
275+ require.Zero(t, browser.reloads)
276+}
277+
278+func TestGPURecovery_ViewWithoutHostOrBrowserIsSkipped(t *testing.T) {
279+ h := newGPURecoveryHarness(t)
280+ noHost, _, _ := newGPURecoveryView(1)
281+ noHost.host = nil
282+ noBrowser, host, _ := newGPURecoveryView(2)
283+ noBrowser.browser = nil
284+ e := newGPURecoveryEngine(noHost, noBrowser)
285+
286+ e.runGPURecoveryPass()
287+
288+ require.Empty(t, h.pending())
289+ require.Zero(t, host.pulses)
290+}
291+
292+func TestGPURecovery_HiddenViewIsDeferredUntilVisible(t *testing.T) {
293+ h := newGPURecoveryHarness(t)
294+ wv, host, browser := newGPURecoveryView(1)
295+ wv.effectiveVisibilityKnown = true
296+ wv.effectiveVisible = false
297+ e := newGPURecoveryEngine(wv)
298+
299+ e.runGPURecoveryPass()
300+ h.flush()
301+ require.Zero(t, host.pulses, "hidden views are not pulsed")
302+ require.Zero(t, browser.reloads, "hidden views are not reloaded")
303+ require.True(t, pendingOf(wv))
304+
305+ // Becoming visible restarts recovery for that view only.
306+ wv.applyEffectiveVisibility(host, true)
307+ require.False(t, pendingOf(wv))
308+ require.Equal(t, 2, host.pulses)
309+ h.flush() // no paint arrived
310+ require.Equal(t, 1, browser.reloads)
311+}
312+
313+func TestGPURecovery_ViewHiddenDuringVerifyIsDeferredNotReloaded(t *testing.T) {
314+ h := newGPURecoveryHarness(t)
315+ wv, _, browser := newGPURecoveryView(1)
316+ e := newGPURecoveryEngine(wv)
317+
318+ e.runGPURecoveryPass()
319+ wv.effectiveVisibilityKnown = true
320+ wv.effectiveVisible = false
321+ h.flush()
322+
323+ require.Zero(t, browser.reloads)
324+ require.True(t, pendingOf(wv))
325+}
326+
327+func TestGPURecovery_IsNotGatedByStallRecoveryEnv(t *testing.T) {
328+ h := newGPURecoveryHarness(t)
329+ t.Setenv(cefRenderStallRecoveryEnvVar, "0")
330+ wv, host, _ := newGPURecoveryView(1)
331+ e := newGPURecoveryEngine(wv)
332+
333+ e.runGPURecoveryPass()
334+
335+ require.False(t, renderStallRecoveryEnabled())
336+ require.Equal(t, 2, host.pulses)
337+ require.Len(t, h.pending(), 1)
338+}
339+
340+func pendingOf(wv *WebView) bool {
341+ wv.mu.RLock()
342+ defer wv.mu.RUnlock()
343+ return wv.gpuRecoveryPending
344+}
345+
346+func markHidden(wv *WebView) {
347+ wv.mu.Lock()
348+ wv.effectiveVisibilityKnown = true
349+ wv.effectiveVisible = false
350+ wv.mu.Unlock()
351+}
352+
353+// GO-001: the old code could observe "hidden", lose the race to a show that
354+// consumed a still-false pending flag, then store pending=true on a visible
355+// view. Check and store are now one critical section, so both orderings of
356+// "defer" vs "show" end with the view recovered exactly once.
357+
358+func TestGPURecovery_LostWakeup_DeferThenShowResumes(t *testing.T) {
359+ h := newGPURecoveryHarness(t)
360+ wv, host, browser := newGPURecoveryView(1)
361+ markHidden(wv)
362+
363+ require.True(t, wv.deferGPURecoveryIfHidden())
364+ require.True(t, pendingOf(wv))
365+
366+ wv.applyEffectiveVisibility(host, true)
367+ require.False(t, pendingOf(wv))
368+ require.Equal(t, 2, host.pulses, "show consumed pending and restarted recovery")
369+ h.flush()
370+ require.Equal(t, 1, browser.reloads)
371+}
372+
373+func TestGPURecovery_LostWakeup_ShowThenDeferDoesNotLeavePending(t *testing.T) {
374+ h := newGPURecoveryHarness(t)
375+ wv, host, _ := newGPURecoveryView(1)
376+ markHidden(wv)
377+
378+ wv.applyEffectiveVisibility(host, true) // show wins the race
379+ require.False(t, wv.deferGPURecoveryIfHidden(), "visible view must not be deferred")
380+ require.False(t, pendingOf(wv), "no stranded pending flag on a visible view")
381+
382+ _, deferred := wv.claimGPURecovery()
383+ require.False(t, deferred)
384+ require.False(t, pendingOf(wv))
385+ require.Empty(t, h.pending())
386+}
387+
388+func TestGPURecovery_ConcurrentShowAndDeferNeverStrandsPending(t *testing.T) {
389+ newGPURecoveryHarness(t)
390+ wv, host, _ := newGPURecoveryView(1)
391+ markHidden(wv)
392+
393+ var wg sync.WaitGroup
394+ wg.Add(2)
395+ go func() {
396+ defer wg.Done()
397+ for i := 0; i < 200; i++ {
398+ wv.applyEffectiveVisibility(host, false)
399+ wv.applyEffectiveVisibility(host, true)
400+ }
401+ }()
402+ go func() {
403+ defer wg.Done()
404+ for i := 0; i < 200; i++ {
405+ wv.deferGPURecoveryIfHidden()
406+ wv.claimGPURecovery()
407+ }
408+ }()
409+ wg.Wait()
410+
411+ // The visibility goroutine ended visible. Whatever the interleaving, a
412+ // visible view must not keep a pending flag nobody will ever consume.
413+ wv.applyEffectiveVisibility(host, true)
414+ require.False(t, pendingOf(wv))
415+}
416+
417+// GO-003: visibility is revalidated when the pulse task runs on the UI thread.
418+
419+func TestGPURecovery_PulseSkipsViewHiddenAfterScheduling(t *testing.T) {
420+ h := newGPURecoveryHarness(t)
421+ wv, host, browser := newGPURecoveryView(1)
422+ e := newGPURecoveryEngine(wv)
423+ h.beforePost = func() { markHidden(wv) } // hidden after scheduling
424+
425+ e.runGPURecoveryPass()
426+
427+ require.Zero(t, host.pulses, "hidden view must not be pulsed (WasHidden(0) would show it)")
428+ require.True(t, pendingOf(wv))
429+ h.beforePost = nil
430+ h.flush() // verify of the same attempt: still hidden
431+ require.Zero(t, browser.reloads)
432+
433+ wv.applyEffectiveVisibility(host, true)
434+ require.Equal(t, 2, host.pulses)
435+}
436+
437+func TestRenderStallPulseSkipsViewHiddenAfterScheduling(t *testing.T) {
438+ h := newGPURecoveryHarness(t)
439+ t.Setenv(cefRenderStallRecoveryEnvVar, "1")
440+ wv, host, _ := newGPURecoveryView(1)
441+ h.beforePost = func() { markHidden(wv) }
442+
443+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
444+
445+ require.Zero(t, host.pulses)
446+}
447+
448+func TestRenderStallRecoveryStillPulsesVisibleViewWhenEnabled(t *testing.T) {
449+ h := newGPURecoveryHarness(t)
450+ t.Setenv(cefRenderStallRecoveryEnvVar, "1")
451+ wv, host, _ := newGPURecoveryView(1)
452+
453+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
454+
455+ require.Equal(t, 2, host.pulses)
456+ require.Len(t, h.pending(), 1)
457+ require.Equal(t, renderRecoveryVerifyDelay, h.pending()[0].delay)
458+}
459+
460+func TestRenderStallRecoveryDisabledByDefault(t *testing.T) {
461+ h := newGPURecoveryHarness(t)
462+ t.Setenv(cefRenderStallRecoveryEnvVar, "0")
463+ wv, host, _ := newGPURecoveryView(1)
464+
465+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
466+
467+ require.Zero(t, host.pulses)
468+ require.Empty(t, h.pending())
469+}
470+
471+// GO-004: per-view recovery generation.
472+
473+func TestGPURecovery_StaleVerifyIgnoredAfterNewerAttempt(t *testing.T) {
474+ h := newGPURecoveryHarness(t)
475+ wv, host, browser := newGPURecoveryView(1)
476+ e := newGPURecoveryEngine(wv)
477+
478+ e.runGPURecoveryPass() // attempt 1
479+ first := h.pending()
480+ require.Len(t, first, 1)
481+ e.runGPURecoveryPass() // a new crash supersedes it: attempt 2
482+ require.Equal(t, 4, host.pulses)
483+ require.Len(t, h.pending(), 2)
484+
485+ first[0].fn() // stale verify of attempt 1 fires "too early"
486+ require.Zero(t, browser.reloads, "stale verify must not reload")
487+
488+ h.flush() // attempt 1 again (stale) plus attempt 2
489+ require.Equal(t, 1, browser.reloads, "only the newest attempt reloads, once")
490+}
491+
492+func TestGPURecovery_ShowAfterNewCrashSupersedesInFlightAttempt(t *testing.T) {
493+ h := newGPURecoveryHarness(t)
494+ wv, host, browser := newGPURecoveryView(1)
495+ e := newGPURecoveryEngine(wv)
496+
497+ e.runGPURecoveryPass() // visible attempt 1
498+ stale := h.pending()[0]
499+ markHidden(wv)
500+ e.runGPURecoveryPass() // hidden: attempt 2 deferred, pending
501+ require.True(t, pendingOf(wv))
502+
503+ wv.applyEffectiveVisibility(host, true) // attempt 3 starts, clears pending
504+ require.False(t, pendingOf(wv))
505+
506+ stale.fn()
507+ require.Zero(t, browser.reloads, "verify of attempt 1 is stale")
508+ h.flush()
509+ require.Equal(t, 1, browser.reloads, "exactly one reload for the newest attempt")
510+}
511+
512+func TestGPURecovery_StalePulseTaskSkipped(t *testing.T) {
513+ h := newGPURecoveryHarness(t)
514+ wv, host, _ := newGPURecoveryView(1)
515+ e := newGPURecoveryEngine(wv)
516+ // A newer attempt starts between posting and executing the pulse task.
517+ h.beforePost = func() { wv.gpuRecoveryGen.Add(1) }
518+
519+ e.runGPURecoveryPass()
520+
521+ require.Zero(t, host.pulses)
522+}
523+
524+// GO-002: the bridge is cleared under wv.mu by teardown.
525+
526+func clearBridge(wv *WebView) {
527+ wv.mu.Lock()
528+ wv.viewBridge = nil
529+ wv.mu.Unlock()
530+}
531+
532+func TestGPURecovery_BridgeClearedBeforePulseTaskRuns(t *testing.T) {
533+ h := newGPURecoveryHarness(t)
534+ wv, host, _ := newGPURecoveryView(1)
535+ e := newGPURecoveryEngine(wv)
536+ h.beforePost = func() { clearBridge(wv) }
537+
538+ e.runGPURecoveryPass()
539+
540+ require.Zero(t, host.pulses)
541+}
542+
543+func TestGPURecovery_BridgeClearedBeforeVerify(t *testing.T) {
544+ h := newGPURecoveryHarness(t)
545+ wv, _, browser := newGPURecoveryView(1)
546+ e := newGPURecoveryEngine(wv)
547+ e.runGPURecoveryPass()
548+
549+ clearBridge(wv)
550+ h.flush()
551+
552+ require.Zero(t, browser.reloads)
553+}
554+
555+func TestGPURecovery_BridgeClearedConcurrentlyIsRaceFree(t *testing.T) {
556+ h := newGPURecoveryHarness(t)
557+ wv, _, _ := newGPURecoveryView(1)
558+ e := newGPURecoveryEngine(wv)
559+
560+ var wg sync.WaitGroup
561+ wg.Add(2)
562+ go func() {
563+ defer wg.Done()
564+ for i := 0; i < 100; i++ {
565+ e.runGPURecoveryPass()
566+ h.flush()
567+ }
568+ }()
569+ go func() {
570+ defer wg.Done()
571+ clearBridge(wv)
572+ }()
573+ wg.Wait()
574+
575+ // After teardown nothing may touch the bridge again.
576+ e.runGPURecoveryPass()
577+ h.flush()
578+ require.Nil(t, wv.liveBridge())
579+}
580+
581+// queuedGTK makes wv dispatch synchronous GTK work onto a queue the test
582+// drains explicitly, so state can change while a reload is "waiting on GTK".
583+type queuedGTK struct{ ops chan func() }
584+
585+func queueGTKDispatch(wv *WebView) *queuedGTK {
586+ q := &queuedGTK{ops: make(chan func(), 4)}
587+ wv.gtkSyncIsOwner = func() bool { return false }
588+ wv.gtkSyncDispatch = func(fn func()) { q.ops <- fn }
589+ wv.gtkSyncTimeout = time.Minute
590+ return q
591+}
592+
593+// verifyAsync runs the single scheduled verify callback on its own goroutine
594+// (it blocks inside the synchronous GTK dispatch) and returns once the reload
595+// operation is queued. The returned func waits for the verify to finish.
596+func (q *queuedGTK) runQueued(t *testing.T) {
597+ t.Helper()
598+ select {
599+ case fn := <-q.ops:
600+ fn()
601+ default:
602+ t.Fatal("no GTK operation queued")
603+ }
604+}
605+
606+func startVerifyBlockedOnGTK(t *testing.T, h *gpuRecoveryHarness, q *queuedGTK) (wait func()) {
607+ t.Helper()
608+ pending := h.pending()
609+ require.Len(t, pending, 1)
610+ h.mu.Lock()
611+ h.scheduled = nil
612+ h.mu.Unlock()
613+ done := make(chan struct{})
614+ go func() {
615+ defer close(done)
616+ pending[0].fn()
617+ }()
618+ // Wait (without sleeping) until the reload operation is queued on GTK.
619+ // Fail fast if verify returns without queuing one, instead of hanging.
620+ select {
621+ case op := <-q.ops:
622+ q.ops <- op
623+ case <-done:
624+ t.Fatal("verify finished without queuing a GTK reload operation")
625+ case <-time.After(5 * time.Second):
626+ t.Fatal("timed out waiting for the GTK reload operation")
627+ }
628+ return func() {
629+ select {
630+ case <-done:
631+ case <-time.After(5 * time.Second):
632+ t.Fatal("timed out waiting for verify to finish")
633+ }
634+ }
635+}
636+
637+func TestGPURecovery_ReloadSkippedWhenSupersededWhileWaitingOnGTK(t *testing.T) {
638+ h := newGPURecoveryHarness(t)
639+ wv, _, browser := newGPURecoveryView(1)
640+ q := queueGTKDispatch(wv)
641+ e := newGPURecoveryEngine(wv)
642+ e.runGPURecoveryPass() // attempt 1 pulses, verify scheduled
643+ wait := startVerifyBlockedOnGTK(t, h, q)
644+
645+ wv.claimGPURecovery() // a newer attempt starts while the reload is queued
646+ q.runQueued(t) // GTK finally drains the old reload
647+ wait()
648+
649+ require.Zero(t, browser.reloads, "reload of a superseded attempt must not navigate")
650+}
651+
652+func TestGPURecovery_ReloadSkippedAndDeferredWhenHiddenWhileWaitingOnGTK(t *testing.T) {
653+ h := newGPURecoveryHarness(t)
654+ wv, host, browser := newGPURecoveryView(1)
655+ q := queueGTKDispatch(wv)
656+ e := newGPURecoveryEngine(wv)
657+ e.runGPURecoveryPass()
658+ wait := startVerifyBlockedOnGTK(t, h, q)
659+
660+ markHidden(wv) // hidden while the reload is queued
661+ q.runQueued(t)
662+ wait()
663+
664+ require.Zero(t, browser.reloads)
665+ require.True(t, pendingOf(wv), "hidden view must be marked pending, not dropped")
666+
667+ // Showing it later resumes recovery.
668+ pulsesBefore := host.pulses
669+ wv.applyEffectiveVisibility(host, true)
670+ require.Equal(t, pulsesBefore+2, host.pulses)
671+}
672+
673+func TestGPURecovery_ReloadSkippedWhenDestroyedWhileWaitingOnGTK(t *testing.T) {
674+ h := newGPURecoveryHarness(t)
675+ wv, _, browser := newGPURecoveryView(1)
676+ q := queueGTKDispatch(wv)
677+ e := newGPURecoveryEngine(wv)
678+ e.runGPURecoveryPass()
679+ wait := startVerifyBlockedOnGTK(t, h, q)
680+
681+ wv.destroyed.Store(true)
682+ q.runQueued(t)
683+ wait()
684+
685+ require.Zero(t, browser.reloads)
686+}
687+
688+func TestGPURecovery_ReloadProceedsWhenStillValidAfterGTKWait(t *testing.T) {
689+ h := newGPURecoveryHarness(t)
690+ wv, _, browser := newGPURecoveryView(1)
691+ q := queueGTKDispatch(wv)
692+ e := newGPURecoveryEngine(wv)
693+ e.runGPURecoveryPass()
694+ wait := startVerifyBlockedOnGTK(t, h, q)
695+
696+ q.runQueued(t)
697+ wait()
698+
699+ require.Equal(t, 1, browser.reloads)
700+}
701+
702+// GO-005: baseline and verify timer are taken when the pulse runs.
703+
704+func TestGPURecovery_VerifyNotScheduledUntilPulseRuns(t *testing.T) {
705+ h := newGPURecoveryHarness(t)
706+ wv, host, _ := newGPURecoveryView(1)
707+ e := newGPURecoveryEngine(wv)
708+ h.holdPosts = true
709+
710+ e.runGPURecoveryPass()
711+ require.Empty(t, h.pending(), "no verify timer while the pulse task is still queued")
712+ require.Zero(t, host.pulses)
713+
714+ h.holdPosts = false
715+ h.runHeld()
716+ require.Equal(t, 2, host.pulses)
717+ require.Len(t, h.pending(), 1)
718+ require.Equal(t, gpuRecoveryVerifyDelay, h.pending()[0].delay)
719+}
720+
721+func TestGPURecovery_BaselineExcludesPaintsBeforePulseRuns(t *testing.T) {
722+ h := newGPURecoveryHarness(t)
723+ wv, _, browser := newGPURecoveryView(1)
724+ e := newGPURecoveryEngine(wv)
725+ h.setPaints(wv, 10)
726+ h.holdPosts = true
727+
728+ e.runGPURecoveryPass()
729+ h.setPaints(wv, 15) // paints landed while the pulse task was queued
730+ h.runHeld() // baseline is captured now: 15
731+ h.flush() // no paint since the pulse
732+
733+ require.Equal(t, 1, browser.reloads, "paints before the pulse must not count as recovery")
734+}
735+
736+func TestGPURecovery_NoVerifyForSkippedOrStalePulse(t *testing.T) {
737+ h := newGPURecoveryHarness(t)
738+ wv, _, _ := newGPURecoveryView(1)
739+ e := newGPURecoveryEngine(wv)
740+ h.holdPosts = true
741+
742+ e.runGPURecoveryPass()
743+ markHidden(wv) // hidden before the queued pulse runs: skipped
744+ h.runHeld()
745+ require.Empty(t, h.pending(), "skipped pulse schedules no verify")
746+
747+ wv.mu.Lock()
748+ wv.effectiveVisible = true
749+ wv.mu.Unlock()
750+ e.runGPURecoveryPass() // attempt 3 queued
751+ wv.claimGPURecovery() // attempt 4 supersedes it
752+ h.runHeld()
753+ require.Empty(t, h.pending(), "stale pulse schedules no verify")
754+}
@@ -0,0 +1,754 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+// gpuRecoveryHost records render-host calls. pulses counts Invalidate calls
16+// (two per pulse); ops records the full call sequence.
17+type gpuRecoveryHost struct {
18+ purecef.BrowserHost
19+ pulses int
20+ ops []string
21+}
22+
23+func (h *gpuRecoveryHost) Invalidate(purecef.PaintElementType) {
24+ h.pulses++
25+ h.ops = append(h.ops, "Invalidate")
26+}
27+
28+func (h *gpuRecoveryHost) WasHidden(hidden int32) {
29+ if hidden != 0 {
30+ h.ops = append(h.ops, "WasHidden(1)")
31+ return
32+ }
33+ h.ops = append(h.ops, "WasHidden(0)")
34+}
35+
36+func (h *gpuRecoveryHost) NotifyScreenInfoChanged() {
37+ h.ops = append(h.ops, "NotifyScreenInfoChanged")
38+}
39+
40+func (h *gpuRecoveryHost) WasResized() { h.ops = append(h.ops, "WasResized") }
41+
42+type gpuRecoveryBrowser struct {
43+ purecef.Browser
44+ id int32
45+ reloads int
46+}
47+
48+func (b *gpuRecoveryBrowser) GetIdentifier() int32 { return b.id }
49+func (b *gpuRecoveryBrowser) Reload() { b.reloads++ }
50+
51+type scheduledCall struct {
52+ delay time.Duration
53+ fn func()
54+}
55+
56+type gpuRecoveryHarness struct {
57+ mu sync.Mutex
58+ scheduled []scheduledCall
59+ paints map[*Cef2gtkAdapter]int
60+ // beforePost, when set, runs right before a posted UI task executes. It
61+ // lets tests change view state between scheduling and execution.
62+ beforePost func()
63+ // holdPosts queues posted UI tasks instead of running them; runHeld runs
64+ // them later, like the CEF UI thread draining its queue.
65+ holdPosts bool
66+ held []purecef.Task
67+}
68+
69+func (h *gpuRecoveryHarness) runHeld() {
70+ h.mu.Lock()
71+ tasks := h.held
72+ h.held = nil
73+ h.mu.Unlock()
74+ for _, task := range tasks {
75+ task.Execute()
76+ }
77+}
78+
79+func (h *gpuRecoveryHarness) setPaints(wv *WebView, n int) {
80+ h.mu.Lock()
81+ defer h.mu.Unlock()
82+ h.paints[wv.viewBridge] = n
83+}
84+
85+func (h *gpuRecoveryHarness) pending() []scheduledCall {
86+ h.mu.Lock()
87+ defer h.mu.Unlock()
88+ return append([]scheduledCall(nil), h.scheduled...)
89+}
90+
91+// newGPURecoveryHarness replaces the CEF task/scheduler seams. Posted UI
92+// tasks run inline; scheduled callbacks are queued until flush is called.
93+func newGPURecoveryHarness(t *testing.T) *gpuRecoveryHarness {
94+ t.Helper()
95+ h := &gpuRecoveryHarness{paints: map[*Cef2gtkAdapter]int{}}
96+ oldNew, oldPost, oldAfter, oldPaints := cefNewTask, cefPostTask, cefScheduleAfter, gpuRecoveryPaintCount
97+ t.Cleanup(func() {
98+ cefNewTask, cefPostTask, cefScheduleAfter, gpuRecoveryPaintCount = oldNew, oldPost, oldAfter, oldPaints
99+ })
100+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
101+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
102+ if h.holdPosts {
103+ h.mu.Lock()
104+ h.held = append(h.held, task)
105+ h.mu.Unlock()
106+ return 1
107+ }
108+ if h.beforePost != nil {
109+ h.beforePost()
110+ }
111+ task.Execute()
112+ return 1
113+ }
114+ cefScheduleAfter = func(delay time.Duration, fn func()) {
115+ h.mu.Lock()
116+ defer h.mu.Unlock()
117+ h.scheduled = append(h.scheduled, scheduledCall{delay: delay, fn: fn})
118+ }
119+ gpuRecoveryPaintCount = func(bridge *Cef2gtkAdapter) int {
120+ h.mu.Lock()
121+ defer h.mu.Unlock()
122+ return h.paints[bridge]
123+ }
124+ return h
125+}
126+
127+// flush runs and clears everything currently scheduled.
128+func (h *gpuRecoveryHarness) flush() {
129+ h.mu.Lock()
130+ calls := h.scheduled
131+ h.scheduled = nil
132+ h.mu.Unlock()
133+ for _, c := range calls {
134+ c.fn()
135+ }
136+}
137+
138+func newGPURecoveryView(id port.WebViewID) (*WebView, *gpuRecoveryHost, *gpuRecoveryBrowser) {
139+ host := &gpuRecoveryHost{}
140+ browser := &gpuRecoveryBrowser{id: int32(id)}
141+ wv := &WebView{
142+ id: id,
143+ ctx: context.Background(),
144+ host: host,
145+ browser: browser,
146+ viewBridge: &Cef2gtkAdapter{},
147+ }
148+ return wv, host, browser
149+}
150+
151+func newGPURecoveryEngine(views ...*WebView) *Engine {
152+ e := &Engine{ctx: context.Background()}
153+ for _, wv := range views {
154+ e.activeWebViews.Store(wv.id, wv)
155+ }
156+ return e
157+}
158+
159+func TestGPURecovery_FirstLaunchDoesNothing(t *testing.T) {
160+ h := newGPURecoveryHarness(t)
161+ e := newGPURecoveryEngine()
162+
163+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
164+
165+ require.Empty(t, h.pending())
166+}
167+
168+func TestGPURecovery_NonGPULaunchDoesNothing(t *testing.T) {
169+ h := newGPURecoveryHarness(t)
170+ e := newGPURecoveryEngine()
171+
172+ e.recordChildProcessLaunch("renderer", "", "", "", "")
173+ e.recordChildProcessLaunch("renderer", "", "", "", "")
174+
175+ require.Empty(t, h.pending())
176+}
177+
178+func TestGPURecovery_SecondLaunchSchedulesOnePass(t *testing.T) {
179+ h := newGPURecoveryHarness(t)
180+ e := newGPURecoveryEngine()
181+
182+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
183+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
184+
185+ require.Len(t, h.pending(), 1)
186+ require.Equal(t, gpuRecoveryDelay, h.pending()[0].delay)
187+}
188+
189+func TestGPURecovery_BurstIsDebouncedToOnePass(t *testing.T) {
190+ h := newGPURecoveryHarness(t)
191+ wv, host, _ := newGPURecoveryView(1)
192+ e := newGPURecoveryEngine(wv)
193+
194+ for i := 0; i < 5; i++ {
195+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
196+ }
197+ require.Len(t, h.pending(), 1, "launches 2..5 must share one pass")
198+
199+ h.flush() // run the pass: one pulse per view
200+ require.Equal(t, 2, host.pulses, "one pulse = two Invalidate calls")
201+
202+ // After the pass ran, a new crash is allowed to schedule another pass.
203+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
204+ require.Len(t, h.pending(), 2, "verify callback plus a new pass")
205+}
206+
207+func TestGPURecovery_ViewThatPaintsIsNotReloaded(t *testing.T) {
208+ h := newGPURecoveryHarness(t)
209+ wv, host, browser := newGPURecoveryView(1)
210+ e := newGPURecoveryEngine(wv)
211+ h.setPaints(wv, 10)
212+
213+ e.runGPURecoveryPass()
214+ require.Equal(t, []string{
215+ "Invalidate", "WasHidden(1)", "WasHidden(0)",
216+ "NotifyScreenInfoChanged", "WasResized", "Invalidate",
217+ }, host.ops, "pulse must reset visibility and screen info, then repaint")
218+ require.Len(t, h.pending(), 1)
219+ require.Equal(t, gpuRecoveryVerifyDelay, h.pending()[0].delay)
220+
221+ h.setPaints(wv, 11) // a frame arrived after the pulse
222+ h.flush()
223+
224+ require.Zero(t, browser.reloads)
225+}
226+
227+func TestGPURecovery_ViewThatDoesNotPaintIsReloaded(t *testing.T) {
228+ h := newGPURecoveryHarness(t)
229+ wv, _, browser := newGPURecoveryView(1)
230+ e := newGPURecoveryEngine(wv)
231+ h.setPaints(wv, 10)
232+
233+ e.runGPURecoveryPass()
234+ h.flush() // paints unchanged
235+
236+ require.Equal(t, 1, browser.reloads)
237+}
238+
239+func TestGPURecovery_OnlyStalledViewsAreReloaded(t *testing.T) {
240+ h := newGPURecoveryHarness(t)
241+ healthy, _, healthyBrowser := newGPURecoveryView(1)
242+ frozen, _, frozenBrowser := newGPURecoveryView(2)
243+ e := newGPURecoveryEngine(healthy, frozen)
244+
245+ e.runGPURecoveryPass()
246+ h.setPaints(healthy, 1)
247+ h.flush()
248+
249+ require.Zero(t, healthyBrowser.reloads)
250+ require.Equal(t, 1, frozenBrowser.reloads)
251+}
252+
253+func TestGPURecovery_DestroyedViewsAreSkipped(t *testing.T) {
254+ h := newGPURecoveryHarness(t)
255+ wv, host, browser := newGPURecoveryView(1)
256+ wv.destroyed.Store(true)
257+ e := newGPURecoveryEngine(wv)
258+
259+ e.runGPURecoveryPass()
260+ h.flush()
261+
262+ require.Zero(t, host.pulses)
263+ require.Zero(t, browser.reloads)
264+}
265+
266+func TestGPURecovery_ViewDestroyedBeforeVerifyIsNotReloaded(t *testing.T) {
267+ h := newGPURecoveryHarness(t)
268+ wv, _, browser := newGPURecoveryView(1)
269+ e := newGPURecoveryEngine(wv)
270+
271+ e.runGPURecoveryPass()
272+ wv.destroyed.Store(true)
273+ h.flush()
274+
275+ require.Zero(t, browser.reloads)
276+}
277+
278+func TestGPURecovery_ViewWithoutHostOrBrowserIsSkipped(t *testing.T) {
279+ h := newGPURecoveryHarness(t)
280+ noHost, _, _ := newGPURecoveryView(1)
281+ noHost.host = nil
282+ noBrowser, host, _ := newGPURecoveryView(2)
283+ noBrowser.browser = nil
284+ e := newGPURecoveryEngine(noHost, noBrowser)
285+
286+ e.runGPURecoveryPass()
287+
288+ require.Empty(t, h.pending())
289+ require.Zero(t, host.pulses)
290+}
291+
292+func TestGPURecovery_HiddenViewIsDeferredUntilVisible(t *testing.T) {
293+ h := newGPURecoveryHarness(t)
294+ wv, host, browser := newGPURecoveryView(1)
295+ wv.effectiveVisibilityKnown = true
296+ wv.effectiveVisible = false
297+ e := newGPURecoveryEngine(wv)
298+
299+ e.runGPURecoveryPass()
300+ h.flush()
301+ require.Zero(t, host.pulses, "hidden views are not pulsed")
302+ require.Zero(t, browser.reloads, "hidden views are not reloaded")
303+ require.True(t, pendingOf(wv))
304+
305+ // Becoming visible restarts recovery for that view only.
306+ wv.applyEffectiveVisibility(host, true)
307+ require.False(t, pendingOf(wv))
308+ require.Equal(t, 2, host.pulses)
309+ h.flush() // no paint arrived
310+ require.Equal(t, 1, browser.reloads)
311+}
312+
313+func TestGPURecovery_ViewHiddenDuringVerifyIsDeferredNotReloaded(t *testing.T) {
314+ h := newGPURecoveryHarness(t)
315+ wv, _, browser := newGPURecoveryView(1)
316+ e := newGPURecoveryEngine(wv)
317+
318+ e.runGPURecoveryPass()
319+ wv.effectiveVisibilityKnown = true
320+ wv.effectiveVisible = false
321+ h.flush()
322+
323+ require.Zero(t, browser.reloads)
324+ require.True(t, pendingOf(wv))
325+}
326+
327+func TestGPURecovery_IsNotGatedByStallRecoveryEnv(t *testing.T) {
328+ h := newGPURecoveryHarness(t)
329+ t.Setenv(cefRenderStallRecoveryEnvVar, "0")
330+ wv, host, _ := newGPURecoveryView(1)
331+ e := newGPURecoveryEngine(wv)
332+
333+ e.runGPURecoveryPass()
334+
335+ require.False(t, renderStallRecoveryEnabled())
336+ require.Equal(t, 2, host.pulses)
337+ require.Len(t, h.pending(), 1)
338+}
339+
340+func pendingOf(wv *WebView) bool {
341+ wv.mu.RLock()
342+ defer wv.mu.RUnlock()
343+ return wv.gpuRecoveryPending
344+}
345+
346+func markHidden(wv *WebView) {
347+ wv.mu.Lock()
348+ wv.effectiveVisibilityKnown = true
349+ wv.effectiveVisible = false
350+ wv.mu.Unlock()
351+}
352+
353+// GO-001: the old code could observe "hidden", lose the race to a show that
354+// consumed a still-false pending flag, then store pending=true on a visible
355+// view. Check and store are now one critical section, so both orderings of
356+// "defer" vs "show" end with the view recovered exactly once.
357+
358+func TestGPURecovery_LostWakeup_DeferThenShowResumes(t *testing.T) {
359+ h := newGPURecoveryHarness(t)
360+ wv, host, browser := newGPURecoveryView(1)
361+ markHidden(wv)
362+
363+ require.True(t, wv.deferGPURecoveryIfHidden())
364+ require.True(t, pendingOf(wv))
365+
366+ wv.applyEffectiveVisibility(host, true)
367+ require.False(t, pendingOf(wv))
368+ require.Equal(t, 2, host.pulses, "show consumed pending and restarted recovery")
369+ h.flush()
370+ require.Equal(t, 1, browser.reloads)
371+}
372+
373+func TestGPURecovery_LostWakeup_ShowThenDeferDoesNotLeavePending(t *testing.T) {
374+ h := newGPURecoveryHarness(t)
375+ wv, host, _ := newGPURecoveryView(1)
376+ markHidden(wv)
377+
378+ wv.applyEffectiveVisibility(host, true) // show wins the race
379+ require.False(t, wv.deferGPURecoveryIfHidden(), "visible view must not be deferred")
380+ require.False(t, pendingOf(wv), "no stranded pending flag on a visible view")
381+
382+ _, deferred := wv.claimGPURecovery()
383+ require.False(t, deferred)
384+ require.False(t, pendingOf(wv))
385+ require.Empty(t, h.pending())
386+}
387+
388+func TestGPURecovery_ConcurrentShowAndDeferNeverStrandsPending(t *testing.T) {
389+ newGPURecoveryHarness(t)
390+ wv, host, _ := newGPURecoveryView(1)
391+ markHidden(wv)
392+
393+ var wg sync.WaitGroup
394+ wg.Add(2)
395+ go func() {
396+ defer wg.Done()
397+ for i := 0; i < 200; i++ {
398+ wv.applyEffectiveVisibility(host, false)
399+ wv.applyEffectiveVisibility(host, true)
400+ }
401+ }()
402+ go func() {
403+ defer wg.Done()
404+ for i := 0; i < 200; i++ {
405+ wv.deferGPURecoveryIfHidden()
406+ wv.claimGPURecovery()
407+ }
408+ }()
409+ wg.Wait()
410+
411+ // The visibility goroutine ended visible. Whatever the interleaving, a
412+ // visible view must not keep a pending flag nobody will ever consume.
413+ wv.applyEffectiveVisibility(host, true)
414+ require.False(t, pendingOf(wv))
415+}
416+
417+// GO-003: visibility is revalidated when the pulse task runs on the UI thread.
418+
419+func TestGPURecovery_PulseSkipsViewHiddenAfterScheduling(t *testing.T) {
420+ h := newGPURecoveryHarness(t)
421+ wv, host, browser := newGPURecoveryView(1)
422+ e := newGPURecoveryEngine(wv)
423+ h.beforePost = func() { markHidden(wv) } // hidden after scheduling
424+
425+ e.runGPURecoveryPass()
426+
427+ require.Zero(t, host.pulses, "hidden view must not be pulsed (WasHidden(0) would show it)")
428+ require.True(t, pendingOf(wv))
429+ h.beforePost = nil
430+ h.flush() // verify of the same attempt: still hidden
431+ require.Zero(t, browser.reloads)
432+
433+ wv.applyEffectiveVisibility(host, true)
434+ require.Equal(t, 2, host.pulses)
435+}
436+
437+func TestRenderStallPulseSkipsViewHiddenAfterScheduling(t *testing.T) {
438+ h := newGPURecoveryHarness(t)
439+ t.Setenv(cefRenderStallRecoveryEnvVar, "1")
440+ wv, host, _ := newGPURecoveryView(1)
441+ h.beforePost = func() { markHidden(wv) }
442+
443+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
444+
445+ require.Zero(t, host.pulses)
446+}
447+
448+func TestRenderStallRecoveryStillPulsesVisibleViewWhenEnabled(t *testing.T) {
449+ h := newGPURecoveryHarness(t)
450+ t.Setenv(cefRenderStallRecoveryEnvVar, "1")
451+ wv, host, _ := newGPURecoveryView(1)
452+
453+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
454+
455+ require.Equal(t, 2, host.pulses)
456+ require.Len(t, h.pending(), 1)
457+ require.Equal(t, renderRecoveryVerifyDelay, h.pending()[0].delay)
458+}
459+
460+func TestRenderStallRecoveryDisabledByDefault(t *testing.T) {
461+ h := newGPURecoveryHarness(t)
462+ t.Setenv(cefRenderStallRecoveryEnvVar, "0")
463+ wv, host, _ := newGPURecoveryView(1)
464+
465+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
466+
467+ require.Zero(t, host.pulses)
468+ require.Empty(t, h.pending())
469+}
470+
471+// GO-004: per-view recovery generation.
472+
473+func TestGPURecovery_StaleVerifyIgnoredAfterNewerAttempt(t *testing.T) {
474+ h := newGPURecoveryHarness(t)
475+ wv, host, browser := newGPURecoveryView(1)
476+ e := newGPURecoveryEngine(wv)
477+
478+ e.runGPURecoveryPass() // attempt 1
479+ first := h.pending()
480+ require.Len(t, first, 1)
481+ e.runGPURecoveryPass() // a new crash supersedes it: attempt 2
482+ require.Equal(t, 4, host.pulses)
483+ require.Len(t, h.pending(), 2)
484+
485+ first[0].fn() // stale verify of attempt 1 fires "too early"
486+ require.Zero(t, browser.reloads, "stale verify must not reload")
487+
488+ h.flush() // attempt 1 again (stale) plus attempt 2
489+ require.Equal(t, 1, browser.reloads, "only the newest attempt reloads, once")
490+}
491+
492+func TestGPURecovery_ShowAfterNewCrashSupersedesInFlightAttempt(t *testing.T) {
493+ h := newGPURecoveryHarness(t)
494+ wv, host, browser := newGPURecoveryView(1)
495+ e := newGPURecoveryEngine(wv)
496+
497+ e.runGPURecoveryPass() // visible attempt 1
498+ stale := h.pending()[0]
499+ markHidden(wv)
500+ e.runGPURecoveryPass() // hidden: attempt 2 deferred, pending
501+ require.True(t, pendingOf(wv))
502+
503+ wv.applyEffectiveVisibility(host, true) // attempt 3 starts, clears pending
504+ require.False(t, pendingOf(wv))
505+
506+ stale.fn()
507+ require.Zero(t, browser.reloads, "verify of attempt 1 is stale")
508+ h.flush()
509+ require.Equal(t, 1, browser.reloads, "exactly one reload for the newest attempt")
510+}
511+
512+func TestGPURecovery_StalePulseTaskSkipped(t *testing.T) {
513+ h := newGPURecoveryHarness(t)
514+ wv, host, _ := newGPURecoveryView(1)
515+ e := newGPURecoveryEngine(wv)
516+ // A newer attempt starts between posting and executing the pulse task.
517+ h.beforePost = func() { wv.gpuRecoveryGen.Add(1) }
518+
519+ e.runGPURecoveryPass()
520+
521+ require.Zero(t, host.pulses)
522+}
523+
524+// GO-002: the bridge is cleared under wv.mu by teardown.
525+
526+func clearBridge(wv *WebView) {
527+ wv.mu.Lock()
528+ wv.viewBridge = nil
529+ wv.mu.Unlock()
530+}
531+
532+func TestGPURecovery_BridgeClearedBeforePulseTaskRuns(t *testing.T) {
533+ h := newGPURecoveryHarness(t)
534+ wv, host, _ := newGPURecoveryView(1)
535+ e := newGPURecoveryEngine(wv)
536+ h.beforePost = func() { clearBridge(wv) }
537+
538+ e.runGPURecoveryPass()
539+
540+ require.Zero(t, host.pulses)
541+}
542+
543+func TestGPURecovery_BridgeClearedBeforeVerify(t *testing.T) {
544+ h := newGPURecoveryHarness(t)
545+ wv, _, browser := newGPURecoveryView(1)
546+ e := newGPURecoveryEngine(wv)
547+ e.runGPURecoveryPass()
548+
549+ clearBridge(wv)
550+ h.flush()
551+
552+ require.Zero(t, browser.reloads)
553+}
554+
555+func TestGPURecovery_BridgeClearedConcurrentlyIsRaceFree(t *testing.T) {
556+ h := newGPURecoveryHarness(t)
557+ wv, _, _ := newGPURecoveryView(1)
558+ e := newGPURecoveryEngine(wv)
559+
560+ var wg sync.WaitGroup
561+ wg.Add(2)
562+ go func() {
563+ defer wg.Done()
564+ for i := 0; i < 100; i++ {
565+ e.runGPURecoveryPass()
566+ h.flush()
567+ }
568+ }()
569+ go func() {
570+ defer wg.Done()
571+ clearBridge(wv)
572+ }()
573+ wg.Wait()
574+
575+ // After teardown nothing may touch the bridge again.
576+ e.runGPURecoveryPass()
577+ h.flush()
578+ require.Nil(t, wv.liveBridge())
579+}
580+
581+// queuedGTK makes wv dispatch synchronous GTK work onto a queue the test
582+// drains explicitly, so state can change while a reload is "waiting on GTK".
583+type queuedGTK struct{ ops chan func() }
584+
585+func queueGTKDispatch(wv *WebView) *queuedGTK {
586+ q := &queuedGTK{ops: make(chan func(), 4)}
587+ wv.gtkSyncIsOwner = func() bool { return false }
588+ wv.gtkSyncDispatch = func(fn func()) { q.ops <- fn }
589+ wv.gtkSyncTimeout = time.Minute
590+ return q
591+}
592+
593+// verifyAsync runs the single scheduled verify callback on its own goroutine
594+// (it blocks inside the synchronous GTK dispatch) and returns once the reload
595+// operation is queued. The returned func waits for the verify to finish.
596+func (q *queuedGTK) runQueued(t *testing.T) {
597+ t.Helper()
598+ select {
599+ case fn := <-q.ops:
600+ fn()
601+ default:
602+ t.Fatal("no GTK operation queued")
603+ }
604+}
605+
606+func startVerifyBlockedOnGTK(t *testing.T, h *gpuRecoveryHarness, q *queuedGTK) (wait func()) {
607+ t.Helper()
608+ pending := h.pending()
609+ require.Len(t, pending, 1)
610+ h.mu.Lock()
611+ h.scheduled = nil
612+ h.mu.Unlock()
613+ done := make(chan struct{})
614+ go func() {
615+ defer close(done)
616+ pending[0].fn()
617+ }()
618+ // Wait (without sleeping) until the reload operation is queued on GTK.
619+ // Fail fast if verify returns without queuing one, instead of hanging.
620+ select {
621+ case op := <-q.ops:
622+ q.ops <- op
623+ case <-done:
624+ t.Fatal("verify finished without queuing a GTK reload operation")
625+ case <-time.After(5 * time.Second):
626+ t.Fatal("timed out waiting for the GTK reload operation")
627+ }
628+ return func() {
629+ select {
630+ case <-done:
631+ case <-time.After(5 * time.Second):
632+ t.Fatal("timed out waiting for verify to finish")
633+ }
634+ }
635+}
636+
637+func TestGPURecovery_ReloadSkippedWhenSupersededWhileWaitingOnGTK(t *testing.T) {
638+ h := newGPURecoveryHarness(t)
639+ wv, _, browser := newGPURecoveryView(1)
640+ q := queueGTKDispatch(wv)
641+ e := newGPURecoveryEngine(wv)
642+ e.runGPURecoveryPass() // attempt 1 pulses, verify scheduled
643+ wait := startVerifyBlockedOnGTK(t, h, q)
644+
645+ wv.claimGPURecovery() // a newer attempt starts while the reload is queued
646+ q.runQueued(t) // GTK finally drains the old reload
647+ wait()
648+
649+ require.Zero(t, browser.reloads, "reload of a superseded attempt must not navigate")
650+}
651+
652+func TestGPURecovery_ReloadSkippedAndDeferredWhenHiddenWhileWaitingOnGTK(t *testing.T) {
653+ h := newGPURecoveryHarness(t)
654+ wv, host, browser := newGPURecoveryView(1)
655+ q := queueGTKDispatch(wv)
656+ e := newGPURecoveryEngine(wv)
657+ e.runGPURecoveryPass()
658+ wait := startVerifyBlockedOnGTK(t, h, q)
659+
660+ markHidden(wv) // hidden while the reload is queued
661+ q.runQueued(t)
662+ wait()
663+
664+ require.Zero(t, browser.reloads)
665+ require.True(t, pendingOf(wv), "hidden view must be marked pending, not dropped")
666+
667+ // Showing it later resumes recovery.
668+ pulsesBefore := host.pulses
669+ wv.applyEffectiveVisibility(host, true)
670+ require.Equal(t, pulsesBefore+2, host.pulses)
671+}
672+
673+func TestGPURecovery_ReloadSkippedWhenDestroyedWhileWaitingOnGTK(t *testing.T) {
674+ h := newGPURecoveryHarness(t)
675+ wv, _, browser := newGPURecoveryView(1)
676+ q := queueGTKDispatch(wv)
677+ e := newGPURecoveryEngine(wv)
678+ e.runGPURecoveryPass()
679+ wait := startVerifyBlockedOnGTK(t, h, q)
680+
681+ wv.destroyed.Store(true)
682+ q.runQueued(t)
683+ wait()
684+
685+ require.Zero(t, browser.reloads)
686+}
687+
688+func TestGPURecovery_ReloadProceedsWhenStillValidAfterGTKWait(t *testing.T) {
689+ h := newGPURecoveryHarness(t)
690+ wv, _, browser := newGPURecoveryView(1)
691+ q := queueGTKDispatch(wv)
692+ e := newGPURecoveryEngine(wv)
693+ e.runGPURecoveryPass()
694+ wait := startVerifyBlockedOnGTK(t, h, q)
695+
696+ q.runQueued(t)
697+ wait()
698+
699+ require.Equal(t, 1, browser.reloads)
700+}
701+
702+// GO-005: baseline and verify timer are taken when the pulse runs.
703+
704+func TestGPURecovery_VerifyNotScheduledUntilPulseRuns(t *testing.T) {
705+ h := newGPURecoveryHarness(t)
706+ wv, host, _ := newGPURecoveryView(1)
707+ e := newGPURecoveryEngine(wv)
708+ h.holdPosts = true
709+
710+ e.runGPURecoveryPass()
711+ require.Empty(t, h.pending(), "no verify timer while the pulse task is still queued")
712+ require.Zero(t, host.pulses)
713+
714+ h.holdPosts = false
715+ h.runHeld()
716+ require.Equal(t, 2, host.pulses)
717+ require.Len(t, h.pending(), 1)
718+ require.Equal(t, gpuRecoveryVerifyDelay, h.pending()[0].delay)
719+}
720+
721+func TestGPURecovery_BaselineExcludesPaintsBeforePulseRuns(t *testing.T) {
722+ h := newGPURecoveryHarness(t)
723+ wv, _, browser := newGPURecoveryView(1)
724+ e := newGPURecoveryEngine(wv)
725+ h.setPaints(wv, 10)
726+ h.holdPosts = true
727+
728+ e.runGPURecoveryPass()
729+ h.setPaints(wv, 15) // paints landed while the pulse task was queued
730+ h.runHeld() // baseline is captured now: 15
731+ h.flush() // no paint since the pulse
732+
733+ require.Equal(t, 1, browser.reloads, "paints before the pulse must not count as recovery")
734+}
735+
736+func TestGPURecovery_NoVerifyForSkippedOrStalePulse(t *testing.T) {
737+ h := newGPURecoveryHarness(t)
738+ wv, _, _ := newGPURecoveryView(1)
739+ e := newGPURecoveryEngine(wv)
740+ h.holdPosts = true
741+
742+ e.runGPURecoveryPass()
743+ markHidden(wv) // hidden before the queued pulse runs: skipped
744+ h.runHeld()
745+ require.Empty(t, h.pending(), "skipped pulse schedules no verify")
746+
747+ wv.mu.Lock()
748+ wv.effectiveVisible = true
749+ wv.mu.Unlock()
750+ e.runGPURecoveryPass() // attempt 3 queued
751+ wv.claimGPURecovery() // attempt 4 supersedes it
752+ h.runHeld()
753+ require.Empty(t, h.pending(), "stale pulse schedules no verify")
754+}
M internal/infrastructure/cef/render_diagnostics.go +114 -33
@@ -10,7 +10,12 @@ import (
1010 "github.com/bnema/dumber/internal/logging"
1111 )
1212
13-const renderStallRecoveryCooldown = 10 * time.Second
13+const (
14+ renderStallRecoveryCooldown = 10 * time.Second
15+ // renderRecoveryVerifyDelay is how long a render pulse gets to produce a
16+ // new accelerated paint before its outcome is evaluated.
17+ renderRecoveryVerifyDelay = 2 * time.Second
18+)
1419
1520 func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, _ port.WebViewID, reason string, classification renderStallClassification) {
1621 if e == nil {
@@ -141,57 +146,45 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, _ *Cef2gtkAdapter,
141146 }
142147 wv.renderStallMu.Unlock()
143148
144- wv.mu.RLock()
145- host := wv.host
146- browser := wv.browser
147- wv.mu.RUnlock()
148- if host == nil {
149+ target, ok := wv.renderPulseTarget()
150+ if !ok {
149151 return
150152 }
151- browserID := int32(0)
152- if browser != nil {
153- browserID = browser.GetIdentifier()
154- }
153+ browserID := target.browserID
155154 logging.FromContext(wv.ctx).Warn().
156155 Uint64("webview_id", uint64(wv.id)).
157156 Int32("browser_id", browserID).
158157 Int("accelerated_paints_before", paintsBefore).
159158 Msg("cef: render stall recovery pulse scheduled")
160- task := cefNewTask(cefTaskFunc(func() {
161- if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil || host == nil {
159+ const msg = "cef: render stall recovery pulse"
160+ posted := wv.postRenderPulseTask(msg, func(host purecef.BrowserHost, _ *Cef2gtkAdapter) {
161+ // Hidden views legitimately produce no paints and the pulse would
162+ // mark them visible, so only pulse views CEF believes are shown.
163+ if wv.effectivelyHidden() {
164+ logging.FromContext(wv.ctx).Warn().
165+ Uint64("webview_id", uint64(wv.id)).
166+ Int32("browser_id", browserID).
167+ Msg(msg + " skipped: view hidden")
162168 return
163169 }
164- host.Invalidate(purecef.PaintElementTypePetView)
165- host.WasHidden(1)
166- host.WasHidden(0)
167- host.NotifyScreenInfoChanged()
168- host.WasResized()
169- host.Invalidate(purecef.PaintElementTypePetView)
170+ pulseRender(host)
170171 logging.FromContext(wv.ctx).Warn().
171172 Uint64("webview_id", uint64(wv.id)).
172173 Int32("browser_id", browserID).
173- Msg("cef: render stall recovery pulse executed")
174- }))
175- if task == nil {
176- return
177- }
178- result := cefPostTask(purecef.ThreadIDTidUi, task)
179- logging.FromContext(wv.ctx).Warn().
180- Uint64("webview_id", uint64(wv.id)).
181- Int32("browser_id", browserID).
182- Int32("post_result", result).
183- Msg("cef: render stall recovery pulse posted")
184- if result != 1 {
174+ Msg(msg + " executed")
175+ })
176+ if !posted {
185177 return
186178 }
187179 wv.renderStallMu.Lock()
188180 wv.renderStallRecoveryLastAt = now
189181 wv.renderStallMu.Unlock()
190- cefScheduleAfter(2*time.Second, func() {
191- if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil {
182+ cefScheduleAfter(renderRecoveryVerifyDelay, func() {
183+ bridge := wv.liveBridge()
184+ if bridge == nil || wv.ctx == nil {
192185 return
193186 }
194- after := wv.viewBridge.Diagnostics().AcceleratedPaints
187+ after := bridge.Diagnostics().AcceleratedPaints
195188 logging.FromContext(wv.ctx).Warn().
196189 Uint64("webview_id", uint64(wv.id)).
197190 Int32("browser_id", browserID).
@@ -201,3 +194,91 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, _ *Cef2gtkAdapter,
201194 Msg("cef: render stall recovery pulse result")
202195 })
203196 }
197+
198+// renderPulseTargetInfo is a consistent snapshot of the state a render pulse
199+// needs, captured under wv.mu.
200+type renderPulseTargetInfo struct {
201+ host purecef.BrowserHost
202+ bridge *Cef2gtkAdapter
203+ browserID int32
204+ hasBrowser bool
205+}
206+
207+// renderPulseTarget snapshots a view that can receive a render pulse. It
208+// reports false when the view is destroyed, has no CEF host yet, or has no
209+// live bridge. The bridge is cleared under wv.mu by teardown, so it must only
210+// be read through this snapshot or liveBridge.
211+func (wv *WebView) renderPulseTarget() (renderPulseTargetInfo, bool) {
212+ if wv == nil || wv.ctx == nil || wv.destroyed.Load() {
213+ return renderPulseTargetInfo{}, false
214+ }
215+ wv.mu.RLock()
216+ host := wv.host
217+ browser := wv.browser
218+ bridge := wv.viewBridge
219+ wv.mu.RUnlock()
220+ if host == nil || bridge == nil {
221+ return renderPulseTargetInfo{}, false
222+ }
223+ info := renderPulseTargetInfo{host: host, bridge: bridge}
224+ if browser != nil {
225+ info.browserID = browser.GetIdentifier()
226+ info.hasBrowser = true
227+ }
228+ return info, true
229+}
230+
231+// liveBridge returns the view bridge, or nil once the view is destroyed or its
232+// bridge torn down. Safe from any goroutine.
233+func (wv *WebView) liveBridge() *Cef2gtkAdapter {
234+ if wv == nil || wv.destroyed.Load() {
235+ return nil
236+ }
237+ wv.mu.RLock()
238+ defer wv.mu.RUnlock()
239+ return wv.viewBridge
240+}
241+
242+// pulseRender asks CEF to produce a fresh OSR frame.
243+func pulseRender(host purecef.BrowserHost) {
244+ host.Invalidate(purecef.PaintElementTypePetView)
245+ host.WasHidden(1)
246+ host.WasHidden(0)
247+ host.NotifyScreenInfoChanged()
248+ host.WasResized()
249+ host.Invalidate(purecef.PaintElementTypePetView)
250+}
251+
252+// postRenderPulseTask posts body to the CEF UI thread. The host and bridge are
253+// revalidated when the task executes; body only runs for a live view. msg is
254+// the log message prefix. It reports whether CEF accepted the task.
255+func (wv *WebView) postRenderPulseTask(msg string, body func(host purecef.BrowserHost, bridge *Cef2gtkAdapter)) bool {
256+ target, ok := wv.renderPulseTarget()
257+ if !ok {
258+ return false
259+ }
260+ host := target.host
261+ task := cefNewTask(cefTaskFunc(func() {
262+ bridge := wv.liveBridge()
263+ if bridge == nil {
264+ return
265+ }
266+ wv.mu.RLock()
267+ currentHost := wv.host
268+ wv.mu.RUnlock()
269+ if currentHost != host {
270+ return
271+ }
272+ body(host, bridge)
273+ }))
274+ if task == nil {
275+ return false
276+ }
277+ result := cefPostTask(purecef.ThreadIDTidUi, task)
278+ logging.FromContext(wv.ctx).Warn().
279+ Uint64("webview_id", uint64(wv.id)).
280+ Int32("browser_id", target.browserID).
281+ Int32("post_result", result).
282+ Msg(msg + " posted")
283+ return result == 1
284+}
@@ -10,7 +10,12 @@ import (
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11 )11 )
12 12
13-const renderStallRecoveryCooldown = 10 * time.Second13+const (
14+ renderStallRecoveryCooldown = 10 * time.Second
15+ // renderRecoveryVerifyDelay is how long a render pulse gets to produce a
16+ // new accelerated paint before its outcome is evaluated.
17+ renderRecoveryVerifyDelay = 2 * time.Second
18+)
14 19
15 func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, _ port.WebViewID, reason string, classification renderStallClassification) {20 func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, _ port.WebViewID, reason string, classification renderStallClassification) {
16 if e == nil {21 if e == nil {
@@ -141,57 +146,45 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, _ *Cef2gtkAdapter,
141 }146 }
142 wv.renderStallMu.Unlock()147 wv.renderStallMu.Unlock()
143 148
144- wv.mu.RLock()149+ target, ok := wv.renderPulseTarget()
145- host := wv.host150+ if !ok {
146- browser := wv.browser
147- wv.mu.RUnlock()
148- if host == nil {
149 return151 return
150 }152 }
151- browserID := int32(0)153+ browserID := target.browserID
152- if browser != nil {
153- browserID = browser.GetIdentifier()
154- }
155 logging.FromContext(wv.ctx).Warn().154 logging.FromContext(wv.ctx).Warn().
156 Uint64("webview_id", uint64(wv.id)).155 Uint64("webview_id", uint64(wv.id)).
157 Int32("browser_id", browserID).156 Int32("browser_id", browserID).
158 Int("accelerated_paints_before", paintsBefore).157 Int("accelerated_paints_before", paintsBefore).
159 Msg("cef: render stall recovery pulse scheduled")158 Msg("cef: render stall recovery pulse scheduled")
160- task := cefNewTask(cefTaskFunc(func() {159+ const msg = "cef: render stall recovery pulse"
161- if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil || host == nil {160+ posted := wv.postRenderPulseTask(msg, func(host purecef.BrowserHost, _ *Cef2gtkAdapter) {
161+ // Hidden views legitimately produce no paints and the pulse would
162+ // mark them visible, so only pulse views CEF believes are shown.
163+ if wv.effectivelyHidden() {
164+ logging.FromContext(wv.ctx).Warn().
165+ Uint64("webview_id", uint64(wv.id)).
166+ Int32("browser_id", browserID).
167+ Msg(msg + " skipped: view hidden")
162 return168 return
163 }169 }
164- host.Invalidate(purecef.PaintElementTypePetView)170+ pulseRender(host)
165- host.WasHidden(1)
166- host.WasHidden(0)
167- host.NotifyScreenInfoChanged()
168- host.WasResized()
169- host.Invalidate(purecef.PaintElementTypePetView)
170 logging.FromContext(wv.ctx).Warn().171 logging.FromContext(wv.ctx).Warn().
171 Uint64("webview_id", uint64(wv.id)).172 Uint64("webview_id", uint64(wv.id)).
172 Int32("browser_id", browserID).173 Int32("browser_id", browserID).
173- Msg("cef: render stall recovery pulse executed")174+ Msg(msg + " executed")
174- }))175+ })
175- if task == nil {176+ if !posted {
176- return
177- }
178- result := cefPostTask(purecef.ThreadIDTidUi, task)
179- logging.FromContext(wv.ctx).Warn().
180- Uint64("webview_id", uint64(wv.id)).
181- Int32("browser_id", browserID).
182- Int32("post_result", result).
183- Msg("cef: render stall recovery pulse posted")
184- if result != 1 {
185 return177 return
186 }178 }
187 wv.renderStallMu.Lock()179 wv.renderStallMu.Lock()
188 wv.renderStallRecoveryLastAt = now180 wv.renderStallRecoveryLastAt = now
189 wv.renderStallMu.Unlock()181 wv.renderStallMu.Unlock()
190- cefScheduleAfter(2*time.Second, func() {182+ cefScheduleAfter(renderRecoveryVerifyDelay, func() {
191- if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil {183+ bridge := wv.liveBridge()
184+ if bridge == nil || wv.ctx == nil {
192 return185 return
193 }186 }
194- after := wv.viewBridge.Diagnostics().AcceleratedPaints187+ after := bridge.Diagnostics().AcceleratedPaints
195 logging.FromContext(wv.ctx).Warn().188 logging.FromContext(wv.ctx).Warn().
196 Uint64("webview_id", uint64(wv.id)).189 Uint64("webview_id", uint64(wv.id)).
197 Int32("browser_id", browserID).190 Int32("browser_id", browserID).
@@ -201,3 +194,91 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, _ *Cef2gtkAdapter,
201 Msg("cef: render stall recovery pulse result")194 Msg("cef: render stall recovery pulse result")
202 })195 })
203 }196 }
197+
198+// renderPulseTargetInfo is a consistent snapshot of the state a render pulse
199+// needs, captured under wv.mu.
200+type renderPulseTargetInfo struct {
201+ host purecef.BrowserHost
202+ bridge *Cef2gtkAdapter
203+ browserID int32
204+ hasBrowser bool
205+}
206+
207+// renderPulseTarget snapshots a view that can receive a render pulse. It
208+// reports false when the view is destroyed, has no CEF host yet, or has no
209+// live bridge. The bridge is cleared under wv.mu by teardown, so it must only
210+// be read through this snapshot or liveBridge.
211+func (wv *WebView) renderPulseTarget() (renderPulseTargetInfo, bool) {
212+ if wv == nil || wv.ctx == nil || wv.destroyed.Load() {
213+ return renderPulseTargetInfo{}, false
214+ }
215+ wv.mu.RLock()
216+ host := wv.host
217+ browser := wv.browser
218+ bridge := wv.viewBridge
219+ wv.mu.RUnlock()
220+ if host == nil || bridge == nil {
221+ return renderPulseTargetInfo{}, false
222+ }
223+ info := renderPulseTargetInfo{host: host, bridge: bridge}
224+ if browser != nil {
225+ info.browserID = browser.GetIdentifier()
226+ info.hasBrowser = true
227+ }
228+ return info, true
229+}
230+
231+// liveBridge returns the view bridge, or nil once the view is destroyed or its
232+// bridge torn down. Safe from any goroutine.
233+func (wv *WebView) liveBridge() *Cef2gtkAdapter {
234+ if wv == nil || wv.destroyed.Load() {
235+ return nil
236+ }
237+ wv.mu.RLock()
238+ defer wv.mu.RUnlock()
239+ return wv.viewBridge
240+}
241+
242+// pulseRender asks CEF to produce a fresh OSR frame.
243+func pulseRender(host purecef.BrowserHost) {
244+ host.Invalidate(purecef.PaintElementTypePetView)
245+ host.WasHidden(1)
246+ host.WasHidden(0)
247+ host.NotifyScreenInfoChanged()
248+ host.WasResized()
249+ host.Invalidate(purecef.PaintElementTypePetView)
250+}
251+
252+// postRenderPulseTask posts body to the CEF UI thread. The host and bridge are
253+// revalidated when the task executes; body only runs for a live view. msg is
254+// the log message prefix. It reports whether CEF accepted the task.
255+func (wv *WebView) postRenderPulseTask(msg string, body func(host purecef.BrowserHost, bridge *Cef2gtkAdapter)) bool {
256+ target, ok := wv.renderPulseTarget()
257+ if !ok {
258+ return false
259+ }
260+ host := target.host
261+ task := cefNewTask(cefTaskFunc(func() {
262+ bridge := wv.liveBridge()
263+ if bridge == nil {
264+ return
265+ }
266+ wv.mu.RLock()
267+ currentHost := wv.host
268+ wv.mu.RUnlock()
269+ if currentHost != host {
270+ return
271+ }
272+ body(host, bridge)
273+ }))
274+ if task == nil {
275+ return false
276+ }
277+ result := cefPostTask(purecef.ThreadIDTidUi, task)
278+ logging.FromContext(wv.ctx).Warn().
279+ Uint64("webview_id", uint64(wv.id)).
280+ Int32("browser_id", target.browserID).
281+ Int32("post_result", result).
282+ Msg(msg + " posted")
283+ return result == 1
284+}
M internal/infrastructure/cef/webview.go +22 -3
@@ -130,9 +130,16 @@ type WebView struct {
130130 renderStallStop chan struct{}
131131 renderStallDone chan struct{}
132132 renderStallRecoveryLastAt time.Time
133- latestProfileSnapshot cef2gtk.ProfileSnapshot
134- previousProfileSnapshot cef2gtk.ProfileSnapshot
135- latestProfileSnapshotAt time.Time
133+ // gpuRecoveryPending marks a view that was hidden during a GPU recovery
134+ // attempt and must be recovered once it becomes visible. It is guarded by
135+ // mu, together with effectiveVisible, so deferring and showing are atomic.
136+ gpuRecoveryPending bool
137+ // gpuRecoveryGen identifies the latest GPU recovery attempt. Callbacks of
138+ // older attempts see a stale value and bail.
139+ gpuRecoveryGen atomic.Uint64
140+ latestProfileSnapshot cef2gtk.ProfileSnapshot
141+ previousProfileSnapshot cef2gtk.ProfileSnapshot
142+ latestProfileSnapshotAt time.Time
136143
137144 removeSizeObserver func()
138145 viewportSyncMu sync.Mutex
@@ -458,6 +465,15 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error {
458465 }
459466
460467 func (wv *WebView) Reload(_ context.Context) error {
468+ return wv.reloadGuarded(nil)
469+}
470+
471+// reloadGuarded reloads the current page. When proceed is non-nil it runs on
472+// the GTK thread inside the navigation callback, immediately before the
473+// reload is issued, and a false result skips the reload. It lets callers that
474+// waited on the synchronous GTK dispatch revalidate their preconditions. It
475+// must not block on the GTK thread and must not be called with wv.mu held.
476+func (wv *WebView) reloadGuarded(proceed func() bool) error {
461477 if wv.destroyed.Load() {
462478 return errDestroyed
463479 }
@@ -468,6 +484,9 @@ func (wv *WebView) Reload(_ context.Context) error {
468484 if browser == nil {
469485 return errNoBrowser
470486 }
487+ if proceed != nil && !proceed() {
488+ return nil
489+ }
471490 browser.Reload()
472491 return nil
473492 })
@@ -130,9 +130,16 @@ type WebView struct {
130 renderStallStop chan struct{}130 renderStallStop chan struct{}
131 renderStallDone chan struct{}131 renderStallDone chan struct{}
132 renderStallRecoveryLastAt time.Time132 renderStallRecoveryLastAt time.Time
133- latestProfileSnapshot cef2gtk.ProfileSnapshot133+ // gpuRecoveryPending marks a view that was hidden during a GPU recovery
134- previousProfileSnapshot cef2gtk.ProfileSnapshot134+ // attempt and must be recovered once it becomes visible. It is guarded by
135- latestProfileSnapshotAt time.Time135+ // mu, together with effectiveVisible, so deferring and showing are atomic.
136+ gpuRecoveryPending bool
137+ // gpuRecoveryGen identifies the latest GPU recovery attempt. Callbacks of
138+ // older attempts see a stale value and bail.
139+ gpuRecoveryGen atomic.Uint64
140+ latestProfileSnapshot cef2gtk.ProfileSnapshot
141+ previousProfileSnapshot cef2gtk.ProfileSnapshot
142+ latestProfileSnapshotAt time.Time
136 143
137 removeSizeObserver func()144 removeSizeObserver func()
138 viewportSyncMu sync.Mutex145 viewportSyncMu sync.Mutex
@@ -458,6 +465,15 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error {
458 }465 }
459 466
460 func (wv *WebView) Reload(_ context.Context) error {467 func (wv *WebView) Reload(_ context.Context) error {
468+ return wv.reloadGuarded(nil)
469+}
470+
471+// reloadGuarded reloads the current page. When proceed is non-nil it runs on
472+// the GTK thread inside the navigation callback, immediately before the
473+// reload is issued, and a false result skips the reload. It lets callers that
474+// waited on the synchronous GTK dispatch revalidate their preconditions. It
475+// must not block on the GTK thread and must not be called with wv.mu held.
476+func (wv *WebView) reloadGuarded(proceed func() bool) error {
461 if wv.destroyed.Load() {477 if wv.destroyed.Load() {
462 return errDestroyed478 return errDestroyed
463 }479 }
@@ -468,6 +484,9 @@ func (wv *WebView) Reload(_ context.Context) error {
468 if browser == nil {484 if browser == nil {
469 return errNoBrowser485 return errNoBrowser
470 }486 }
487+ if proceed != nil && !proceed() {
488+ return nil
489+ }
471 browser.Reload()490 browser.Reload()
472 return nil491 return nil
473 })492 })
M internal/infrastructure/cef/webview_scroll_cancel.go +7 -2
@@ -28,10 +28,15 @@ func (wv *WebView) scrollCancelTarget() scrollCancelBridge {
2828 if wv.scrollCancelSeam != nil {
2929 return wv.scrollCancelSeam
3030 }
31- if wv.viewBridge == nil {
31+ // Teardown clears viewBridge under wv.mu from the GTK thread while this
32+ // runs from any thread (CEF UI, timers, GTK).
33+ wv.mu.RLock()
34+ bridge := wv.viewBridge
35+ wv.mu.RUnlock()
36+ if bridge == nil {
3237 return nil
3338 }
34- return wv.viewBridge
39+ return bridge
3540 }
3641
3742 // scrollMotionRetired immediately retires animated scroll motion and release
@@ -28,10 +28,15 @@ func (wv *WebView) scrollCancelTarget() scrollCancelBridge {
28 if wv.scrollCancelSeam != nil {28 if wv.scrollCancelSeam != nil {
29 return wv.scrollCancelSeam29 return wv.scrollCancelSeam
30 }30 }
31- if wv.viewBridge == nil {31+ // Teardown clears viewBridge under wv.mu from the GTK thread while this
32+ // runs from any thread (CEF UI, timers, GTK).
33+ wv.mu.RLock()
34+ bridge := wv.viewBridge
35+ wv.mu.RUnlock()
36+ if bridge == nil {
32 return nil37 return nil
33 }38 }
34- return wv.viewBridge39+ return bridge
35 }40 }
36 41
37 // scrollMotionRetired immediately retires animated scroll motion and release42 // scrollMotionRetired immediately retires animated scroll motion and release
M internal/infrastructure/cef/webview_viewport.go +10 -0
@@ -622,9 +622,19 @@ func (wv *WebView) applyEffectiveVisibility(host viewportSyncBrowserHost, visibl
622622 }
623623 wv.effectiveVisibilityKnown = true
624624 wv.effectiveVisible = visible
625+ // Consume the deferred GPU recovery in the same critical section that
626+ // publishes visibility: a concurrent deferral either saw the view hidden
627+ // (and set pending before this point) or sees it visible and does not defer.
628+ resumeGPURecovery := visible && wv.gpuRecoveryPending
629+ if visible {
630+ wv.gpuRecoveryPending = false
631+ }
625632 wv.mu.Unlock()
626633 if visible {
627634 host.WasHidden(0)
635+ if resumeGPURecovery {
636+ wv.startGPURecovery()
637+ }
628638 return
629639 }
630640 // The hidden transition retires scroll motion: a re-shown page never
@@ -622,9 +622,19 @@ func (wv *WebView) applyEffectiveVisibility(host viewportSyncBrowserHost, visibl
622 }622 }
623 wv.effectiveVisibilityKnown = true623 wv.effectiveVisibilityKnown = true
624 wv.effectiveVisible = visible624 wv.effectiveVisible = visible
625+ // Consume the deferred GPU recovery in the same critical section that
626+ // publishes visibility: a concurrent deferral either saw the view hidden
627+ // (and set pending before this point) or sees it visible and does not defer.
628+ resumeGPURecovery := visible && wv.gpuRecoveryPending
629+ if visible {
630+ wv.gpuRecoveryPending = false
631+ }
625 wv.mu.Unlock()632 wv.mu.Unlock()
626 if visible {633 if visible {
627 host.WasHidden(0)634 host.WasHidden(0)
635+ if resumeGPURecovery {
636+ wv.startGPURecovery()
637+ }
628 return638 return
629 }639 }
630 // The hidden transition retires scroll motion: a re-shown page never640 // The hidden transition retires scroll motion: a re-shown page never

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close