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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 57 | 57 | alreadyRunningAppRelaunchMu sync.RWMutex | |
| 58 | 58 | alreadyRunningAppRelaunchHandler func(string) | |
| 59 | 59 | gpuRelaunchCount atomic.Int32 | |
| 60 | + | // gpuRecoveryScheduled debounces GPU-relaunch recovery passes. | |
| 61 | + | gpuRecoveryScheduled atomic.Bool | |
| 60 | 62 | ||
| 61 | 63 | // activeWebViews tracks all live webviews for CSS broadcast. | |
| 62 | 64 | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| @@ -488,11 +490,12 @@ func (e *Engine) recordContextInitialized() { | |||
| 488 | 490 | } | |
| 489 | 491 | ||
| 490 | 492 | func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, renderNodeOverride, commandLine string) { | |
| 493 | + | var gpuLaunchCount uint64 | |
| 491 | 494 | switch processType { | |
| 492 | 495 | case "renderer": | |
| 493 | 496 | e.childLaunchRenderer.Add(1) | |
| 494 | 497 | case "gpu-process": | |
| 495 | - | e.childLaunchGPU.Add(1) | |
| 498 | + | gpuLaunchCount = e.childLaunchGPU.Add(1) | |
| 496 | 499 | default: | |
| 497 | 500 | e.childLaunchOther.Add(1) | |
| 498 | 501 | } | |
| @@ -507,6 +510,9 @@ func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, | |||
| 507 | 510 | Uint64("gpu_launches", e.childLaunchGPU.Load()). | |
| 508 | 511 | Uint64("other_launches", e.childLaunchOther.Load()). | |
| 509 | 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 | 518 | func (e *Engine) recordBrowserCreateRequest(width, height, result int32) { | |
| @@ -57,6 +57,8 @@ type Engine struct { | |||||
| 57 | alreadyRunningAppRelaunchMu sync.RWMutex | 57 | alreadyRunningAppRelaunchMu sync.RWMutex | ||
| 58 | alreadyRunningAppRelaunchHandler func(string) | 58 | alreadyRunningAppRelaunchHandler func(string) | ||
| 59 | gpuRelaunchCount atomic.Int32 | 59 | 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]*WebView | 64 | 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 ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 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 | + | ) | |
| 14 | 19 | ||
| 15 | 20 | func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, _ port.WebViewID, reason string, classification renderStallClassification) { | |
| 16 | 21 | if e == nil { | |
| @@ -141,57 +146,45 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, _ *Cef2gtkAdapter, | |||
| 141 | 146 | } | |
| 142 | 147 | wv.renderStallMu.Unlock() | |
| 143 | 148 | ||
| 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 { | |
| 149 | 151 | return | |
| 150 | 152 | } | |
| 151 | - | browserID := int32(0) | |
| 152 | - | if browser != nil { | |
| 153 | - | browserID = browser.GetIdentifier() | |
| 154 | - | } | |
| 153 | + | browserID := target.browserID | |
| 155 | 154 | logging.FromContext(wv.ctx).Warn(). | |
| 156 | 155 | Uint64("webview_id", uint64(wv.id)). | |
| 157 | 156 | Int32("browser_id", browserID). | |
| 158 | 157 | Int("accelerated_paints_before", paintsBefore). | |
| 159 | 158 | 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") | |
| 162 | 168 | return | |
| 163 | 169 | } | |
| 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) | |
| 170 | 171 | logging.FromContext(wv.ctx).Warn(). | |
| 171 | 172 | Uint64("webview_id", uint64(wv.id)). | |
| 172 | 173 | 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 { | |
| 185 | 177 | return | |
| 186 | 178 | } | |
| 187 | 179 | wv.renderStallMu.Lock() | |
| 188 | 180 | wv.renderStallRecoveryLastAt = now | |
| 189 | 181 | 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 { | |
| 192 | 185 | return | |
| 193 | 186 | } | |
| 194 | - | after := wv.viewBridge.Diagnostics().AcceleratedPaints | |
| 187 | + | after := bridge.Diagnostics().AcceleratedPaints | |
| 195 | 188 | logging.FromContext(wv.ctx).Warn(). | |
| 196 | 189 | Uint64("webview_id", uint64(wv.id)). | |
| 197 | 190 | Int32("browser_id", browserID). | |
| @@ -201,3 +194,91 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, _ *Cef2gtkAdapter, | |||
| 201 | 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 | + | } | |
| @@ -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.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 | + | ) | |||
| 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.host | 150 | + | if !ok { |
| 146 | - | browser := wv.browser | |||
| 147 | - | wv.mu.RUnlock() | |||
| 148 | - | if host == nil { | |||
| 149 | return | 151 | 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 | return | 168 | 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 | return | 177 | return | ||
| 186 | } | 178 | } | ||
| 187 | wv.renderStallMu.Lock() | 179 | wv.renderStallMu.Lock() | ||
| 188 | wv.renderStallRecoveryLastAt = now | 180 | 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 | return | 185 | return | ||
| 193 | } | 186 | } | ||
| 194 | - | after := wv.viewBridge.Diagnostics().AcceleratedPaints | 187 | + | 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 { | |||
| 130 | 130 | renderStallStop chan struct{} | |
| 131 | 131 | renderStallDone chan struct{} | |
| 132 | 132 | 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 | |
| 136 | 143 | ||
| 137 | 144 | removeSizeObserver func() | |
| 138 | 145 | viewportSyncMu sync.Mutex | |
| @@ -458,6 +465,15 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error { | |||
| 458 | 465 | } | |
| 459 | 466 | ||
| 460 | 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 | 477 | if wv.destroyed.Load() { | |
| 462 | 478 | return errDestroyed | |
| 463 | 479 | } | |
| @@ -468,6 +484,9 @@ func (wv *WebView) Reload(_ context.Context) error { | |||
| 468 | 484 | if browser == nil { | |
| 469 | 485 | return errNoBrowser | |
| 470 | 486 | } | |
| 487 | + | if proceed != nil && !proceed() { | |
| 488 | + | return nil | |
| 489 | + | } | |
| 471 | 490 | browser.Reload() | |
| 472 | 491 | return nil | |
| 473 | 492 | }) | |
| @@ -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.Time | 132 | renderStallRecoveryLastAt time.Time | ||
| 133 | - | latestProfileSnapshot cef2gtk.ProfileSnapshot | 133 | + | // gpuRecoveryPending marks a view that was hidden during a GPU recovery |
| 134 | - | previousProfileSnapshot cef2gtk.ProfileSnapshot | 134 | + | // attempt and must be recovered once it becomes visible. It is guarded by |
| 135 | - | latestProfileSnapshotAt time.Time | 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 | |||
| 136 | 143 | ||||
| 137 | removeSizeObserver func() | 144 | removeSizeObserver func() | ||
| 138 | viewportSyncMu sync.Mutex | 145 | 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 errDestroyed | 478 | 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 errNoBrowser | 485 | return errNoBrowser | ||
| 470 | } | 486 | } | ||
| 487 | + | if proceed != nil && !proceed() { | |||
| 488 | + | return nil | |||
| 489 | + | } | |||
| 471 | browser.Reload() | 490 | browser.Reload() | ||
| 472 | return nil | 491 | return nil | ||
| 473 | }) | 492 | }) | ||
M internal/infrastructure/cef/webview_scroll_cancel.go +7 -2
| @@ -28,10 +28,15 @@ func (wv *WebView) scrollCancelTarget() scrollCancelBridge { | |||
| 28 | 28 | if wv.scrollCancelSeam != nil { | |
| 29 | 29 | 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 | 37 | return nil | |
| 33 | 38 | } | |
| 34 | - | return wv.viewBridge | |
| 39 | + | return bridge | |
| 35 | 40 | } | |
| 36 | 41 | ||
| 37 | 42 | // 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.scrollCancelSeam | 29 | 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 nil | 37 | return nil | ||
| 33 | } | 38 | } | ||
| 34 | - | return wv.viewBridge | 39 | + | return bridge |
| 35 | } | 40 | } | ||
| 36 | 41 | ||||
| 37 | // scrollMotionRetired immediately retires animated scroll motion and release | 42 | // 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 | |||
| 622 | 622 | } | |
| 623 | 623 | wv.effectiveVisibilityKnown = true | |
| 624 | 624 | 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 | 632 | wv.mu.Unlock() | |
| 626 | 633 | if visible { | |
| 627 | 634 | host.WasHidden(0) | |
| 635 | + | if resumeGPURecovery { | |
| 636 | + | wv.startGPURecovery() | |
| 637 | + | } | |
| 628 | 638 | return | |
| 629 | 639 | } | |
| 630 | 640 | // 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 = true | 623 | wv.effectiveVisibilityKnown = true | ||
| 624 | wv.effectiveVisible = visible | 624 | 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 | return | 638 | return | ||
| 629 | } | 639 | } | ||
| 630 | // The hidden transition retires scroll motion: a re-shown page never | 640 | // The hidden transition retires scroll motion: a re-shown page never | ||