Filter out stale accessibility events.

This commit is contained in:
Storm Dragon committed 2026-11-04 14:23:54 -05:00
1 parent 9e15f8652d
commit f23e89ed93
10 files changed
+469 -2

No files matched your search

@@ -398,6 +398,15 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A
//export cthulhuAtspiEventCallback
func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
dispatch_native_event(event, userData, false, 0)
}
//export cthulhuAtspiSelectionCallback
func cthulhuAtspiSelectionCallback(event *C.AtspiEvent, userData unsafe.Pointer, epoch C.uint64_t) {
dispatch_native_event(event, userData, true, uint64(epoch))
}
func dispatch_native_event(event *C.AtspiEvent, userData unsafe.Pointer, deferredSelection bool, selectionEpoch uint64) {
if event == nil || userData == nil {
return
}
@@ -408,6 +417,9 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
return
}
secureEpoch := adapter.secureEpoch.Load()
if deferredSelection && (selectionEpoch != secureEpoch || adapter.secure.Load()) {
return
}
// Retire copied header identities even while locked. Tab lifecycle only
// checks roles; it never captures text or presents output.
nativeEventType := C.GoString(C.cthulhu_event_type(event))
@@ -422,10 +434,20 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
}
eventType := canonicalObjectChangeEventType(nativeEventType)
originID := nativeObjectID(event.source)
adapter.retireEchoFocus(eventType, int(C.cthulhu_event_detail1(event)), originID)
// Retired GTK cells can emit hundreds of obsolete focus/selection claims
// during a rebuild. Leave lifecycle retirement on its established path,
// but avoid all snapshot and foreground queries for known-dead targets.
switch eventType {
case FocusEvent, FocusedStateEvent, SelectedStateEvent, SelectionChangedEvent, ActiveDescendantEvent:
if C.cthulhu_accessible_known_dead(event.source) != 0 ||
eventType == ActiveDescendantEvent && C.cthulhu_accessible_known_dead(C.cthulhu_event_active_descendant(event)) != 0 {
return
}
}
if eventType == AccessibleNameEvent || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 {
C.cthulhu_forget_menu_checkability(event.source)
}
adapter.retireEchoFocus(eventType, int(C.cthulhu_event_detail1(event)), originID)
if eventType != DefunctStateEvent || C.cthulhu_event_detail1(event) != 0 {
adapter.retireLibreOfficeHeaders(event.source, eventType, originID)
}
@@ -438,6 +460,18 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
}
adapter.applySecureReset()
gtkSelection := eventType == SelectionChangedEvent && C.cthulhu_gtk_table(event.source, 0) != 0
// Structural events which only invalidate caches do not need a captured
// selection between every removed row. A structural event that survives
// invalidation below still flushes before its ordinary presentation path.
selectionBoundary := eventType != DefunctStateEvent && !strings.HasPrefix(eventType, ChildrenChangedEvent)
if !deferredSelection && selectionBoundary {
if gtkSelection {
C.cthulhu_flush_other_selection(adapter.callbackData, event.source)
} else {
C.cthulhu_flush_selection(adapter.callbackData)
}
}
adapter.refreshForeground()
if isDocumentLifecycleEvent(eventType) || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 {
adapter.deferredLiveRegions.take(originID)
@@ -505,6 +539,13 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
adapter.backgroundEventsFiltered.Add(1)
return
}
// Seed an unknown toolkit identity only after foreground filtering, so a
// background provider never delays this path with a toolkit lookup.
if !deferredSelection && eventType == SelectionChangedEvent &&
(gtkSelection || C.cthulhu_gtk_table(event.source, 1) != 0) {
C.cthulhu_defer_selection(adapter.callbackData, event, C.uint64_t(secureEpoch))
return
}
if tab := addedSelectedBrowserTab(event, eventType); tab != nil {
defer C.g_object_unref(C.gpointer(tab))
returnedTarget = tab
@@ -517,6 +558,9 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
if invalidated && webNotification == nil && eventType == ChildrenAddedEvent && (live.politeness == "" || live.politeness == "off") {
return
}
if !deferredSelection && !selectionBoundary {
C.cthulhu_flush_selection(adapter.callbackData)
}
deferLive, completeLive := adapter.prepare_live_region(event, eventType, originID, live)
if deferLive {
return
@@ -532,6 +576,13 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
if target == nil {
return
}
if (eventType == FocusEvent || eventType == FocusedStateEvent || eventType == SelectedStateEvent ||
eventType == ActiveDescendantEvent || eventType == SelectionChangedEvent) && C.cthulhu_accessible_known_dead(target) != 0 {
if ownedTarget {
C.g_object_unref(C.gpointer(target))
}
return
}
refreshNativeEventTarget(event, target, eventType)
snapshot, popupFor, payload := adapter.captureNativeEventContent(event, target, eventType, originID, live, completeLive)
eventType, inlineMenuID := normalizeNativeMenuFocus(event, target, eventType, originID, &snapshot)
+90
View File
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include "adapter_native.h"
extern void cthulhuAtspiSelectionCallback(AtspiEvent *event, void *userData, uint64_t epoch);
// This check uses only libatspi's cache. Unknown metadata retains the normal
// query path; a stale non-FOCUSED state alone never rejects an explicit claim.
gboolean cthulhu_accessible_known_dead(AtspiAccessible *source) {
if (source == NULL) return TRUE;
if (source->states != NULL && atspi_state_set_contains(source->states, ATSPI_STATE_DEFUNCT)) return TRUE;
return (source->cached_properties & ATSPI_CACHE_ROLE) && source->role == ATSPI_ROLE_INVALID;
}
gboolean cthulhu_gtk_table(AtspiAccessible *source, gboolean queryToolkit) {
if (source == NULL || !(source->cached_properties & ATSPI_CACHE_ROLE)) return FALSE;
if (source->role != ATSPI_ROLE_TABLE && source->role != ATSPI_ROLE_TREE_TABLE &&
source->role != ATSPI_ROLE_LIST && source->role != ATSPI_ROLE_TREE) return FALSE;
AtspiApplication *application = ATSPI_OBJECT(source)->app;
// libatspi caches toolkit identity per application. Seed that cache on
// the first table selection if focus capture has not needed it yet.
if (queryToolkit && application != NULL && application->toolkit_name == NULL) {
gchar *toolkit = atspi_accessible_get_toolkit_name(source, NULL);
g_free(toolkit);
}
if (application == NULL || g_ascii_strcasecmp(application->toolkit_name != NULL ? application->toolkit_name : "", "gtk") != 0) return FALSE;
if (source->role == ATSPI_ROLE_TABLE || source->role == ATSPI_ROLE_TREE_TABLE) return TRUE;
if ((source->role != ATSPI_ROLE_LIST && source->role != ATSPI_ROLE_TREE) ||
!(source->cached_properties & ATSPI_CACHE_INTERFACES)) return FALSE;
AtspiTable *table = atspi_accessible_get_table_iface(source);
if (table == NULL) return FALSE;
g_object_unref(table);
return TRUE;
}
// All queue state and boxed events belong exclusively to the GLib owner.
// Only one table can be pending. A different table or another live event
// flushes it first, preserving ordering across focus/lifecycle boundaries.
void cthulhu_flush_selection(cthulhu_callback_data *data) {
if (data->selectionIdle != 0) {
g_source_remove(data->selectionIdle);
data->selectionIdle = 0;
}
AtspiEvent *event = data->pendingSelection;
uint64_t epoch = data->selectionEpoch;
data->pendingSelection = NULL;
if (event == NULL) return;
cthulhuAtspiSelectionCallback(event, data, epoch);
g_boxed_free(ATSPI_TYPE_EVENT, event);
}
void cthulhu_flush_other_selection(cthulhu_callback_data *data, AtspiAccessible *source) {
if (data->pendingSelection != NULL && data->pendingSelection->source != source) {
cthulhu_flush_selection(data);
}
}
static gboolean cthulhu_selection_idle(gpointer userData) {
cthulhu_callback_data *data = userData;
data->selectionIdle = 0;
cthulhu_flush_selection(data);
return G_SOURCE_REMOVE;
}
void cthulhu_defer_selection(cthulhu_callback_data *data, AtspiEvent *event, uint64_t epoch) {
cthulhu_flush_other_selection(data, event->source);
if (data->pendingSelection != NULL) g_boxed_free(ATSPI_TYPE_EVENT, data->pendingSelection);
data->pendingSelection = g_boxed_copy(ATSPI_TYPE_EVENT, event);
data->selectionEpoch = epoch;
if (data->selectionIdle == 0) {
// No timer/debounce delay: read the latest selection when the ready
// provider signals have drained. Ordinary arrows still flush on their
// following live focus/descendant event, or at the next idle turn.
data->selectionIdle = g_idle_add(cthulhu_selection_idle, data);
}
}
void cthulhu_clear_selection(cthulhu_callback_data *data) {
if (data == NULL) return;
if (data->selectionIdle != 0) {
g_source_remove(data->selectionIdle);
data->selectionIdle = 0;
}
if (data->pendingSelection != NULL) {
g_boxed_free(ATSPI_TYPE_EVENT, data->pendingSelection);
data->pendingSelection = NULL;
}
}
+1
View File
@@ -872,6 +872,7 @@ uintptr_t cthulhu_callback_value(const cthulhu_callback_data *data) {
void cthulhu_callback_data_free(cthulhu_callback_data *data) {
if (data == NULL) return;
cthulhu_clear_selection(data);
// Dispatch has stopped and the Go request mutex excludes new sources.
// Destroy notifications remove each source and release its retained ref.
while (data->captures != NULL) {
+9
View File
@@ -85,6 +85,9 @@ typedef struct {
uintptr_t callback;
GMutex mutex;
GList *captures;
AtspiEvent *pendingSelection;
guint selectionIdle;
uint64_t selectionEpoch;
} cthulhu_callback_data;
gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
@@ -140,6 +143,12 @@ const gchar *cthulhu_accessible_path(AtspiAccessible *source);
const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source);
AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source);
gboolean cthulhu_may_be_background_notification(AtspiAccessible *source);
gboolean cthulhu_accessible_known_dead(AtspiAccessible *source);
gboolean cthulhu_gtk_table(AtspiAccessible *source, gboolean queryToolkit);
void cthulhu_defer_selection(cthulhu_callback_data *data, AtspiEvent *event, uint64_t epoch);
void cthulhu_flush_selection(cthulhu_callback_data *data);
void cthulhu_flush_other_selection(cthulhu_callback_data *data, AtspiAccessible *source);
void cthulhu_clear_selection(cthulhu_callback_data *data);
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source);
AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source);
AtspiAccessible *cthulhu_read_only_combo(AtspiAccessible *source);
+1
View File
@@ -43,6 +43,7 @@ func (adapter *Adapter) applySecureReset() {
if !adapter.secureReset.Swap(false) {
return
}
C.cthulhu_clear_selection(adapter.callbackData)
adapter.deferredLiveRegions = nil
adapter.invalidateTree()
adapter.treeMutex.Lock()
+139
View File
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestNativeGUITableRefresh(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_TABLE_REFRESH") != "1" {
t.Skip("requires private Xorg/AT-SPI session")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
adapter := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
flags, err := exec.CommandContext(ctx, "pkg-config", "--cflags", "--libs", "atspi-2").Output()
if err != nil {
t.Fatal(err)
}
library := filepath.Join(t.TempDir(), "refresh-signals.so")
args := append([]string{"-shared", "-fPIC", "-o", library, "../../tests/atspi_refresh_signals.c"}, strings.Fields(string(flags))...)
if output, err := exec.CommandContext(ctx, "cc", args...).CombinedOutput(); err != nil {
t.Fatalf("compile signal fixture: %v: %s", err, output)
}
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_table_refresh.py", library)
input, err := command.StdinPipe()
if err != nil {
t.Fatal(err)
}
output, err := command.StdoutPipe()
if err != nil {
t.Fatal(err)
}
command.Stderr = os.Stderr
if err := command.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() })
line, err := bufio.NewReader(output).ReadString('\n')
fields := strings.Fields(line)
if err != nil || len(fields) != 3 || fields[0] != "ready" {
t.Fatalf("fixture readiness: %q, %v", line, err)
}
if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", fields[1]).CombinedOutput(); err != nil {
t.Fatalf("focus: %s, %v", output, err)
}
wait := func(predicate func(Event) bool) Event {
t.Helper()
var last Event
for {
select {
case event := <-adapter.Events():
last = event
if predicate(event) {
return event
}
case <-ctx.Done():
t.Fatalf("event missing: %v; last=%s %q", ctx.Err(), last.Type, last.Source.Name)
}
}
}
initial := wait(func(event Event) bool {
return event.Source.Name == "Original row 0" && event.Source.GUITable && !event.ForegroundMismatch
})
// Populate libatspi's cells so the retired objects are known before the
// provider emits stale claims about them, as in a previously read timeline.
if initial.Source.BusName != fields[2] {
t.Fatal("signal fixture is not using GTK's provider connection")
}
tree, err := adapter.CaptureDesktop(ctx)
if err != nil {
t.Fatal(err)
}
var oldPaths []string
tree.mu.RLock()
for id, entry := range tree.nodes {
if strings.HasPrefix(entry.snapshot.Name, "Original row ") {
oldPaths = append(oldPaths, id.Path)
}
}
tree.mu.RUnlock()
if len(oldPaths) != 300 {
t.Fatalf("cached %d original cells, want 300", len(oldPaths))
}
fmt.Fprintln(input, "refresh", initial.Source.TablePath, strings.Join(oldPaths, ","))
invalidEvents, selectionEvents := 0, 0
finalSelection := false
wait(func(event Event) bool {
if event.Type != DefunctStateEvent && (event.Source.Role == "invalid" || event.Source.Defunct) {
invalidEvents++
}
if event.Type == SelectionChangedEvent {
selectionEvents++
finalSelection = finalSelection || event.Source.Name == "Updated row 7" && !event.ForegroundMismatch
}
return event.Type == TextInsertedEvent && event.Payload == "Refresh complete"
})
if !finalSelection {
t.Error("refresh did not deliver the final selected row")
}
if invalidEvents != 0 {
t.Errorf("captured %d obsolete cell events during refresh", invalidEvents)
}
if selectionEvents > 20 {
t.Errorf("captured %d selection events for one synchronous refresh", selectionEvents)
}
t.Logf("refresh: %d invalid captures, %d selection captures", invalidEvents, selectionEvents)
for _, row := range []int{8, 9, 8} {
fmt.Fprintln(input, "select", row)
wait(func(event Event) bool {
return event.Source.Name == fmt.Sprint("Updated row ", row) && event.Source.GUITable && !event.ForegroundMismatch
})
}
// A table update immediately followed by editor focus must not restore the
// old row later when a deferred selection callback finally runs.
fmt.Fprintln(input, "select 9\nentry")
wait(func(event Event) bool { return event.Source.Name == "After table" && event.Source.Focused })
fmt.Fprintln(input, "barrier")
wait(func(event Event) bool {
if event.Type == SelectionChangedEvent || event.Type == ActiveDescendantEvent {
t.Errorf("table event delivered after editor focus: %s %q", event.Type, event.Source.Name)
}
return event.Type == TextInsertedEvent && event.Payload == "Focus boundary complete"
})
}