v0.33.1 9577fee

gpu_recovery_test.go RawHistoryFolder

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}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close