detached e2a53aa

fix(cef): recover all views after a GPU process relaunch

When the Chromium GPU process crashes and is relaunched, existing OSR
browsers stop receiving accelerated paints and the whole browser looks
frozen.

Detect every GPU process launch after the first one and, after a short
debounce, run a recovery pass on all live views: pulse the render host
on the CEF UI thread, then reload any visible view that still has not
painted two seconds after the pulse. Hidden views are deferred and
recovered when they become visible. A per-view generation discards
stale pulses and reloads, and the reload revalidates lifecycle,
visibility and generation inside the GTK navigation callback.

Also read the view bridge under the view lock in scrollCancelTarget,
which raced with bridge teardown.
commit
e2a53aacdce707db8cefec9b8148cbb08e25e510
author
brice · · 2026-10-15 07:43 UTC
parent
44cde30
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +1128 -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 +738 -0
@@ -0,0 +1,738 @@
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+type gpuRecoveryHost struct {
16+ purecef.BrowserHost
17+ pulses int
18+}
19+
20+func (h *gpuRecoveryHost) Invalidate(purecef.PaintElementType) { h.pulses++ }
21+func (h *gpuRecoveryHost) WasHidden(int32) {}
22+func (h *gpuRecoveryHost) NotifyScreenInfoChanged() {}
23+func (h *gpuRecoveryHost) WasResized() {}
24+
25+type gpuRecoveryBrowser struct {
26+ purecef.Browser
27+ id int32
28+ reloads int
29+}
30+
31+func (b *gpuRecoveryBrowser) GetIdentifier() int32 { return b.id }
32+func (b *gpuRecoveryBrowser) Reload() { b.reloads++ }
33+
34+type scheduledCall struct {
35+ delay time.Duration
36+ fn func()
37+}
38+
39+type gpuRecoveryHarness struct {
40+ mu sync.Mutex
41+ scheduled []scheduledCall
42+ paints map[*Cef2gtkAdapter]int
43+ // beforePost, when set, runs right before a posted UI task executes. It
44+ // lets tests change view state between scheduling and execution.
45+ beforePost func()
46+ // holdPosts queues posted UI tasks instead of running them; runHeld runs
47+ // them later, like the CEF UI thread draining its queue.
48+ holdPosts bool
49+ held []purecef.Task
50+}
51+
52+func (h *gpuRecoveryHarness) runHeld() {
53+ h.mu.Lock()
54+ tasks := h.held
55+ h.held = nil
56+ h.mu.Unlock()
57+ for _, task := range tasks {
58+ task.Execute()
59+ }
60+}
61+
62+func (h *gpuRecoveryHarness) setPaints(wv *WebView, n int) {
63+ h.mu.Lock()
64+ defer h.mu.Unlock()
65+ h.paints[wv.viewBridge] = n
66+}
67+
68+func (h *gpuRecoveryHarness) pending() []scheduledCall {
69+ h.mu.Lock()
70+ defer h.mu.Unlock()
71+ return append([]scheduledCall(nil), h.scheduled...)
72+}
73+
74+// newGPURecoveryHarness replaces the CEF task/scheduler seams. Posted UI
75+// tasks run inline; scheduled callbacks are queued until flush is called.
76+func newGPURecoveryHarness(t *testing.T) *gpuRecoveryHarness {
77+ t.Helper()
78+ h := &gpuRecoveryHarness{paints: map[*Cef2gtkAdapter]int{}}
79+ oldNew, oldPost, oldAfter, oldPaints := cefNewTask, cefPostTask, cefScheduleAfter, gpuRecoveryPaintCount
80+ t.Cleanup(func() {
81+ cefNewTask, cefPostTask, cefScheduleAfter, gpuRecoveryPaintCount = oldNew, oldPost, oldAfter, oldPaints
82+ })
83+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
84+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
85+ if h.holdPosts {
86+ h.mu.Lock()
87+ h.held = append(h.held, task)
88+ h.mu.Unlock()
89+ return 1
90+ }
91+ if h.beforePost != nil {
92+ h.beforePost()
93+ }
94+ task.Execute()
95+ return 1
96+ }
97+ cefScheduleAfter = func(delay time.Duration, fn func()) {
98+ h.mu.Lock()
99+ defer h.mu.Unlock()
100+ h.scheduled = append(h.scheduled, scheduledCall{delay: delay, fn: fn})
101+ }
102+ gpuRecoveryPaintCount = func(bridge *Cef2gtkAdapter) int {
103+ h.mu.Lock()
104+ defer h.mu.Unlock()
105+ return h.paints[bridge]
106+ }
107+ return h
108+}
109+
110+// flush runs and clears everything currently scheduled.
111+func (h *gpuRecoveryHarness) flush() {
112+ h.mu.Lock()
113+ calls := h.scheduled
114+ h.scheduled = nil
115+ h.mu.Unlock()
116+ for _, c := range calls {
117+ c.fn()
118+ }
119+}
120+
121+func newGPURecoveryView(id port.WebViewID) (*WebView, *gpuRecoveryHost, *gpuRecoveryBrowser) {
122+ host := &gpuRecoveryHost{}
123+ browser := &gpuRecoveryBrowser{id: int32(id)}
124+ wv := &WebView{
125+ id: id,
126+ ctx: context.Background(),
127+ host: host,
128+ browser: browser,
129+ viewBridge: &Cef2gtkAdapter{},
130+ }
131+ return wv, host, browser
132+}
133+
134+func newGPURecoveryEngine(views ...*WebView) *Engine {
135+ e := &Engine{ctx: context.Background()}
136+ for _, wv := range views {
137+ e.activeWebViews.Store(wv.id, wv)
138+ }
139+ return e
140+}
141+
142+func TestGPURecovery_FirstLaunchDoesNothing(t *testing.T) {
143+ h := newGPURecoveryHarness(t)
144+ e := newGPURecoveryEngine()
145+
146+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
147+
148+ require.Empty(t, h.pending())
149+}
150+
151+func TestGPURecovery_NonGPULaunchDoesNothing(t *testing.T) {
152+ h := newGPURecoveryHarness(t)
153+ e := newGPURecoveryEngine()
154+
155+ e.recordChildProcessLaunch("renderer", "", "", "", "")
156+ e.recordChildProcessLaunch("renderer", "", "", "", "")
157+
158+ require.Empty(t, h.pending())
159+}
160+
161+func TestGPURecovery_SecondLaunchSchedulesOnePass(t *testing.T) {
162+ h := newGPURecoveryHarness(t)
163+ e := newGPURecoveryEngine()
164+
165+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
166+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
167+
168+ require.Len(t, h.pending(), 1)
169+ require.Equal(t, gpuRecoveryDelay, h.pending()[0].delay)
170+}
171+
172+func TestGPURecovery_BurstIsDebouncedToOnePass(t *testing.T) {
173+ h := newGPURecoveryHarness(t)
174+ wv, host, _ := newGPURecoveryView(1)
175+ e := newGPURecoveryEngine(wv)
176+
177+ for i := 0; i < 5; i++ {
178+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
179+ }
180+ require.Len(t, h.pending(), 1, "launches 2..5 must share one pass")
181+
182+ h.flush() // run the pass: one pulse per view
183+ require.Equal(t, 2, host.pulses, "one pulse = two Invalidate calls")
184+
185+ // After the pass ran, a new crash is allowed to schedule another pass.
186+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
187+ require.Len(t, h.pending(), 2, "verify callback plus a new pass")
188+}
189+
190+func TestGPURecovery_ViewThatPaintsIsNotReloaded(t *testing.T) {
191+ h := newGPURecoveryHarness(t)
192+ wv, host, browser := newGPURecoveryView(1)
193+ e := newGPURecoveryEngine(wv)
194+ h.setPaints(wv, 10)
195+
196+ e.runGPURecoveryPass()
197+ require.Equal(t, 2, host.pulses)
198+ require.Len(t, h.pending(), 1)
199+ require.Equal(t, gpuRecoveryVerifyDelay, h.pending()[0].delay)
200+
201+ h.setPaints(wv, 11) // a frame arrived after the pulse
202+ h.flush()
203+
204+ require.Zero(t, browser.reloads)
205+}
206+
207+func TestGPURecovery_ViewThatDoesNotPaintIsReloaded(t *testing.T) {
208+ h := newGPURecoveryHarness(t)
209+ wv, _, browser := newGPURecoveryView(1)
210+ e := newGPURecoveryEngine(wv)
211+ h.setPaints(wv, 10)
212+
213+ e.runGPURecoveryPass()
214+ h.flush() // paints unchanged
215+
216+ require.Equal(t, 1, browser.reloads)
217+}
218+
219+func TestGPURecovery_OnlyStalledViewsAreReloaded(t *testing.T) {
220+ h := newGPURecoveryHarness(t)
221+ healthy, _, healthyBrowser := newGPURecoveryView(1)
222+ frozen, _, frozenBrowser := newGPURecoveryView(2)
223+ e := newGPURecoveryEngine(healthy, frozen)
224+
225+ e.runGPURecoveryPass()
226+ h.setPaints(healthy, 1)
227+ h.flush()
228+
229+ require.Zero(t, healthyBrowser.reloads)
230+ require.Equal(t, 1, frozenBrowser.reloads)
231+}
232+
233+func TestGPURecovery_DestroyedViewsAreSkipped(t *testing.T) {
234+ h := newGPURecoveryHarness(t)
235+ wv, host, browser := newGPURecoveryView(1)
236+ wv.destroyed.Store(true)
237+ e := newGPURecoveryEngine(wv)
238+
239+ e.runGPURecoveryPass()
240+ h.flush()
241+
242+ require.Zero(t, host.pulses)
243+ require.Zero(t, browser.reloads)
244+}
245+
246+func TestGPURecovery_ViewDestroyedBeforeVerifyIsNotReloaded(t *testing.T) {
247+ h := newGPURecoveryHarness(t)
248+ wv, _, browser := newGPURecoveryView(1)
249+ e := newGPURecoveryEngine(wv)
250+
251+ e.runGPURecoveryPass()
252+ wv.destroyed.Store(true)
253+ h.flush()
254+
255+ require.Zero(t, browser.reloads)
256+}
257+
258+func TestGPURecovery_ViewWithoutHostOrBrowserIsSkipped(t *testing.T) {
259+ h := newGPURecoveryHarness(t)
260+ noHost, _, _ := newGPURecoveryView(1)
261+ noHost.host = nil
262+ noBrowser, host, _ := newGPURecoveryView(2)
263+ noBrowser.browser = nil
264+ e := newGPURecoveryEngine(noHost, noBrowser)
265+
266+ e.runGPURecoveryPass()
267+
268+ require.Empty(t, h.pending())
269+ require.Zero(t, host.pulses)
270+}
271+
272+func TestGPURecovery_HiddenViewIsDeferredUntilVisible(t *testing.T) {
273+ h := newGPURecoveryHarness(t)
274+ wv, host, browser := newGPURecoveryView(1)
275+ wv.effectiveVisibilityKnown = true
276+ wv.effectiveVisible = false
277+ e := newGPURecoveryEngine(wv)
278+
279+ e.runGPURecoveryPass()
280+ h.flush()
281+ require.Zero(t, host.pulses, "hidden views are not pulsed")
282+ require.Zero(t, browser.reloads, "hidden views are not reloaded")
283+ require.True(t, pendingOf(wv))
284+
285+ // Becoming visible restarts recovery for that view only.
286+ wv.applyEffectiveVisibility(host, true)
287+ require.False(t, pendingOf(wv))
288+ require.Equal(t, 2, host.pulses)
289+ h.flush() // no paint arrived
290+ require.Equal(t, 1, browser.reloads)
291+}
292+
293+func TestGPURecovery_ViewHiddenDuringVerifyIsDeferredNotReloaded(t *testing.T) {
294+ h := newGPURecoveryHarness(t)
295+ wv, _, browser := newGPURecoveryView(1)
296+ e := newGPURecoveryEngine(wv)
297+
298+ e.runGPURecoveryPass()
299+ wv.effectiveVisibilityKnown = true
300+ wv.effectiveVisible = false
301+ h.flush()
302+
303+ require.Zero(t, browser.reloads)
304+ require.True(t, pendingOf(wv))
305+}
306+
307+func TestGPURecovery_IsNotGatedByStallRecoveryEnv(t *testing.T) {
308+ h := newGPURecoveryHarness(t)
309+ t.Setenv(cefRenderStallRecoveryEnvVar, "0")
310+ wv, host, _ := newGPURecoveryView(1)
311+ e := newGPURecoveryEngine(wv)
312+
313+ e.runGPURecoveryPass()
314+
315+ require.False(t, renderStallRecoveryEnabled())
316+ require.Equal(t, 2, host.pulses)
317+ require.Len(t, h.pending(), 1)
318+}
319+
320+func pendingOf(wv *WebView) bool {
321+ wv.mu.RLock()
322+ defer wv.mu.RUnlock()
323+ return wv.gpuRecoveryPending
324+}
325+
326+func markHidden(wv *WebView) {
327+ wv.mu.Lock()
328+ wv.effectiveVisibilityKnown = true
329+ wv.effectiveVisible = false
330+ wv.mu.Unlock()
331+}
332+
333+// GO-001: the old code could observe "hidden", lose the race to a show that
334+// consumed a still-false pending flag, then store pending=true on a visible
335+// view. Check and store are now one critical section, so both orderings of
336+// "defer" vs "show" end with the view recovered exactly once.
337+
338+func TestGPURecovery_LostWakeup_DeferThenShowResumes(t *testing.T) {
339+ h := newGPURecoveryHarness(t)
340+ wv, host, browser := newGPURecoveryView(1)
341+ markHidden(wv)
342+
343+ require.True(t, wv.deferGPURecoveryIfHidden())
344+ require.True(t, pendingOf(wv))
345+
346+ wv.applyEffectiveVisibility(host, true)
347+ require.False(t, pendingOf(wv))
348+ require.Equal(t, 2, host.pulses, "show consumed pending and restarted recovery")
349+ h.flush()
350+ require.Equal(t, 1, browser.reloads)
351+}
352+
353+func TestGPURecovery_LostWakeup_ShowThenDeferDoesNotLeavePending(t *testing.T) {
354+ h := newGPURecoveryHarness(t)
355+ wv, host, _ := newGPURecoveryView(1)
356+ markHidden(wv)
357+
358+ wv.applyEffectiveVisibility(host, true) // show wins the race
359+ require.False(t, wv.deferGPURecoveryIfHidden(), "visible view must not be deferred")
360+ require.False(t, pendingOf(wv), "no stranded pending flag on a visible view")
361+
362+ _, deferred := wv.claimGPURecovery()
363+ require.False(t, deferred)
364+ require.False(t, pendingOf(wv))
365+ require.Empty(t, h.pending())
366+}
367+
368+func TestGPURecovery_VerifyHiddenThenShownBetweenCheckAndStore(t *testing.T) {
369+ h := newGPURecoveryHarness(t)
370+ wv, host, browser := newGPURecoveryView(1)
371+ e := newGPURecoveryEngine(wv)
372+ e.runGPURecoveryPass() // visible pulse, verify scheduled
373+
374+ // The view is hidden and then shown again before verify runs. The show
375+ // happens while nothing is pending, so it must not strand anything and the
376+ // verify must see a visible, still-frozen view and reload it.
377+ wv.applyEffectiveVisibility(host, false)
378+ wv.applyEffectiveVisibility(host, true)
379+ h.flush()
380+
381+ require.False(t, pendingOf(wv))
382+ require.Equal(t, 1, browser.reloads)
383+}
384+
385+func TestGPURecovery_ConcurrentShowAndDeferNeverStrandsPending(t *testing.T) {
386+ newGPURecoveryHarness(t)
387+ wv, host, _ := newGPURecoveryView(1)
388+ markHidden(wv)
389+
390+ var wg sync.WaitGroup
391+ wg.Add(2)
392+ go func() {
393+ defer wg.Done()
394+ for i := 0; i < 200; i++ {
395+ wv.applyEffectiveVisibility(host, false)
396+ wv.applyEffectiveVisibility(host, true)
397+ }
398+ }()
399+ go func() {
400+ defer wg.Done()
401+ for i := 0; i < 200; i++ {
402+ wv.deferGPURecoveryIfHidden()
403+ wv.claimGPURecovery()
404+ }
405+ }()
406+ wg.Wait()
407+
408+ // The visibility goroutine ended visible. Whatever the interleaving, a
409+ // visible view must not keep a pending flag nobody will ever consume.
410+ wv.applyEffectiveVisibility(host, true)
411+ require.False(t, pendingOf(wv))
412+}
413+
414+// GO-003: visibility is revalidated when the pulse task runs on the UI thread.
415+
416+func TestGPURecovery_PulseSkipsViewHiddenAfterScheduling(t *testing.T) {
417+ h := newGPURecoveryHarness(t)
418+ wv, host, browser := newGPURecoveryView(1)
419+ e := newGPURecoveryEngine(wv)
420+ h.beforePost = func() { markHidden(wv) } // hidden after scheduling
421+
422+ e.runGPURecoveryPass()
423+
424+ require.Zero(t, host.pulses, "hidden view must not be pulsed (WasHidden(0) would show it)")
425+ require.True(t, pendingOf(wv))
426+ h.beforePost = nil
427+ h.flush() // verify of the same attempt: still hidden
428+ require.Zero(t, browser.reloads)
429+
430+ wv.applyEffectiveVisibility(host, true)
431+ require.Equal(t, 2, host.pulses)
432+}
433+
434+func TestRenderStallPulseSkipsViewHiddenAfterScheduling(t *testing.T) {
435+ h := newGPURecoveryHarness(t)
436+ t.Setenv(cefRenderStallRecoveryEnvVar, "1")
437+ wv, host, _ := newGPURecoveryView(1)
438+ h.beforePost = func() { markHidden(wv) }
439+
440+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
441+
442+ require.Zero(t, host.pulses)
443+}
444+
445+func TestRenderStallRecoveryStillPulsesVisibleViewWhenEnabled(t *testing.T) {
446+ h := newGPURecoveryHarness(t)
447+ t.Setenv(cefRenderStallRecoveryEnvVar, "1")
448+ wv, host, _ := newGPURecoveryView(1)
449+
450+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
451+
452+ require.Equal(t, 2, host.pulses)
453+ require.Len(t, h.pending(), 1)
454+ require.Equal(t, renderRecoveryVerifyDelay, h.pending()[0].delay)
455+}
456+
457+func TestRenderStallRecoveryDisabledByDefault(t *testing.T) {
458+ h := newGPURecoveryHarness(t)
459+ t.Setenv(cefRenderStallRecoveryEnvVar, "0")
460+ wv, host, _ := newGPURecoveryView(1)
461+
462+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
463+
464+ require.Zero(t, host.pulses)
465+ require.Empty(t, h.pending())
466+}
467+
468+// GO-004: per-view recovery generation.
469+
470+func TestGPURecovery_StaleVerifyIgnoredAfterNewerAttempt(t *testing.T) {
471+ h := newGPURecoveryHarness(t)
472+ wv, host, browser := newGPURecoveryView(1)
473+ e := newGPURecoveryEngine(wv)
474+
475+ e.runGPURecoveryPass() // attempt 1
476+ first := h.pending()
477+ require.Len(t, first, 1)
478+ e.runGPURecoveryPass() // a new crash supersedes it: attempt 2
479+ require.Equal(t, 4, host.pulses)
480+ require.Len(t, h.pending(), 2)
481+
482+ first[0].fn() // stale verify of attempt 1 fires "too early"
483+ require.Zero(t, browser.reloads, "stale verify must not reload")
484+
485+ h.flush() // attempt 1 again (stale) plus attempt 2
486+ require.Equal(t, 1, browser.reloads, "only the newest attempt reloads, once")
487+}
488+
489+func TestGPURecovery_ShowAfterNewCrashSupersedesInFlightAttempt(t *testing.T) {
490+ h := newGPURecoveryHarness(t)
491+ wv, host, browser := newGPURecoveryView(1)
492+ e := newGPURecoveryEngine(wv)
493+
494+ e.runGPURecoveryPass() // visible attempt 1
495+ stale := h.pending()[0]
496+ markHidden(wv)
497+ e.runGPURecoveryPass() // hidden: attempt 2 deferred, pending
498+ require.True(t, pendingOf(wv))
499+
500+ wv.applyEffectiveVisibility(host, true) // attempt 3 starts, clears pending
501+ require.False(t, pendingOf(wv))
502+
503+ stale.fn()
504+ require.Zero(t, browser.reloads, "verify of attempt 1 is stale")
505+ h.flush()
506+ require.Equal(t, 1, browser.reloads, "exactly one reload for the newest attempt")
507+}
508+
509+func TestGPURecovery_StalePulseTaskSkipped(t *testing.T) {
510+ h := newGPURecoveryHarness(t)
511+ wv, host, _ := newGPURecoveryView(1)
512+ e := newGPURecoveryEngine(wv)
513+ // A newer attempt starts between posting and executing the pulse task.
514+ h.beforePost = func() { wv.gpuRecoveryGen.Add(1) }
515+
516+ e.runGPURecoveryPass()
517+
518+ require.Zero(t, host.pulses)
519+}
520+
521+// GO-002: the bridge is cleared under wv.mu by teardown.
522+
523+func clearBridge(wv *WebView) {
524+ wv.mu.Lock()
525+ wv.viewBridge = nil
526+ wv.mu.Unlock()
527+}
528+
529+func TestGPURecovery_BridgeClearedBeforePulseTaskRuns(t *testing.T) {
530+ h := newGPURecoveryHarness(t)
531+ wv, host, _ := newGPURecoveryView(1)
532+ e := newGPURecoveryEngine(wv)
533+ h.beforePost = func() { clearBridge(wv) }
534+
535+ e.runGPURecoveryPass()
536+
537+ require.Zero(t, host.pulses)
538+}
539+
540+func TestGPURecovery_BridgeClearedBeforeVerify(t *testing.T) {
541+ h := newGPURecoveryHarness(t)
542+ wv, _, browser := newGPURecoveryView(1)
543+ e := newGPURecoveryEngine(wv)
544+ e.runGPURecoveryPass()
545+
546+ clearBridge(wv)
547+ h.flush()
548+
549+ require.Zero(t, browser.reloads)
550+}
551+
552+func TestGPURecovery_BridgeClearedConcurrentlyIsRaceFree(t *testing.T) {
553+ h := newGPURecoveryHarness(t)
554+ wv, _, _ := newGPURecoveryView(1)
555+ e := newGPURecoveryEngine(wv)
556+
557+ var wg sync.WaitGroup
558+ wg.Add(2)
559+ go func() {
560+ defer wg.Done()
561+ for i := 0; i < 100; i++ {
562+ e.runGPURecoveryPass()
563+ h.flush()
564+ }
565+ }()
566+ go func() {
567+ defer wg.Done()
568+ clearBridge(wv)
569+ }()
570+ wg.Wait()
571+
572+ // After teardown nothing may touch the bridge again.
573+ e.runGPURecoveryPass()
574+ h.flush()
575+ require.Nil(t, wv.liveBridge())
576+}
577+
578+// queuedGTK makes wv dispatch synchronous GTK work onto a queue the test
579+// drains explicitly, so state can change while a reload is "waiting on GTK".
580+type queuedGTK struct{ ops chan func() }
581+
582+func queueGTKDispatch(wv *WebView) *queuedGTK {
583+ q := &queuedGTK{ops: make(chan func(), 4)}
584+ wv.gtkSyncIsOwner = func() bool { return false }
585+ wv.gtkSyncDispatch = func(fn func()) { q.ops <- fn }
586+ wv.gtkSyncTimeout = time.Minute
587+ return q
588+}
589+
590+// verifyAsync runs the single scheduled verify callback on its own goroutine
591+// (it blocks inside the synchronous GTK dispatch) and returns once the reload
592+// operation is queued. The returned func waits for the verify to finish.
593+func (q *queuedGTK) runQueued(t *testing.T) {
594+ t.Helper()
595+ select {
596+ case fn := <-q.ops:
597+ fn()
598+ default:
599+ t.Fatal("no GTK operation queued")
600+ }
601+}
602+
603+func startVerifyBlockedOnGTK(t *testing.T, h *gpuRecoveryHarness, q *queuedGTK) (wait func()) {
604+ t.Helper()
605+ pending := h.pending()
606+ require.Len(t, pending, 1)
607+ h.mu.Lock()
608+ h.scheduled = nil
609+ h.mu.Unlock()
610+ done := make(chan struct{})
611+ go func() {
612+ defer close(done)
613+ pending[0].fn()
614+ }()
615+ // Wait (without sleeping) until the reload operation is queued on GTK.
616+ op := <-q.ops
617+ q.ops <- op
618+ return func() { <-done }
619+}
620+
621+func TestGPURecovery_ReloadSkippedWhenSupersededWhileWaitingOnGTK(t *testing.T) {
622+ h := newGPURecoveryHarness(t)
623+ wv, _, browser := newGPURecoveryView(1)
624+ q := queueGTKDispatch(wv)
625+ e := newGPURecoveryEngine(wv)
626+ e.runGPURecoveryPass() // attempt 1 pulses, verify scheduled
627+ wait := startVerifyBlockedOnGTK(t, h, q)
628+
629+ wv.claimGPURecovery() // a newer attempt starts while the reload is queued
630+ q.runQueued(t) // GTK finally drains the old reload
631+ wait()
632+
633+ require.Zero(t, browser.reloads, "reload of a superseded attempt must not navigate")
634+}
635+
636+func TestGPURecovery_ReloadSkippedAndDeferredWhenHiddenWhileWaitingOnGTK(t *testing.T) {
637+ h := newGPURecoveryHarness(t)
638+ wv, host, browser := newGPURecoveryView(1)
639+ q := queueGTKDispatch(wv)
640+ e := newGPURecoveryEngine(wv)
641+ e.runGPURecoveryPass()
642+ wait := startVerifyBlockedOnGTK(t, h, q)
643+
644+ markHidden(wv) // hidden while the reload is queued
645+ q.runQueued(t)
646+ wait()
647+
648+ require.Zero(t, browser.reloads)
649+ require.True(t, pendingOf(wv), "hidden view must be marked pending, not dropped")
650+
651+ // Showing it later resumes recovery.
652+ pulsesBefore := host.pulses
653+ wv.applyEffectiveVisibility(host, true)
654+ require.Equal(t, pulsesBefore+2, host.pulses)
655+}
656+
657+func TestGPURecovery_ReloadSkippedWhenDestroyedWhileWaitingOnGTK(t *testing.T) {
658+ h := newGPURecoveryHarness(t)
659+ wv, _, browser := newGPURecoveryView(1)
660+ q := queueGTKDispatch(wv)
661+ e := newGPURecoveryEngine(wv)
662+ e.runGPURecoveryPass()
663+ wait := startVerifyBlockedOnGTK(t, h, q)
664+
665+ wv.destroyed.Store(true)
666+ q.runQueued(t)
667+ wait()
668+
669+ require.Zero(t, browser.reloads)
670+}
671+
672+func TestGPURecovery_ReloadProceedsWhenStillValidAfterGTKWait(t *testing.T) {
673+ h := newGPURecoveryHarness(t)
674+ wv, _, browser := newGPURecoveryView(1)
675+ q := queueGTKDispatch(wv)
676+ e := newGPURecoveryEngine(wv)
677+ e.runGPURecoveryPass()
678+ wait := startVerifyBlockedOnGTK(t, h, q)
679+
680+ q.runQueued(t)
681+ wait()
682+
683+ require.Equal(t, 1, browser.reloads)
684+}
685+
686+// GO-005: baseline and verify timer are taken when the pulse runs.
687+
688+func TestGPURecovery_VerifyNotScheduledUntilPulseRuns(t *testing.T) {
689+ h := newGPURecoveryHarness(t)
690+ wv, host, _ := newGPURecoveryView(1)
691+ e := newGPURecoveryEngine(wv)
692+ h.holdPosts = true
693+
694+ e.runGPURecoveryPass()
695+ require.Empty(t, h.pending(), "no verify timer while the pulse task is still queued")
696+ require.Zero(t, host.pulses)
697+
698+ h.holdPosts = false
699+ h.runHeld()
700+ require.Equal(t, 2, host.pulses)
701+ require.Len(t, h.pending(), 1)
702+ require.Equal(t, gpuRecoveryVerifyDelay, h.pending()[0].delay)
703+}
704+
705+func TestGPURecovery_BaselineExcludesPaintsBeforePulseRuns(t *testing.T) {
706+ h := newGPURecoveryHarness(t)
707+ wv, _, browser := newGPURecoveryView(1)
708+ e := newGPURecoveryEngine(wv)
709+ h.setPaints(wv, 10)
710+ h.holdPosts = true
711+
712+ e.runGPURecoveryPass()
713+ h.setPaints(wv, 15) // paints landed while the pulse task was queued
714+ h.runHeld() // baseline is captured now: 15
715+ h.flush() // no paint since the pulse
716+
717+ require.Equal(t, 1, browser.reloads, "paints before the pulse must not count as recovery")
718+}
719+
720+func TestGPURecovery_NoVerifyForSkippedOrStalePulse(t *testing.T) {
721+ h := newGPURecoveryHarness(t)
722+ wv, _, _ := newGPURecoveryView(1)
723+ e := newGPURecoveryEngine(wv)
724+ h.holdPosts = true
725+
726+ e.runGPURecoveryPass()
727+ markHidden(wv) // hidden before the queued pulse runs: skipped
728+ h.runHeld()
729+ require.Empty(t, h.pending(), "skipped pulse schedules no verify")
730+
731+ wv.mu.Lock()
732+ wv.effectiveVisible = true
733+ wv.mu.Unlock()
734+ e.runGPURecoveryPass() // attempt 3 queued
735+ wv.claimGPURecovery() // attempt 4 supersedes it
736+ h.runHeld()
737+ require.Empty(t, h.pending(), "stale pulse schedules no verify")
738+}
@@ -0,0 +1,738 @@
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+type gpuRecoveryHost struct {
16+ purecef.BrowserHost
17+ pulses int
18+}
19+
20+func (h *gpuRecoveryHost) Invalidate(purecef.PaintElementType) { h.pulses++ }
21+func (h *gpuRecoveryHost) WasHidden(int32) {}
22+func (h *gpuRecoveryHost) NotifyScreenInfoChanged() {}
23+func (h *gpuRecoveryHost) WasResized() {}
24+
25+type gpuRecoveryBrowser struct {
26+ purecef.Browser
27+ id int32
28+ reloads int
29+}
30+
31+func (b *gpuRecoveryBrowser) GetIdentifier() int32 { return b.id }
32+func (b *gpuRecoveryBrowser) Reload() { b.reloads++ }
33+
34+type scheduledCall struct {
35+ delay time.Duration
36+ fn func()
37+}
38+
39+type gpuRecoveryHarness struct {
40+ mu sync.Mutex
41+ scheduled []scheduledCall
42+ paints map[*Cef2gtkAdapter]int
43+ // beforePost, when set, runs right before a posted UI task executes. It
44+ // lets tests change view state between scheduling and execution.
45+ beforePost func()
46+ // holdPosts queues posted UI tasks instead of running them; runHeld runs
47+ // them later, like the CEF UI thread draining its queue.
48+ holdPosts bool
49+ held []purecef.Task
50+}
51+
52+func (h *gpuRecoveryHarness) runHeld() {
53+ h.mu.Lock()
54+ tasks := h.held
55+ h.held = nil
56+ h.mu.Unlock()
57+ for _, task := range tasks {
58+ task.Execute()
59+ }
60+}
61+
62+func (h *gpuRecoveryHarness) setPaints(wv *WebView, n int) {
63+ h.mu.Lock()
64+ defer h.mu.Unlock()
65+ h.paints[wv.viewBridge] = n
66+}
67+
68+func (h *gpuRecoveryHarness) pending() []scheduledCall {
69+ h.mu.Lock()
70+ defer h.mu.Unlock()
71+ return append([]scheduledCall(nil), h.scheduled...)
72+}
73+
74+// newGPURecoveryHarness replaces the CEF task/scheduler seams. Posted UI
75+// tasks run inline; scheduled callbacks are queued until flush is called.
76+func newGPURecoveryHarness(t *testing.T) *gpuRecoveryHarness {
77+ t.Helper()
78+ h := &gpuRecoveryHarness{paints: map[*Cef2gtkAdapter]int{}}
79+ oldNew, oldPost, oldAfter, oldPaints := cefNewTask, cefPostTask, cefScheduleAfter, gpuRecoveryPaintCount
80+ t.Cleanup(func() {
81+ cefNewTask, cefPostTask, cefScheduleAfter, gpuRecoveryPaintCount = oldNew, oldPost, oldAfter, oldPaints
82+ })
83+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
84+ cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
85+ if h.holdPosts {
86+ h.mu.Lock()
87+ h.held = append(h.held, task)
88+ h.mu.Unlock()
89+ return 1
90+ }
91+ if h.beforePost != nil {
92+ h.beforePost()
93+ }
94+ task.Execute()
95+ return 1
96+ }
97+ cefScheduleAfter = func(delay time.Duration, fn func()) {
98+ h.mu.Lock()
99+ defer h.mu.Unlock()
100+ h.scheduled = append(h.scheduled, scheduledCall{delay: delay, fn: fn})
101+ }
102+ gpuRecoveryPaintCount = func(bridge *Cef2gtkAdapter) int {
103+ h.mu.Lock()
104+ defer h.mu.Unlock()
105+ return h.paints[bridge]
106+ }
107+ return h
108+}
109+
110+// flush runs and clears everything currently scheduled.
111+func (h *gpuRecoveryHarness) flush() {
112+ h.mu.Lock()
113+ calls := h.scheduled
114+ h.scheduled = nil
115+ h.mu.Unlock()
116+ for _, c := range calls {
117+ c.fn()
118+ }
119+}
120+
121+func newGPURecoveryView(id port.WebViewID) (*WebView, *gpuRecoveryHost, *gpuRecoveryBrowser) {
122+ host := &gpuRecoveryHost{}
123+ browser := &gpuRecoveryBrowser{id: int32(id)}
124+ wv := &WebView{
125+ id: id,
126+ ctx: context.Background(),
127+ host: host,
128+ browser: browser,
129+ viewBridge: &Cef2gtkAdapter{},
130+ }
131+ return wv, host, browser
132+}
133+
134+func newGPURecoveryEngine(views ...*WebView) *Engine {
135+ e := &Engine{ctx: context.Background()}
136+ for _, wv := range views {
137+ e.activeWebViews.Store(wv.id, wv)
138+ }
139+ return e
140+}
141+
142+func TestGPURecovery_FirstLaunchDoesNothing(t *testing.T) {
143+ h := newGPURecoveryHarness(t)
144+ e := newGPURecoveryEngine()
145+
146+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
147+
148+ require.Empty(t, h.pending())
149+}
150+
151+func TestGPURecovery_NonGPULaunchDoesNothing(t *testing.T) {
152+ h := newGPURecoveryHarness(t)
153+ e := newGPURecoveryEngine()
154+
155+ e.recordChildProcessLaunch("renderer", "", "", "", "")
156+ e.recordChildProcessLaunch("renderer", "", "", "", "")
157+
158+ require.Empty(t, h.pending())
159+}
160+
161+func TestGPURecovery_SecondLaunchSchedulesOnePass(t *testing.T) {
162+ h := newGPURecoveryHarness(t)
163+ e := newGPURecoveryEngine()
164+
165+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
166+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
167+
168+ require.Len(t, h.pending(), 1)
169+ require.Equal(t, gpuRecoveryDelay, h.pending()[0].delay)
170+}
171+
172+func TestGPURecovery_BurstIsDebouncedToOnePass(t *testing.T) {
173+ h := newGPURecoveryHarness(t)
174+ wv, host, _ := newGPURecoveryView(1)
175+ e := newGPURecoveryEngine(wv)
176+
177+ for i := 0; i < 5; i++ {
178+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
179+ }
180+ require.Len(t, h.pending(), 1, "launches 2..5 must share one pass")
181+
182+ h.flush() // run the pass: one pulse per view
183+ require.Equal(t, 2, host.pulses, "one pulse = two Invalidate calls")
184+
185+ // After the pass ran, a new crash is allowed to schedule another pass.
186+ e.recordChildProcessLaunch("gpu-process", "", "", "", "")
187+ require.Len(t, h.pending(), 2, "verify callback plus a new pass")
188+}
189+
190+func TestGPURecovery_ViewThatPaintsIsNotReloaded(t *testing.T) {
191+ h := newGPURecoveryHarness(t)
192+ wv, host, browser := newGPURecoveryView(1)
193+ e := newGPURecoveryEngine(wv)
194+ h.setPaints(wv, 10)
195+
196+ e.runGPURecoveryPass()
197+ require.Equal(t, 2, host.pulses)
198+ require.Len(t, h.pending(), 1)
199+ require.Equal(t, gpuRecoveryVerifyDelay, h.pending()[0].delay)
200+
201+ h.setPaints(wv, 11) // a frame arrived after the pulse
202+ h.flush()
203+
204+ require.Zero(t, browser.reloads)
205+}
206+
207+func TestGPURecovery_ViewThatDoesNotPaintIsReloaded(t *testing.T) {
208+ h := newGPURecoveryHarness(t)
209+ wv, _, browser := newGPURecoveryView(1)
210+ e := newGPURecoveryEngine(wv)
211+ h.setPaints(wv, 10)
212+
213+ e.runGPURecoveryPass()
214+ h.flush() // paints unchanged
215+
216+ require.Equal(t, 1, browser.reloads)
217+}
218+
219+func TestGPURecovery_OnlyStalledViewsAreReloaded(t *testing.T) {
220+ h := newGPURecoveryHarness(t)
221+ healthy, _, healthyBrowser := newGPURecoveryView(1)
222+ frozen, _, frozenBrowser := newGPURecoveryView(2)
223+ e := newGPURecoveryEngine(healthy, frozen)
224+
225+ e.runGPURecoveryPass()
226+ h.setPaints(healthy, 1)
227+ h.flush()
228+
229+ require.Zero(t, healthyBrowser.reloads)
230+ require.Equal(t, 1, frozenBrowser.reloads)
231+}
232+
233+func TestGPURecovery_DestroyedViewsAreSkipped(t *testing.T) {
234+ h := newGPURecoveryHarness(t)
235+ wv, host, browser := newGPURecoveryView(1)
236+ wv.destroyed.Store(true)
237+ e := newGPURecoveryEngine(wv)
238+
239+ e.runGPURecoveryPass()
240+ h.flush()
241+
242+ require.Zero(t, host.pulses)
243+ require.Zero(t, browser.reloads)
244+}
245+
246+func TestGPURecovery_ViewDestroyedBeforeVerifyIsNotReloaded(t *testing.T) {
247+ h := newGPURecoveryHarness(t)
248+ wv, _, browser := newGPURecoveryView(1)
249+ e := newGPURecoveryEngine(wv)
250+
251+ e.runGPURecoveryPass()
252+ wv.destroyed.Store(true)
253+ h.flush()
254+
255+ require.Zero(t, browser.reloads)
256+}
257+
258+func TestGPURecovery_ViewWithoutHostOrBrowserIsSkipped(t *testing.T) {
259+ h := newGPURecoveryHarness(t)
260+ noHost, _, _ := newGPURecoveryView(1)
261+ noHost.host = nil
262+ noBrowser, host, _ := newGPURecoveryView(2)
263+ noBrowser.browser = nil
264+ e := newGPURecoveryEngine(noHost, noBrowser)
265+
266+ e.runGPURecoveryPass()
267+
268+ require.Empty(t, h.pending())
269+ require.Zero(t, host.pulses)
270+}
271+
272+func TestGPURecovery_HiddenViewIsDeferredUntilVisible(t *testing.T) {
273+ h := newGPURecoveryHarness(t)
274+ wv, host, browser := newGPURecoveryView(1)
275+ wv.effectiveVisibilityKnown = true
276+ wv.effectiveVisible = false
277+ e := newGPURecoveryEngine(wv)
278+
279+ e.runGPURecoveryPass()
280+ h.flush()
281+ require.Zero(t, host.pulses, "hidden views are not pulsed")
282+ require.Zero(t, browser.reloads, "hidden views are not reloaded")
283+ require.True(t, pendingOf(wv))
284+
285+ // Becoming visible restarts recovery for that view only.
286+ wv.applyEffectiveVisibility(host, true)
287+ require.False(t, pendingOf(wv))
288+ require.Equal(t, 2, host.pulses)
289+ h.flush() // no paint arrived
290+ require.Equal(t, 1, browser.reloads)
291+}
292+
293+func TestGPURecovery_ViewHiddenDuringVerifyIsDeferredNotReloaded(t *testing.T) {
294+ h := newGPURecoveryHarness(t)
295+ wv, _, browser := newGPURecoveryView(1)
296+ e := newGPURecoveryEngine(wv)
297+
298+ e.runGPURecoveryPass()
299+ wv.effectiveVisibilityKnown = true
300+ wv.effectiveVisible = false
301+ h.flush()
302+
303+ require.Zero(t, browser.reloads)
304+ require.True(t, pendingOf(wv))
305+}
306+
307+func TestGPURecovery_IsNotGatedByStallRecoveryEnv(t *testing.T) {
308+ h := newGPURecoveryHarness(t)
309+ t.Setenv(cefRenderStallRecoveryEnvVar, "0")
310+ wv, host, _ := newGPURecoveryView(1)
311+ e := newGPURecoveryEngine(wv)
312+
313+ e.runGPURecoveryPass()
314+
315+ require.False(t, renderStallRecoveryEnabled())
316+ require.Equal(t, 2, host.pulses)
317+ require.Len(t, h.pending(), 1)
318+}
319+
320+func pendingOf(wv *WebView) bool {
321+ wv.mu.RLock()
322+ defer wv.mu.RUnlock()
323+ return wv.gpuRecoveryPending
324+}
325+
326+func markHidden(wv *WebView) {
327+ wv.mu.Lock()
328+ wv.effectiveVisibilityKnown = true
329+ wv.effectiveVisible = false
330+ wv.mu.Unlock()
331+}
332+
333+// GO-001: the old code could observe "hidden", lose the race to a show that
334+// consumed a still-false pending flag, then store pending=true on a visible
335+// view. Check and store are now one critical section, so both orderings of
336+// "defer" vs "show" end with the view recovered exactly once.
337+
338+func TestGPURecovery_LostWakeup_DeferThenShowResumes(t *testing.T) {
339+ h := newGPURecoveryHarness(t)
340+ wv, host, browser := newGPURecoveryView(1)
341+ markHidden(wv)
342+
343+ require.True(t, wv.deferGPURecoveryIfHidden())
344+ require.True(t, pendingOf(wv))
345+
346+ wv.applyEffectiveVisibility(host, true)
347+ require.False(t, pendingOf(wv))
348+ require.Equal(t, 2, host.pulses, "show consumed pending and restarted recovery")
349+ h.flush()
350+ require.Equal(t, 1, browser.reloads)
351+}
352+
353+func TestGPURecovery_LostWakeup_ShowThenDeferDoesNotLeavePending(t *testing.T) {
354+ h := newGPURecoveryHarness(t)
355+ wv, host, _ := newGPURecoveryView(1)
356+ markHidden(wv)
357+
358+ wv.applyEffectiveVisibility(host, true) // show wins the race
359+ require.False(t, wv.deferGPURecoveryIfHidden(), "visible view must not be deferred")
360+ require.False(t, pendingOf(wv), "no stranded pending flag on a visible view")
361+
362+ _, deferred := wv.claimGPURecovery()
363+ require.False(t, deferred)
364+ require.False(t, pendingOf(wv))
365+ require.Empty(t, h.pending())
366+}
367+
368+func TestGPURecovery_VerifyHiddenThenShownBetweenCheckAndStore(t *testing.T) {
369+ h := newGPURecoveryHarness(t)
370+ wv, host, browser := newGPURecoveryView(1)
371+ e := newGPURecoveryEngine(wv)
372+ e.runGPURecoveryPass() // visible pulse, verify scheduled
373+
374+ // The view is hidden and then shown again before verify runs. The show
375+ // happens while nothing is pending, so it must not strand anything and the
376+ // verify must see a visible, still-frozen view and reload it.
377+ wv.applyEffectiveVisibility(host, false)
378+ wv.applyEffectiveVisibility(host, true)
379+ h.flush()
380+
381+ require.False(t, pendingOf(wv))
382+ require.Equal(t, 1, browser.reloads)
383+}
384+
385+func TestGPURecovery_ConcurrentShowAndDeferNeverStrandsPending(t *testing.T) {
386+ newGPURecoveryHarness(t)
387+ wv, host, _ := newGPURecoveryView(1)
388+ markHidden(wv)
389+
390+ var wg sync.WaitGroup
391+ wg.Add(2)
392+ go func() {
393+ defer wg.Done()
394+ for i := 0; i < 200; i++ {
395+ wv.applyEffectiveVisibility(host, false)
396+ wv.applyEffectiveVisibility(host, true)
397+ }
398+ }()
399+ go func() {
400+ defer wg.Done()
401+ for i := 0; i < 200; i++ {
402+ wv.deferGPURecoveryIfHidden()
403+ wv.claimGPURecovery()
404+ }
405+ }()
406+ wg.Wait()
407+
408+ // The visibility goroutine ended visible. Whatever the interleaving, a
409+ // visible view must not keep a pending flag nobody will ever consume.
410+ wv.applyEffectiveVisibility(host, true)
411+ require.False(t, pendingOf(wv))
412+}
413+
414+// GO-003: visibility is revalidated when the pulse task runs on the UI thread.
415+
416+func TestGPURecovery_PulseSkipsViewHiddenAfterScheduling(t *testing.T) {
417+ h := newGPURecoveryHarness(t)
418+ wv, host, browser := newGPURecoveryView(1)
419+ e := newGPURecoveryEngine(wv)
420+ h.beforePost = func() { markHidden(wv) } // hidden after scheduling
421+
422+ e.runGPURecoveryPass()
423+
424+ require.Zero(t, host.pulses, "hidden view must not be pulsed (WasHidden(0) would show it)")
425+ require.True(t, pendingOf(wv))
426+ h.beforePost = nil
427+ h.flush() // verify of the same attempt: still hidden
428+ require.Zero(t, browser.reloads)
429+
430+ wv.applyEffectiveVisibility(host, true)
431+ require.Equal(t, 2, host.pulses)
432+}
433+
434+func TestRenderStallPulseSkipsViewHiddenAfterScheduling(t *testing.T) {
435+ h := newGPURecoveryHarness(t)
436+ t.Setenv(cefRenderStallRecoveryEnvVar, "1")
437+ wv, host, _ := newGPURecoveryView(1)
438+ h.beforePost = func() { markHidden(wv) }
439+
440+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
441+
442+ require.Zero(t, host.pulses)
443+}
444+
445+func TestRenderStallRecoveryStillPulsesVisibleViewWhenEnabled(t *testing.T) {
446+ h := newGPURecoveryHarness(t)
447+ t.Setenv(cefRenderStallRecoveryEnvVar, "1")
448+ wv, host, _ := newGPURecoveryView(1)
449+
450+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
451+
452+ require.Equal(t, 2, host.pulses)
453+ require.Len(t, h.pending(), 1)
454+ require.Equal(t, renderRecoveryVerifyDelay, h.pending()[0].delay)
455+}
456+
457+func TestRenderStallRecoveryDisabledByDefault(t *testing.T) {
458+ h := newGPURecoveryHarness(t)
459+ t.Setenv(cefRenderStallRecoveryEnvVar, "0")
460+ wv, host, _ := newGPURecoveryView(1)
461+
462+ wv.maybeRunRenderStallRecovery(time.Now(), nil, 5)
463+
464+ require.Zero(t, host.pulses)
465+ require.Empty(t, h.pending())
466+}
467+
468+// GO-004: per-view recovery generation.
469+
470+func TestGPURecovery_StaleVerifyIgnoredAfterNewerAttempt(t *testing.T) {
471+ h := newGPURecoveryHarness(t)
472+ wv, host, browser := newGPURecoveryView(1)
473+ e := newGPURecoveryEngine(wv)
474+
475+ e.runGPURecoveryPass() // attempt 1
476+ first := h.pending()
477+ require.Len(t, first, 1)
478+ e.runGPURecoveryPass() // a new crash supersedes it: attempt 2
479+ require.Equal(t, 4, host.pulses)
480+ require.Len(t, h.pending(), 2)
481+
482+ first[0].fn() // stale verify of attempt 1 fires "too early"
483+ require.Zero(t, browser.reloads, "stale verify must not reload")
484+
485+ h.flush() // attempt 1 again (stale) plus attempt 2
486+ require.Equal(t, 1, browser.reloads, "only the newest attempt reloads, once")
487+}
488+
489+func TestGPURecovery_ShowAfterNewCrashSupersedesInFlightAttempt(t *testing.T) {
490+ h := newGPURecoveryHarness(t)
491+ wv, host, browser := newGPURecoveryView(1)
492+ e := newGPURecoveryEngine(wv)
493+
494+ e.runGPURecoveryPass() // visible attempt 1
495+ stale := h.pending()[0]
496+ markHidden(wv)
497+ e.runGPURecoveryPass() // hidden: attempt 2 deferred, pending
498+ require.True(t, pendingOf(wv))
499+
500+ wv.applyEffectiveVisibility(host, true) // attempt 3 starts, clears pending
501+ require.False(t, pendingOf(wv))
502+
503+ stale.fn()
504+ require.Zero(t, browser.reloads, "verify of attempt 1 is stale")
505+ h.flush()
506+ require.Equal(t, 1, browser.reloads, "exactly one reload for the newest attempt")
507+}
508+
509+func TestGPURecovery_StalePulseTaskSkipped(t *testing.T) {
510+ h := newGPURecoveryHarness(t)
511+ wv, host, _ := newGPURecoveryView(1)
512+ e := newGPURecoveryEngine(wv)
513+ // A newer attempt starts between posting and executing the pulse task.
514+ h.beforePost = func() { wv.gpuRecoveryGen.Add(1) }
515+
516+ e.runGPURecoveryPass()
517+
518+ require.Zero(t, host.pulses)
519+}
520+
521+// GO-002: the bridge is cleared under wv.mu by teardown.
522+
523+func clearBridge(wv *WebView) {
524+ wv.mu.Lock()
525+ wv.viewBridge = nil
526+ wv.mu.Unlock()
527+}
528+
529+func TestGPURecovery_BridgeClearedBeforePulseTaskRuns(t *testing.T) {
530+ h := newGPURecoveryHarness(t)
531+ wv, host, _ := newGPURecoveryView(1)
532+ e := newGPURecoveryEngine(wv)
533+ h.beforePost = func() { clearBridge(wv) }
534+
535+ e.runGPURecoveryPass()
536+
537+ require.Zero(t, host.pulses)
538+}
539+
540+func TestGPURecovery_BridgeClearedBeforeVerify(t *testing.T) {
541+ h := newGPURecoveryHarness(t)
542+ wv, _, browser := newGPURecoveryView(1)
543+ e := newGPURecoveryEngine(wv)
544+ e.runGPURecoveryPass()
545+
546+ clearBridge(wv)
547+ h.flush()
548+
549+ require.Zero(t, browser.reloads)
550+}
551+
552+func TestGPURecovery_BridgeClearedConcurrentlyIsRaceFree(t *testing.T) {
553+ h := newGPURecoveryHarness(t)
554+ wv, _, _ := newGPURecoveryView(1)
555+ e := newGPURecoveryEngine(wv)
556+
557+ var wg sync.WaitGroup
558+ wg.Add(2)
559+ go func() {
560+ defer wg.Done()
561+ for i := 0; i < 100; i++ {
562+ e.runGPURecoveryPass()
563+ h.flush()
564+ }
565+ }()
566+ go func() {
567+ defer wg.Done()
568+ clearBridge(wv)
569+ }()
570+ wg.Wait()
571+
572+ // After teardown nothing may touch the bridge again.
573+ e.runGPURecoveryPass()
574+ h.flush()
575+ require.Nil(t, wv.liveBridge())
576+}
577+
578+// queuedGTK makes wv dispatch synchronous GTK work onto a queue the test
579+// drains explicitly, so state can change while a reload is "waiting on GTK".
580+type queuedGTK struct{ ops chan func() }
581+
582+func queueGTKDispatch(wv *WebView) *queuedGTK {
583+ q := &queuedGTK{ops: make(chan func(), 4)}
584+ wv.gtkSyncIsOwner = func() bool { return false }
585+ wv.gtkSyncDispatch = func(fn func()) { q.ops <- fn }
586+ wv.gtkSyncTimeout = time.Minute
587+ return q
588+}
589+
590+// verifyAsync runs the single scheduled verify callback on its own goroutine
591+// (it blocks inside the synchronous GTK dispatch) and returns once the reload
592+// operation is queued. The returned func waits for the verify to finish.
593+func (q *queuedGTK) runQueued(t *testing.T) {
594+ t.Helper()
595+ select {
596+ case fn := <-q.ops:
597+ fn()
598+ default:
599+ t.Fatal("no GTK operation queued")
600+ }
601+}
602+
603+func startVerifyBlockedOnGTK(t *testing.T, h *gpuRecoveryHarness, q *queuedGTK) (wait func()) {
604+ t.Helper()
605+ pending := h.pending()
606+ require.Len(t, pending, 1)
607+ h.mu.Lock()
608+ h.scheduled = nil
609+ h.mu.Unlock()
610+ done := make(chan struct{})
611+ go func() {
612+ defer close(done)
613+ pending[0].fn()
614+ }()
615+ // Wait (without sleeping) until the reload operation is queued on GTK.
616+ op := <-q.ops
617+ q.ops <- op
618+ return func() { <-done }
619+}
620+
621+func TestGPURecovery_ReloadSkippedWhenSupersededWhileWaitingOnGTK(t *testing.T) {
622+ h := newGPURecoveryHarness(t)
623+ wv, _, browser := newGPURecoveryView(1)
624+ q := queueGTKDispatch(wv)
625+ e := newGPURecoveryEngine(wv)
626+ e.runGPURecoveryPass() // attempt 1 pulses, verify scheduled
627+ wait := startVerifyBlockedOnGTK(t, h, q)
628+
629+ wv.claimGPURecovery() // a newer attempt starts while the reload is queued
630+ q.runQueued(t) // GTK finally drains the old reload
631+ wait()
632+
633+ require.Zero(t, browser.reloads, "reload of a superseded attempt must not navigate")
634+}
635+
636+func TestGPURecovery_ReloadSkippedAndDeferredWhenHiddenWhileWaitingOnGTK(t *testing.T) {
637+ h := newGPURecoveryHarness(t)
638+ wv, host, browser := newGPURecoveryView(1)
639+ q := queueGTKDispatch(wv)
640+ e := newGPURecoveryEngine(wv)
641+ e.runGPURecoveryPass()
642+ wait := startVerifyBlockedOnGTK(t, h, q)
643+
644+ markHidden(wv) // hidden while the reload is queued
645+ q.runQueued(t)
646+ wait()
647+
648+ require.Zero(t, browser.reloads)
649+ require.True(t, pendingOf(wv), "hidden view must be marked pending, not dropped")
650+
651+ // Showing it later resumes recovery.
652+ pulsesBefore := host.pulses
653+ wv.applyEffectiveVisibility(host, true)
654+ require.Equal(t, pulsesBefore+2, host.pulses)
655+}
656+
657+func TestGPURecovery_ReloadSkippedWhenDestroyedWhileWaitingOnGTK(t *testing.T) {
658+ h := newGPURecoveryHarness(t)
659+ wv, _, browser := newGPURecoveryView(1)
660+ q := queueGTKDispatch(wv)
661+ e := newGPURecoveryEngine(wv)
662+ e.runGPURecoveryPass()
663+ wait := startVerifyBlockedOnGTK(t, h, q)
664+
665+ wv.destroyed.Store(true)
666+ q.runQueued(t)
667+ wait()
668+
669+ require.Zero(t, browser.reloads)
670+}
671+
672+func TestGPURecovery_ReloadProceedsWhenStillValidAfterGTKWait(t *testing.T) {
673+ h := newGPURecoveryHarness(t)
674+ wv, _, browser := newGPURecoveryView(1)
675+ q := queueGTKDispatch(wv)
676+ e := newGPURecoveryEngine(wv)
677+ e.runGPURecoveryPass()
678+ wait := startVerifyBlockedOnGTK(t, h, q)
679+
680+ q.runQueued(t)
681+ wait()
682+
683+ require.Equal(t, 1, browser.reloads)
684+}
685+
686+// GO-005: baseline and verify timer are taken when the pulse runs.
687+
688+func TestGPURecovery_VerifyNotScheduledUntilPulseRuns(t *testing.T) {
689+ h := newGPURecoveryHarness(t)
690+ wv, host, _ := newGPURecoveryView(1)
691+ e := newGPURecoveryEngine(wv)
692+ h.holdPosts = true
693+
694+ e.runGPURecoveryPass()
695+ require.Empty(t, h.pending(), "no verify timer while the pulse task is still queued")
696+ require.Zero(t, host.pulses)
697+
698+ h.holdPosts = false
699+ h.runHeld()
700+ require.Equal(t, 2, host.pulses)
701+ require.Len(t, h.pending(), 1)
702+ require.Equal(t, gpuRecoveryVerifyDelay, h.pending()[0].delay)
703+}
704+
705+func TestGPURecovery_BaselineExcludesPaintsBeforePulseRuns(t *testing.T) {
706+ h := newGPURecoveryHarness(t)
707+ wv, _, browser := newGPURecoveryView(1)
708+ e := newGPURecoveryEngine(wv)
709+ h.setPaints(wv, 10)
710+ h.holdPosts = true
711+
712+ e.runGPURecoveryPass()
713+ h.setPaints(wv, 15) // paints landed while the pulse task was queued
714+ h.runHeld() // baseline is captured now: 15
715+ h.flush() // no paint since the pulse
716+
717+ require.Equal(t, 1, browser.reloads, "paints before the pulse must not count as recovery")
718+}
719+
720+func TestGPURecovery_NoVerifyForSkippedOrStalePulse(t *testing.T) {
721+ h := newGPURecoveryHarness(t)
722+ wv, _, _ := newGPURecoveryView(1)
723+ e := newGPURecoveryEngine(wv)
724+ h.holdPosts = true
725+
726+ e.runGPURecoveryPass()
727+ markHidden(wv) // hidden before the queued pulse runs: skipped
728+ h.runHeld()
729+ require.Empty(t, h.pending(), "skipped pulse schedules no verify")
730+
731+ wv.mu.Lock()
732+ wv.effectiveVisible = true
733+ wv.mu.Unlock()
734+ e.runGPURecoveryPass() // attempt 3 queued
735+ wv.claimGPURecovery() // attempt 4 supersedes it
736+ h.runHeld()
737+ require.Empty(t, h.pending(), "stale pulse schedules no verify")
738+}
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