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