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 ( | |||
| 12 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 15 | + | "github.com/bnema/dumber/internal/shared/syncdispatch" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 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 | 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 | 334 | return | |
| 289 | 335 | } | |
| 290 | 336 | worker := wv.a11yWorker | |
| 337 | + | if worker == nil { | |
| 338 | + | return | |
| 339 | + | } | |
| 291 | 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 | } | |
| @@ -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 | return | 334 | return | ||
| 289 | } | 335 | } | ||
| 290 | worker := wv.a11yWorker | 336 | 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 ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/dumber/internal/application/port" | |
| 15 | 15 | "github.com/bnema/dumber/internal/logging" | |
| 16 | + | "github.com/bnema/dumber/internal/shared/syncdispatch" | |
| 16 | 17 | purecef "github.com/bnema/purego-cef/cef" | |
| 17 | 18 | "github.com/rs/zerolog" | |
| 18 | 19 | "github.com/stretchr/testify/assert" | |
| @@ -397,8 +398,13 @@ func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) { | |||
| 397 | 398 | return wv.a11yStats.Snapshot().Count == 1 | |
| 398 | 399 | }, 2*time.Second, 10*time.Millisecond) | |
| 399 | 400 | ||
| 401 | + | worker := wv.a11yWorker | |
| 400 | 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 | 408 | var found bool | |
| 403 | 409 | dec := json.NewDecoder(bytes.NewReader(output.Bytes())) | |
| 404 | 410 | for dec.More() { | |
| @@ -415,3 +421,155 @@ func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) { | |||
| 415 | 421 | assert.True(t, found) | |
| 416 | 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 | + | } | |
| @@ -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 == 1 | 398 | 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 bool | 408 | 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 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "sync" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -41,10 +42,12 @@ func TestWebView_EnableAccessibility_NoOpWhenDestroyedOrHostNil(t *testing.T) { | |||
| 41 | 42 | destroyed.host = host | |
| 42 | 43 | destroyed.EnableAccessibility() | |
| 43 | 44 | assert.Empty(t, host.recordedStates()) | |
| 45 | + | assert.False(t, destroyed.a11yRequested.Load()) | |
| 44 | 46 | ||
| 45 | 47 | noHost := &WebView{} | |
| 46 | 48 | noHost.EnableAccessibility() | |
| 47 | 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 | 53 | func TestWebView_EnableAccessibility_IdempotentOncePerWebView(t *testing.T) { | |
| @@ -81,22 +84,90 @@ func TestWebView_EnableAccessibility_IndependentPerWebView(t *testing.T) { | |||
| 81 | 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 | 88 | t.Parallel() | |
| 86 | 89 | ||
| 87 | 90 | wv := &WebView{} | |
| 88 | 91 | wv.EnableAccessibility() | |
| 92 | + | assert.True(t, wv.a11yRequested.Load()) | |
| 89 | 93 | assert.False(t, wv.a11yEnabled.Load()) | |
| 90 | 94 | ||
| 91 | 95 | host := &accessibilityStateHost{} | |
| 92 | 96 | wv.host = host | |
| 93 | - | wv.EnableAccessibility() | |
| 97 | + | wv.applyAccessibilityIfReady() | |
| 94 | 98 | ||
| 95 | 99 | require.Len(t, host.recordedStates(), 1) | |
| 96 | 100 | assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0]) | |
| 97 | 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 | 171 | func TestWebView_ImplementsAccessibilityEnabler(t *testing.T) { | |
| 101 | 172 | t.Parallel() | |
| 102 | 173 | var _ port.AccessibilityEnabler = (*WebView)(nil) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package cef | 1 | 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 = host | 42 | 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 = host | 96 | 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) { | |||
| 172 | 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() | |
| 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 | |
| 179 | 177 | } | |
| 180 | 178 | wv.startRenderStallWatchdog() | |
| 181 | 179 | 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, nil | 179 | return wv, nil | ||
M internal/infrastructure/cef/webview.go +24 -7
| @@ -155,10 +155,16 @@ type WebView struct { | |||
| 155 | 155 | ||
| 156 | 156 | // a11yWorker receives serialized accessibility payloads off the CEF UI | |
| 157 | 157 | // 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 | |
| 162 | 168 | ||
| 163 | 169 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 164 | 170 | // widget is visible. Access is guarded by mu. | |
| @@ -2543,10 +2549,21 @@ func (wv *WebView) CancelPageScroll(_ context.Context) { | |||
| 2543 | 2549 | q.commitMu.Unlock() | |
| 2544 | 2550 | } | |
| 2545 | 2551 | ||
| 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. | |
| 2548 | 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 | 2567 | return | |
| 2551 | 2568 | } | |
| 2552 | 2569 | wv.mu.RLock() | |
| @@ -155,10 +155,16 @@ type WebView struct { | |||||
| 155 | 155 | ||||
| 156 | // a11yWorker receives serialized accessibility payloads off the CEF UI | 156 | // 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 *accessibilityCaptureWorker | 158 | + | // After publication, a11yWorker/a11yCapture stay immutable; teardown uses |
| 159 | - | a11yCapture *accessibilityCapture | 159 | + | // a11yCaptureFinalized so abort/shutdown close the worker once without |
| 160 | - | a11yStats accessibilityStats | 160 | + | // racing CEF callback sinks that still read these pointers. |
| 161 | - | a11yEnabled atomic.Bool | 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 | |||
| 162 | 168 | ||||
| 163 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | 169 | // 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 | return | 2567 | 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( | |||
| 955 | 955 | } | |
| 956 | 956 | h.wv.scheduleStartAdaptiveFrameRatePolling() | |
| 957 | 957 | h.wv.scheduleStartBeginFrameLoop() | |
| 958 | + | h.wv.applyAccessibilityIfReady() | |
| 958 | 959 | h.wv.fireReadyToShow() | |
| 959 | 960 | } | |
| 960 | 961 | ||
| @@ -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 | ||||