Filter out stale accessibility events.
This commit is contained in:
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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user