detached 39cda6d

fix(cef): preserve accessibility lifecycle requests

commit
39cda6d436b91d04298a45a92dfc8631eddd4ea7
author
brice · · 2026-07-29 09:20 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
4e133f4
signature
unsigned
6 files · +311 -15
M internal/infrastructure/cef/accessibility_capture.go +53 -2
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/dumber/internal/application/port"
1313 "github.com/bnema/dumber/internal/infrastructure/config"
1414 "github.com/bnema/dumber/internal/logging"
15+ "github.com/bnema/dumber/internal/shared/syncdispatch"
1516 )
1617
1718 const (
@@ -283,11 +284,61 @@ func (wv *WebView) consumeAccessibilityPayload(p accessibilityPayload) {
283284 }
284285 }
285286
287+// abortAccessibilityCaptureSetup closes a capture worker started during
288+// newWebView without emitting Destroy's summary. Safe to call when unset.
289+func (wv *WebView) abortAccessibilityCaptureSetup() {
290+ wv.finalizeAccessibilityCapture(false)
291+}
292+
293+// failNewWebViewAfterAccessibilityCapture centralizes cleanup for every
294+// newWebView error return after enableAccessibilityCaptureIfRequested may have
295+// started the worker. Closes the worker exactly once without Destroy summary.
296+func (wv *WebView) failNewWebViewAfterAccessibilityCapture(
297+ operation string,
298+ result syncdispatch.SyncDispatchResult,
299+) error {
300+ wv.abortAccessibilityCaptureSetup()
301+ wv.destroyViewBridgeOnGTKAsync()
302+ return errGTKSyncDispatchIncomplete(operation, result)
303+}
304+
305+// newWebViewInstallViewportSyncHooks is the viewport-hook install used by
306+// newWebView. Tests replace it to force incomplete GTK dispatch after capture
307+// init without a live display.
308+var newWebViewInstallViewportSyncHooks = func(wv *WebView) syncdispatch.SyncDispatchResult {
309+ return wv.installViewportSyncHooks()
310+}
311+
312+// installNewWebViewViewportHooksAfterCapture installs viewport sync hooks and
313+// aborts capture setup if GTK dispatch does not complete.
314+func (wv *WebView) installNewWebViewViewportHooksAfterCapture() error {
315+ result := newWebViewInstallViewportSyncHooks(wv)
316+ if result.Completed() {
317+ return nil
318+ }
319+ return wv.failNewWebViewAfterAccessibilityCapture("install CEF viewport sync hooks", result)
320+}
321+
286322 func (wv *WebView) shutdownAccessibilityCapture() {
287- if wv == nil || wv.a11yWorker == nil {
323+ wv.finalizeAccessibilityCapture(true)
324+}
325+
326+// finalizeAccessibilityCapture claims one-shot ownership of worker close.
327+// Pointers stay published so concurrent enqueueAccessibilityPayload reads are
328+// race-free; Submit observes the closed worker and returns false.
329+func (wv *WebView) finalizeAccessibilityCapture(emitSummary bool) {
330+ if wv == nil {
331+ return
332+ }
333+ if !wv.a11yCaptureFinalized.CompareAndSwap(false, true) {
288334 return
289335 }
290336 worker := wv.a11yWorker
337+ if worker == nil {
338+ return
339+ }
291340 worker.Close()
292- logAccessibilitySummary(wv.ctx, wv.id, wv.a11yStats.Snapshot(), worker.Dropped())
341+ if emitSummary {
342+ logAccessibilitySummary(wv.ctx, wv.id, wv.a11yStats.Snapshot(), worker.Dropped())
343+ }
293344 }
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/infrastructure/config"13 "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
15+ "github.com/bnema/dumber/internal/shared/syncdispatch"
15 )16 )
16 17
17 const (18 const (
@@ -283,11 +284,61 @@ func (wv *WebView) consumeAccessibilityPayload(p accessibilityPayload) {
283 }284 }
284 }285 }
285 286
287+// abortAccessibilityCaptureSetup closes a capture worker started during
288+// newWebView without emitting Destroy's summary. Safe to call when unset.
289+func (wv *WebView) abortAccessibilityCaptureSetup() {
290+ wv.finalizeAccessibilityCapture(false)
291+}
292+
293+// failNewWebViewAfterAccessibilityCapture centralizes cleanup for every
294+// newWebView error return after enableAccessibilityCaptureIfRequested may have
295+// started the worker. Closes the worker exactly once without Destroy summary.
296+func (wv *WebView) failNewWebViewAfterAccessibilityCapture(
297+ operation string,
298+ result syncdispatch.SyncDispatchResult,
299+) error {
300+ wv.abortAccessibilityCaptureSetup()
301+ wv.destroyViewBridgeOnGTKAsync()
302+ return errGTKSyncDispatchIncomplete(operation, result)
303+}
304+
305+// newWebViewInstallViewportSyncHooks is the viewport-hook install used by
306+// newWebView. Tests replace it to force incomplete GTK dispatch after capture
307+// init without a live display.
308+var newWebViewInstallViewportSyncHooks = func(wv *WebView) syncdispatch.SyncDispatchResult {
309+ return wv.installViewportSyncHooks()
310+}
311+
312+// installNewWebViewViewportHooksAfterCapture installs viewport sync hooks and
313+// aborts capture setup if GTK dispatch does not complete.
314+func (wv *WebView) installNewWebViewViewportHooksAfterCapture() error {
315+ result := newWebViewInstallViewportSyncHooks(wv)
316+ if result.Completed() {
317+ return nil
318+ }
319+ return wv.failNewWebViewAfterAccessibilityCapture("install CEF viewport sync hooks", result)
320+}
321+
286 func (wv *WebView) shutdownAccessibilityCapture() {322 func (wv *WebView) shutdownAccessibilityCapture() {
287- if wv == nil || wv.a11yWorker == nil {323+ wv.finalizeAccessibilityCapture(true)
324+}
325+
326+// finalizeAccessibilityCapture claims one-shot ownership of worker close.
327+// Pointers stay published so concurrent enqueueAccessibilityPayload reads are
328+// race-free; Submit observes the closed worker and returns false.
329+func (wv *WebView) finalizeAccessibilityCapture(emitSummary bool) {
330+ if wv == nil {
331+ return
332+ }
333+ if !wv.a11yCaptureFinalized.CompareAndSwap(false, true) {
288 return334 return
289 }335 }
290 worker := wv.a11yWorker336 worker := wv.a11yWorker
337+ if worker == nil {
338+ return
339+ }
291 worker.Close()340 worker.Close()
292- logAccessibilitySummary(wv.ctx, wv.id, wv.a11yStats.Snapshot(), worker.Dropped())341+ if emitSummary {
342+ logAccessibilitySummary(wv.ctx, wv.id, wv.a11yStats.Snapshot(), worker.Dropped())
343+ }
293 }344 }
M internal/infrastructure/cef/accessibility_capture_test.go +158 -0
@@ -13,6 +13,7 @@ import (
1313
1414 "github.com/bnema/dumber/internal/application/port"
1515 "github.com/bnema/dumber/internal/logging"
16+ "github.com/bnema/dumber/internal/shared/syncdispatch"
1617 purecef "github.com/bnema/purego-cef/cef"
1718 "github.com/rs/zerolog"
1819 "github.com/stretchr/testify/assert"
@@ -397,8 +398,13 @@ func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) {
397398 return wv.a11yStats.Snapshot().Count == 1
398399 }, 2*time.Second, 10*time.Millisecond)
399400
401+ worker := wv.a11yWorker
400402 wv.shutdownAccessibilityCapture()
401403
404+ require.True(t, wv.a11yCaptureFinalized.Load())
405+ require.Same(t, worker, wv.a11yWorker)
406+ require.NotNil(t, wv.a11yCapture)
407+
402408 var found bool
403409 dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
404410 for dec.More() {
@@ -415,3 +421,155 @@ func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) {
415421 assert.True(t, found)
416422 require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
417423 }
424+
425+func TestInstallNewWebViewViewportHooksAfterCapture_FailureClosesWorkerOnce(t *testing.T) {
426+ home := t.TempDir()
427+ t.Setenv("HOME", home)
428+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
429+ t.Setenv("DUMBER_A11Y_CAPTURE", "1")
430+
431+ prev := newWebViewInstallViewportSyncHooks
432+ t.Cleanup(func() { newWebViewInstallViewportSyncHooks = prev })
433+ newWebViewInstallViewportSyncHooks = func(*WebView) syncdispatch.SyncDispatchResult {
434+ return syncdispatch.SyncDispatchResult{
435+ Label: "cef.install_viewport_sync_hooks",
436+ Status: syncdispatch.SyncDispatchTimedOut,
437+ }
438+ }
439+
440+ var output bytes.Buffer
441+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
442+ ctx := logging.WithContext(context.Background(), logger)
443+
444+ wv := &WebView{
445+ ctx: ctx,
446+ id: port.WebViewID(99),
447+ gtkSyncDispatch: func(fn func()) {
448+ if fn != nil {
449+ fn()
450+ }
451+ },
452+ gtkSyncIsOwner: func() bool { return true },
453+ viewBridge: &Cef2gtkAdapter{},
454+ }
455+ require.NoError(t, wv.enableAccessibilityCaptureIfRequested())
456+ worker := wv.a11yWorker
457+ require.NotNil(t, worker)
458+
459+ err := wv.installNewWebViewViewportHooksAfterCapture()
460+ require.Error(t, err)
461+ require.Contains(t, err.Error(), "install CEF viewport sync hooks")
462+ require.True(t, wv.a11yCaptureFinalized.Load())
463+ require.Same(t, worker, wv.a11yWorker, "published worker pointer must stay immutable")
464+ require.NotNil(t, wv.a11yCapture, "published capture pointer must stay immutable")
465+ require.True(t, worker.closed)
466+ require.False(t, worker.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
467+ require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
468+
469+ wv.shutdownAccessibilityCapture()
470+ wv.Destroy()
471+
472+ summaryCount := 0
473+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
474+ for dec.More() {
475+ var rec map[string]any
476+ require.NoError(t, dec.Decode(&rec))
477+ if rec["message"] == "cef: accessibility summary" {
478+ summaryCount++
479+ }
480+ }
481+ assert.Equal(t, 0, summaryCount, "failed init must not emit Destroy summary")
482+}
483+
484+func TestShutdownAccessibilityCapture_IdempotentAfterAbort(t *testing.T) {
485+ dir := t.TempDir()
486+ capture, err := newAccessibilityCapture(dir)
487+ require.NoError(t, err)
488+
489+ var output bytes.Buffer
490+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
491+ ctx := logging.WithContext(context.Background(), logger)
492+
493+ wv := &WebView{ctx: ctx, id: port.WebViewID(7), a11yCapture: capture}
494+ wv.a11yWorker = newAccessibilityCaptureWorker(2, wv.consumeAccessibilityPayload)
495+
496+ wv.abortAccessibilityCaptureSetup()
497+ require.True(t, wv.a11yCaptureFinalized.Load())
498+ require.NotNil(t, wv.a11yWorker)
499+ require.True(t, wv.a11yWorker.closed)
500+ require.NotNil(t, wv.a11yCapture)
501+ wv.shutdownAccessibilityCapture()
502+ wv.shutdownAccessibilityCapture()
503+
504+ summaryCount := 0
505+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
506+ for dec.More() {
507+ var rec map[string]any
508+ require.NoError(t, dec.Decode(&rec))
509+ if rec["message"] == "cef: accessibility summary" {
510+ summaryCount++
511+ }
512+ }
513+ assert.Equal(t, 0, summaryCount)
514+}
515+
516+func TestEnqueueAccessibilityPayload_ConcurrentWithShutdownRaceFree(t *testing.T) {
517+ dir := t.TempDir()
518+ capture, err := newAccessibilityCapture(dir)
519+ require.NoError(t, err)
520+
521+ var output bytes.Buffer
522+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
523+ ctx := logging.WithContext(context.Background(), logger)
524+
525+ wv := &WebView{ctx: ctx, id: port.WebViewID(11), a11yCapture: capture}
526+ wv.a11yWorker = newAccessibilityCaptureWorker(64, wv.consumeAccessibilityPayload)
527+ worker := wv.a11yWorker
528+
529+ const producers = 8
530+ const perProducer = 200
531+ var wg sync.WaitGroup
532+ start := make(chan struct{})
533+ for range producers {
534+ wg.Add(1)
535+ go func() {
536+ defer wg.Done()
537+ <-start
538+ for range perProducer {
539+ _ = wv.enqueueAccessibilityPayload(accessibilityPayload{
540+ Kind: accessibilityCaptureKindTree, JSON: `{}`, Bytes: 2, SerializeNanos: 1,
541+ })
542+ }
543+ }()
544+ }
545+ wg.Add(1)
546+ go func() {
547+ defer wg.Done()
548+ <-start
549+ wv.shutdownAccessibilityCapture()
550+ }()
551+ close(start)
552+ wg.Wait()
553+
554+ require.True(t, wv.a11yCaptureFinalized.Load())
555+ require.Same(t, worker, wv.a11yWorker)
556+ require.NotNil(t, wv.a11yCapture)
557+ require.True(t, worker.closed)
558+ assert.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
559+ Kind: accessibilityCaptureKindTree, JSON: `{}`,
560+ }))
561+
562+ wv.shutdownAccessibilityCapture()
563+ wv.abortAccessibilityCaptureSetup()
564+
565+ summaryCount := 0
566+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
567+ for dec.More() {
568+ var rec map[string]any
569+ require.NoError(t, dec.Decode(&rec))
570+ if rec["message"] == "cef: accessibility summary" {
571+ summaryCount++
572+ }
573+ }
574+ assert.Equal(t, 1, summaryCount, "successful shutdown must emit exactly one summary")
575+}
@@ -13,6 +13,7 @@ import (
13 13
14 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
15 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
16+ "github.com/bnema/dumber/internal/shared/syncdispatch"
16 purecef "github.com/bnema/purego-cef/cef"17 purecef "github.com/bnema/purego-cef/cef"
17 "github.com/rs/zerolog"18 "github.com/rs/zerolog"
18 "github.com/stretchr/testify/assert"19 "github.com/stretchr/testify/assert"
@@ -397,8 +398,13 @@ func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) {
397 return wv.a11yStats.Snapshot().Count == 1398 return wv.a11yStats.Snapshot().Count == 1
398 }, 2*time.Second, 10*time.Millisecond)399 }, 2*time.Second, 10*time.Millisecond)
399 400
401+ worker := wv.a11yWorker
400 wv.shutdownAccessibilityCapture()402 wv.shutdownAccessibilityCapture()
401 403
404+ require.True(t, wv.a11yCaptureFinalized.Load())
405+ require.Same(t, worker, wv.a11yWorker)
406+ require.NotNil(t, wv.a11yCapture)
407+
402 var found bool408 var found bool
403 dec := json.NewDecoder(bytes.NewReader(output.Bytes()))409 dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
404 for dec.More() {410 for dec.More() {
@@ -415,3 +421,155 @@ func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) {
415 assert.True(t, found)421 assert.True(t, found)
416 require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))422 require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
417 }423 }
424+
425+func TestInstallNewWebViewViewportHooksAfterCapture_FailureClosesWorkerOnce(t *testing.T) {
426+ home := t.TempDir()
427+ t.Setenv("HOME", home)
428+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
429+ t.Setenv("DUMBER_A11Y_CAPTURE", "1")
430+
431+ prev := newWebViewInstallViewportSyncHooks
432+ t.Cleanup(func() { newWebViewInstallViewportSyncHooks = prev })
433+ newWebViewInstallViewportSyncHooks = func(*WebView) syncdispatch.SyncDispatchResult {
434+ return syncdispatch.SyncDispatchResult{
435+ Label: "cef.install_viewport_sync_hooks",
436+ Status: syncdispatch.SyncDispatchTimedOut,
437+ }
438+ }
439+
440+ var output bytes.Buffer
441+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
442+ ctx := logging.WithContext(context.Background(), logger)
443+
444+ wv := &WebView{
445+ ctx: ctx,
446+ id: port.WebViewID(99),
447+ gtkSyncDispatch: func(fn func()) {
448+ if fn != nil {
449+ fn()
450+ }
451+ },
452+ gtkSyncIsOwner: func() bool { return true },
453+ viewBridge: &Cef2gtkAdapter{},
454+ }
455+ require.NoError(t, wv.enableAccessibilityCaptureIfRequested())
456+ worker := wv.a11yWorker
457+ require.NotNil(t, worker)
458+
459+ err := wv.installNewWebViewViewportHooksAfterCapture()
460+ require.Error(t, err)
461+ require.Contains(t, err.Error(), "install CEF viewport sync hooks")
462+ require.True(t, wv.a11yCaptureFinalized.Load())
463+ require.Same(t, worker, wv.a11yWorker, "published worker pointer must stay immutable")
464+ require.NotNil(t, wv.a11yCapture, "published capture pointer must stay immutable")
465+ require.True(t, worker.closed)
466+ require.False(t, worker.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
467+ require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
468+
469+ wv.shutdownAccessibilityCapture()
470+ wv.Destroy()
471+
472+ summaryCount := 0
473+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
474+ for dec.More() {
475+ var rec map[string]any
476+ require.NoError(t, dec.Decode(&rec))
477+ if rec["message"] == "cef: accessibility summary" {
478+ summaryCount++
479+ }
480+ }
481+ assert.Equal(t, 0, summaryCount, "failed init must not emit Destroy summary")
482+}
483+
484+func TestShutdownAccessibilityCapture_IdempotentAfterAbort(t *testing.T) {
485+ dir := t.TempDir()
486+ capture, err := newAccessibilityCapture(dir)
487+ require.NoError(t, err)
488+
489+ var output bytes.Buffer
490+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
491+ ctx := logging.WithContext(context.Background(), logger)
492+
493+ wv := &WebView{ctx: ctx, id: port.WebViewID(7), a11yCapture: capture}
494+ wv.a11yWorker = newAccessibilityCaptureWorker(2, wv.consumeAccessibilityPayload)
495+
496+ wv.abortAccessibilityCaptureSetup()
497+ require.True(t, wv.a11yCaptureFinalized.Load())
498+ require.NotNil(t, wv.a11yWorker)
499+ require.True(t, wv.a11yWorker.closed)
500+ require.NotNil(t, wv.a11yCapture)
501+ wv.shutdownAccessibilityCapture()
502+ wv.shutdownAccessibilityCapture()
503+
504+ summaryCount := 0
505+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
506+ for dec.More() {
507+ var rec map[string]any
508+ require.NoError(t, dec.Decode(&rec))
509+ if rec["message"] == "cef: accessibility summary" {
510+ summaryCount++
511+ }
512+ }
513+ assert.Equal(t, 0, summaryCount)
514+}
515+
516+func TestEnqueueAccessibilityPayload_ConcurrentWithShutdownRaceFree(t *testing.T) {
517+ dir := t.TempDir()
518+ capture, err := newAccessibilityCapture(dir)
519+ require.NoError(t, err)
520+
521+ var output bytes.Buffer
522+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
523+ ctx := logging.WithContext(context.Background(), logger)
524+
525+ wv := &WebView{ctx: ctx, id: port.WebViewID(11), a11yCapture: capture}
526+ wv.a11yWorker = newAccessibilityCaptureWorker(64, wv.consumeAccessibilityPayload)
527+ worker := wv.a11yWorker
528+
529+ const producers = 8
530+ const perProducer = 200
531+ var wg sync.WaitGroup
532+ start := make(chan struct{})
533+ for range producers {
534+ wg.Add(1)
535+ go func() {
536+ defer wg.Done()
537+ <-start
538+ for range perProducer {
539+ _ = wv.enqueueAccessibilityPayload(accessibilityPayload{
540+ Kind: accessibilityCaptureKindTree, JSON: `{}`, Bytes: 2, SerializeNanos: 1,
541+ })
542+ }
543+ }()
544+ }
545+ wg.Add(1)
546+ go func() {
547+ defer wg.Done()
548+ <-start
549+ wv.shutdownAccessibilityCapture()
550+ }()
551+ close(start)
552+ wg.Wait()
553+
554+ require.True(t, wv.a11yCaptureFinalized.Load())
555+ require.Same(t, worker, wv.a11yWorker)
556+ require.NotNil(t, wv.a11yCapture)
557+ require.True(t, worker.closed)
558+ assert.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
559+ Kind: accessibilityCaptureKindTree, JSON: `{}`,
560+ }))
561+
562+ wv.shutdownAccessibilityCapture()
563+ wv.abortAccessibilityCaptureSetup()
564+
565+ summaryCount := 0
566+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
567+ for dec.More() {
568+ var rec map[string]any
569+ require.NoError(t, dec.Decode(&rec))
570+ if rec["message"] == "cef: accessibility summary" {
571+ summaryCount++
572+ }
573+ }
574+ assert.Equal(t, 1, summaryCount, "successful shutdown must emit exactly one summary")
575+}
M internal/infrastructure/cef/accessibility_enable_test.go +73 -2
@@ -1,6 +1,7 @@
11 package cef
22
33 import (
4+ "context"
45 "sync"
56 "testing"
67
@@ -41,10 +42,12 @@ func TestWebView_EnableAccessibility_NoOpWhenDestroyedOrHostNil(t *testing.T) {
4142 destroyed.host = host
4243 destroyed.EnableAccessibility()
4344 assert.Empty(t, host.recordedStates())
45+ assert.False(t, destroyed.a11yRequested.Load())
4446
4547 noHost := &WebView{}
4648 noHost.EnableAccessibility()
4749 assert.False(t, noHost.a11yEnabled.Load())
50+ assert.True(t, noHost.a11yRequested.Load(), "early request must persist while host is nil")
4851 }
4952
5053 func TestWebView_EnableAccessibility_IdempotentOncePerWebView(t *testing.T) {
@@ -81,22 +84,90 @@ func TestWebView_EnableAccessibility_IndependentPerWebView(t *testing.T) {
8184 assert.Equal(t, purecef.StateStateEnabled, hostB.recordedStates()[0])
8285 }
8386
84-func TestWebView_EnableAccessibility_RetriesWhenHostBecomesReady(t *testing.T) {
87+func TestWebView_EnableAccessibility_PersistsUntilHostAttachment(t *testing.T) {
8588 t.Parallel()
8689
8790 wv := &WebView{}
8891 wv.EnableAccessibility()
92+ assert.True(t, wv.a11yRequested.Load())
8993 assert.False(t, wv.a11yEnabled.Load())
9094
9195 host := &accessibilityStateHost{}
9296 wv.host = host
93- wv.EnableAccessibility()
97+ wv.applyAccessibilityIfReady()
9498
9599 require.Len(t, host.recordedStates(), 1)
96100 assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
97101 assert.True(t, wv.a11yEnabled.Load())
98102 }
99103
104+func TestWebView_EnableAccessibility_ConcurrentCallsEnableOnce(t *testing.T) {
105+ t.Parallel()
106+
107+ host := &accessibilityStateHost{}
108+ wv := &WebView{host: host}
109+
110+ var wg sync.WaitGroup
111+ for range 32 {
112+ wg.Add(1)
113+ go func() {
114+ defer wg.Done()
115+ wv.EnableAccessibility()
116+ }()
117+ }
118+ wg.Wait()
119+
120+ require.Len(t, host.recordedStates(), 1)
121+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
122+}
123+
124+func TestWebView_EnableAccessibility_RequestThenDestroyBeforeAttachmentNeverEnables(t *testing.T) {
125+ t.Parallel()
126+
127+ wv := &WebView{}
128+ wv.EnableAccessibility()
129+ assert.True(t, wv.a11yRequested.Load())
130+
131+ wv.Destroy()
132+
133+ host := &accessibilityStateHost{}
134+ wv.host = host
135+ wv.applyAccessibilityIfReady()
136+ wv.EnableAccessibility()
137+
138+ assert.Empty(t, host.recordedStates())
139+ assert.False(t, wv.a11yEnabled.Load())
140+}
141+
142+func TestFinishAfterCreated_AppliesPendingAccessibilityOnce(t *testing.T) {
143+ t.Parallel()
144+
145+ wv := &WebView{ctx: context.Background()}
146+ wv.EnableAccessibility()
147+ assert.False(t, wv.a11yEnabled.Load())
148+
149+ host := &accessibilityStateHost{}
150+ wv.host = host
151+ h := &handlerSet{wv: wv}
152+ h.finishAfterCreated(nil, host, afterCreatedState{})
153+ h.finishAfterCreated(nil, host, afterCreatedState{})
154+
155+ require.Len(t, host.recordedStates(), 1)
156+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
157+}
158+
159+func TestFinishAfterCreated_SkipsWhenNoPendingRequest(t *testing.T) {
160+ t.Parallel()
161+
162+ host := &accessibilityStateHost{}
163+ wv := &WebView{ctx: context.Background(), host: host}
164+ h := &handlerSet{wv: wv}
165+ h.finishAfterCreated(nil, host, afterCreatedState{})
166+
167+ assert.Empty(t, host.recordedStates())
168+ assert.False(t, wv.a11yEnabled.Load())
169+}
170+
100171 func TestWebView_ImplementsAccessibilityEnabler(t *testing.T) {
101172 t.Parallel()
102173 var _ port.AccessibilityEnabler = (*WebView)(nil)
@@ -1,6 +1,7 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 "sync"5 "sync"
5 "testing"6 "testing"
6 7
@@ -41,10 +42,12 @@ func TestWebView_EnableAccessibility_NoOpWhenDestroyedOrHostNil(t *testing.T) {
41 destroyed.host = host42 destroyed.host = host
42 destroyed.EnableAccessibility()43 destroyed.EnableAccessibility()
43 assert.Empty(t, host.recordedStates())44 assert.Empty(t, host.recordedStates())
45+ assert.False(t, destroyed.a11yRequested.Load())
44 46
45 noHost := &WebView{}47 noHost := &WebView{}
46 noHost.EnableAccessibility()48 noHost.EnableAccessibility()
47 assert.False(t, noHost.a11yEnabled.Load())49 assert.False(t, noHost.a11yEnabled.Load())
50+ assert.True(t, noHost.a11yRequested.Load(), "early request must persist while host is nil")
48 }51 }
49 52
50 func TestWebView_EnableAccessibility_IdempotentOncePerWebView(t *testing.T) {53 func TestWebView_EnableAccessibility_IdempotentOncePerWebView(t *testing.T) {
@@ -81,22 +84,90 @@ func TestWebView_EnableAccessibility_IndependentPerWebView(t *testing.T) {
81 assert.Equal(t, purecef.StateStateEnabled, hostB.recordedStates()[0])84 assert.Equal(t, purecef.StateStateEnabled, hostB.recordedStates()[0])
82 }85 }
83 86
84-func TestWebView_EnableAccessibility_RetriesWhenHostBecomesReady(t *testing.T) {87+func TestWebView_EnableAccessibility_PersistsUntilHostAttachment(t *testing.T) {
85 t.Parallel()88 t.Parallel()
86 89
87 wv := &WebView{}90 wv := &WebView{}
88 wv.EnableAccessibility()91 wv.EnableAccessibility()
92+ assert.True(t, wv.a11yRequested.Load())
89 assert.False(t, wv.a11yEnabled.Load())93 assert.False(t, wv.a11yEnabled.Load())
90 94
91 host := &accessibilityStateHost{}95 host := &accessibilityStateHost{}
92 wv.host = host96 wv.host = host
93- wv.EnableAccessibility()97+ wv.applyAccessibilityIfReady()
94 98
95 require.Len(t, host.recordedStates(), 1)99 require.Len(t, host.recordedStates(), 1)
96 assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])100 assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
97 assert.True(t, wv.a11yEnabled.Load())101 assert.True(t, wv.a11yEnabled.Load())
98 }102 }
99 103
104+func TestWebView_EnableAccessibility_ConcurrentCallsEnableOnce(t *testing.T) {
105+ t.Parallel()
106+
107+ host := &accessibilityStateHost{}
108+ wv := &WebView{host: host}
109+
110+ var wg sync.WaitGroup
111+ for range 32 {
112+ wg.Add(1)
113+ go func() {
114+ defer wg.Done()
115+ wv.EnableAccessibility()
116+ }()
117+ }
118+ wg.Wait()
119+
120+ require.Len(t, host.recordedStates(), 1)
121+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
122+}
123+
124+func TestWebView_EnableAccessibility_RequestThenDestroyBeforeAttachmentNeverEnables(t *testing.T) {
125+ t.Parallel()
126+
127+ wv := &WebView{}
128+ wv.EnableAccessibility()
129+ assert.True(t, wv.a11yRequested.Load())
130+
131+ wv.Destroy()
132+
133+ host := &accessibilityStateHost{}
134+ wv.host = host
135+ wv.applyAccessibilityIfReady()
136+ wv.EnableAccessibility()
137+
138+ assert.Empty(t, host.recordedStates())
139+ assert.False(t, wv.a11yEnabled.Load())
140+}
141+
142+func TestFinishAfterCreated_AppliesPendingAccessibilityOnce(t *testing.T) {
143+ t.Parallel()
144+
145+ wv := &WebView{ctx: context.Background()}
146+ wv.EnableAccessibility()
147+ assert.False(t, wv.a11yEnabled.Load())
148+
149+ host := &accessibilityStateHost{}
150+ wv.host = host
151+ h := &handlerSet{wv: wv}
152+ h.finishAfterCreated(nil, host, afterCreatedState{})
153+ h.finishAfterCreated(nil, host, afterCreatedState{})
154+
155+ require.Len(t, host.recordedStates(), 1)
156+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
157+}
158+
159+func TestFinishAfterCreated_SkipsWhenNoPendingRequest(t *testing.T) {
160+ t.Parallel()
161+
162+ host := &accessibilityStateHost{}
163+ wv := &WebView{ctx: context.Background(), host: host}
164+ h := &handlerSet{wv: wv}
165+ h.finishAfterCreated(nil, host, afterCreatedState{})
166+
167+ assert.Empty(t, host.recordedStates())
168+ assert.False(t, wv.a11yEnabled.Load())
169+}
170+
100 func TestWebView_ImplementsAccessibilityEnabler(t *testing.T) {171 func TestWebView_ImplementsAccessibilityEnabler(t *testing.T) {
101 t.Parallel()172 t.Parallel()
102 var _ port.AccessibilityEnabler = (*WebView)(nil)173 var _ port.AccessibilityEnabler = (*WebView)(nil)
M internal/infrastructure/cef/factory.go +2 -4
@@ -172,10 +172,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
172172 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
173173 }
174174 }
175- viewportHooksResult := wv.installViewportSyncHooks()
176- if !viewportHooksResult.Completed() {
177- wv.destroyViewBridgeOnGTKAsync()
178- return nil, errGTKSyncDispatchIncomplete("install CEF viewport sync hooks", viewportHooksResult)
175+ if err := wv.installNewWebViewViewportHooksAfterCapture(); err != nil {
176+ return nil, err
179177 }
180178 wv.startRenderStallWatchdog()
181179 return wv, nil
@@ -172,10 +172,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
172 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")172 logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling")
173 }173 }
174 }174 }
175- viewportHooksResult := wv.installViewportSyncHooks()175+ if err := wv.installNewWebViewViewportHooksAfterCapture(); err != nil {
176- if !viewportHooksResult.Completed() {176+ return nil, err
177- wv.destroyViewBridgeOnGTKAsync()
178- return nil, errGTKSyncDispatchIncomplete("install CEF viewport sync hooks", viewportHooksResult)
179 }177 }
180 wv.startRenderStallWatchdog()178 wv.startRenderStallWatchdog()
181 return wv, nil179 return wv, nil
M internal/infrastructure/cef/webview.go +24 -7
@@ -155,10 +155,16 @@ type WebView struct {
155155
156156 // a11yWorker receives serialized accessibility payloads off the CEF UI
157157 // thread. Production leaves it nil unless DUMBER_A11Y_CAPTURE=1.
158- a11yWorker *accessibilityCaptureWorker
159- a11yCapture *accessibilityCapture
160- a11yStats accessibilityStats
161- a11yEnabled atomic.Bool
158+ // After publication, a11yWorker/a11yCapture stay immutable; teardown uses
159+ // a11yCaptureFinalized so abort/shutdown close the worker once without
160+ // racing CEF callback sinks that still read these pointers.
161+ a11yWorker *accessibilityCaptureWorker
162+ a11yCapture *accessibilityCapture
163+ a11yCaptureFinalized atomic.Bool
164+ a11yStats accessibilityStats
165+ // a11yRequested records EnableAccessibility before the browser host exists.
166+ a11yRequested atomic.Bool
167+ a11yEnabled atomic.Bool
162168
163169 // beginFrameTick drives CEF external BeginFrame requests while the GTK
164170 // widget is visible. Access is guarded by mu.
@@ -2543,10 +2549,21 @@ func (wv *WebView) CancelPageScroll(_ context.Context) {
25432549 q.commitMu.Unlock()
25442550 }
25452551
2546-// EnableAccessibility turns on CEF accessibility for this WebView exactly once.
2547-// Calls before the browser host exists are no-ops so UI can safely retry.
2552+// EnableAccessibility records a request to enable CEF accessibility and applies
2553+// it once the browser host exists. Destroyed views never enable. Calls before
2554+// host attachment remain pending until OnAfterCreated applies them.
25482555 func (wv *WebView) EnableAccessibility() {
2549- if wv == nil || wv.destroyed.Load() || wv.a11yEnabled.Load() {
2556+ if wv == nil || wv.destroyed.Load() {
2557+ return
2558+ }
2559+ wv.a11yRequested.Store(true)
2560+ wv.applyAccessibilityIfReady()
2561+}
2562+
2563+// applyAccessibilityIfReady performs exactly one SetAccessibilityState after a
2564+// pending request and a live host are both present.
2565+func (wv *WebView) applyAccessibilityIfReady() {
2566+ if wv == nil || wv.destroyed.Load() || !wv.a11yRequested.Load() || wv.a11yEnabled.Load() {
25502567 return
25512568 }
25522569 wv.mu.RLock()
@@ -155,10 +155,16 @@ type WebView struct {
155 155
156 // a11yWorker receives serialized accessibility payloads off the CEF UI156 // a11yWorker receives serialized accessibility payloads off the CEF UI
157 // thread. Production leaves it nil unless DUMBER_A11Y_CAPTURE=1.157 // thread. Production leaves it nil unless DUMBER_A11Y_CAPTURE=1.
158- a11yWorker *accessibilityCaptureWorker158+ // After publication, a11yWorker/a11yCapture stay immutable; teardown uses
159- a11yCapture *accessibilityCapture159+ // a11yCaptureFinalized so abort/shutdown close the worker once without
160- a11yStats accessibilityStats160+ // racing CEF callback sinks that still read these pointers.
161- a11yEnabled atomic.Bool161+ a11yWorker *accessibilityCaptureWorker
162+ a11yCapture *accessibilityCapture
163+ a11yCaptureFinalized atomic.Bool
164+ a11yStats accessibilityStats
165+ // a11yRequested records EnableAccessibility before the browser host exists.
166+ a11yRequested atomic.Bool
167+ a11yEnabled atomic.Bool
162 168
163 // beginFrameTick drives CEF external BeginFrame requests while the GTK169 // beginFrameTick drives CEF external BeginFrame requests while the GTK
164 // widget is visible. Access is guarded by mu.170 // widget is visible. Access is guarded by mu.
@@ -2543,10 +2549,21 @@ func (wv *WebView) CancelPageScroll(_ context.Context) {
2543 q.commitMu.Unlock()2549 q.commitMu.Unlock()
2544 }2550 }
2545 2551
2546-// EnableAccessibility turns on CEF accessibility for this WebView exactly once.2552+// EnableAccessibility records a request to enable CEF accessibility and applies
2547-// Calls before the browser host exists are no-ops so UI can safely retry.2553+// it once the browser host exists. Destroyed views never enable. Calls before
2554+// host attachment remain pending until OnAfterCreated applies them.
2548 func (wv *WebView) EnableAccessibility() {2555 func (wv *WebView) EnableAccessibility() {
2549- if wv == nil || wv.destroyed.Load() || wv.a11yEnabled.Load() {2556+ if wv == nil || wv.destroyed.Load() {
2557+ return
2558+ }
2559+ wv.a11yRequested.Store(true)
2560+ wv.applyAccessibilityIfReady()
2561+}
2562+
2563+// applyAccessibilityIfReady performs exactly one SetAccessibilityState after a
2564+// pending request and a live host are both present.
2565+func (wv *WebView) applyAccessibilityIfReady() {
2566+ if wv == nil || wv.destroyed.Load() || !wv.a11yRequested.Load() || wv.a11yEnabled.Load() {
2550 return2567 return
2551 }2568 }
2552 wv.mu.RLock()2569 wv.mu.RLock()
M internal/infrastructure/cef/webview_handlers.go +1 -0
@@ -955,6 +955,7 @@ func (h *handlerSet) finishAfterCreated(
955955 }
956956 h.wv.scheduleStartAdaptiveFrameRatePolling()
957957 h.wv.scheduleStartBeginFrameLoop()
958+ h.wv.applyAccessibilityIfReady()
958959 h.wv.fireReadyToShow()
959960 }
960961
@@ -955,6 +955,7 @@ func (h *handlerSet) finishAfterCreated(
955 }955 }
956 h.wv.scheduleStartAdaptiveFrameRatePolling()956 h.wv.scheduleStartAdaptiveFrameRatePolling()
957 h.wv.scheduleStartBeginFrameLoop()957 h.wv.scheduleStartBeginFrameLoop()
958+ h.wv.applyAccessibilityIfReady()
958 h.wv.fireReadyToShow()959 h.wv.fireReadyToShow()
959 }960 }
960 961

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