1package cef
2
3import (
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.
17type gpuRecoveryHost struct {
18 purecef.BrowserHost
19 pulses int
20 ops []string
21}
22
23func (h *gpuRecoveryHost) Invalidate(purecef.PaintElementType) {
24 h.pulses++
25 h.ops = append(h.ops, "Invalidate")
26}
27
28func (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
36func (h *gpuRecoveryHost) NotifyScreenInfoChanged() {
37 h.ops = append(h.ops, "NotifyScreenInfoChanged")
38}
39
40func (h *gpuRecoveryHost) WasResized() { h.ops = append(h.ops, "WasResized") }
41
42type gpuRecoveryBrowser struct {
43 purecef.Browser
44 id int32
45 reloads int
46}
47
48func (b *gpuRecoveryBrowser) GetIdentifier() int32 { return b.id }
49func (b *gpuRecoveryBrowser) Reload() { b.reloads++ }
50
51type scheduledCall struct {
52 delay time.Duration
53 fn func()
54}
55
56type 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
69func (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
79func (h *gpuRecoveryHarness) setPaints(wv *WebView, n int) {
80 h.mu.Lock()
81 defer h.mu.Unlock()
82 h.paints[wv.viewBridge] = n
83}
84
85func (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.
93func 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.
128func (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
138func 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
151func 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
159func 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
168func 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
178func 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
189func 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
207func 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
227func 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
239func 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
253func 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
266func 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
278func 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
292func 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
313func 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
327func 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
340func pendingOf(wv *WebView) bool {
341 wv.mu.RLock()
342 defer wv.mu.RUnlock()
343 return wv.gpuRecoveryPending
344}
345
346func 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
358func 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
373func 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
388func 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
419func 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
437func 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
448func 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
460func 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
473func 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
492func 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
512func 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
526func clearBridge(wv *WebView) {
527 wv.mu.Lock()
528 wv.viewBridge = nil
529 wv.mu.Unlock()
530}
531
532func 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
543func 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
555func 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".
583type queuedGTK struct{ ops chan func() }
584
585func 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.
596func (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
606func 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
637func 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
652func 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
673func 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
688func 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
704func 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
721func 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
736func 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}