Make web document navigation survive live-updating pages

Recreated-node caret recovery, tolerant caret validation, per-root retained document trees and cursors across tab switches, document warm-up, coalesced structural moves, and hung-provider fast-fail with an 'Application is not responding' announcement.
This commit is contained in:
hjozwiak committed 2026-11-03 10:34:55 -05:00
1 parent 8ce5e5cdad
commit 7eef6e8be6
30 files changed
+1484 -174

No files matched your search

+194 -28
View File
@@ -25,6 +25,7 @@ import (
"runtime/cgo"
"sync"
"sync/atomic"
"time"
"unsafe"
)
@@ -134,30 +135,40 @@ type Adapter struct {
foregroundRecovery int // GLib thread: bounded retries while a new client publishes AT-SPI
recoveringForeground bool
events chan Event
treeRequests chan treeCaptureRequest
overflow atomic.Uint64
treeInvalidations atomic.Uint64
backgroundEventsFiltered atomic.Uint64 // rejected before native target/snapshot queries
secureMutex sync.Mutex
secureContext context.Context
secureCancel context.CancelFunc
secureEpoch atomic.Uint64
secure atomic.Bool
secureReset atomic.Bool
secureResync atomic.Bool
captureSound atomic.Bool
captureTextAttributes atomic.Bool
running atomic.Bool
ready atomic.Bool
loopDone chan struct{}
loopError error // published by closing loopDone
stopRequested bool // stopMutex; keep Stop retries from queuing stale quit sources
stopMutex sync.Mutex
treeMutex sync.Mutex
tree *AccessibleTree
documentTree *AccessibleTree
documentTextTree *AccessibleTree
events chan Event
treeRequests chan treeCaptureRequest
overflow atomic.Uint64
treeInvalidations atomic.Uint64
backgroundEventsFiltered atomic.Uint64 // rejected before native target/snapshot queries
secureMutex sync.Mutex
secureContext context.Context
secureCancel context.CancelFunc
secureEpoch atomic.Uint64
secure atomic.Bool
secureReset atomic.Bool
secureResync atomic.Bool
captureSound atomic.Bool
captureTextAttributes atomic.Bool
running atomic.Bool
ready atomic.Bool
loopDone chan struct{}
loopError error // published by closing loopDone
stopRequested bool // stopMutex; keep Stop retries from queuing stale quit sources
stopMutex sync.Mutex
hungMutex sync.Mutex
hungBuses map[string]time.Time
treeMutex sync.Mutex
tree *AccessibleTree
documentTree *AccessibleTree
documentTextTree *AccessibleTree
// The active document slots above serve one document root at a time.
// Recently captured documents are stashed by root (bounded, LRU) so
// returning to a tab reuses its tree and retained native sources
// instead of snapshotting the whole document cold. Guarded by
// treeMutex; stashed sources are released only on the GLib thread.
documentSlotRoot ObjectID
stashedDocuments map[ObjectID]stashedDocument
stashedDocumentIDs []ObjectID
nativeDocument map[ObjectID]*C.AtspiAccessible
documentLineCache map[ObjectID]DocumentCaretText // GLib owner; at most 64 bounded text objects
nativeReview map[ObjectID]*C.AtspiAccessible
@@ -354,12 +365,46 @@ func (adapter *Adapter) SetCaptureLogger(logger *slog.Logger) {
adapter.captureLogger.Store(logger)
}
const providerHungCooldown = 2 * time.Second
// markProviderHung records a capture deadline for one application bus.
// Orca similarly remembers hung objects briefly so later commands skip
// straight past a provider that just stalled.
func (adapter *Adapter) markProviderHung(busName string, at time.Time) {
if busName == "" {
return
}
adapter.hungMutex.Lock()
defer adapter.hungMutex.Unlock()
if adapter.hungBuses == nil {
adapter.hungBuses = make(map[string]time.Time)
}
adapter.hungBuses[busName] = at
}
func (adapter *Adapter) providerHung(busName string, at time.Time) bool {
adapter.hungMutex.Lock()
defer adapter.hungMutex.Unlock()
since, ok := adapter.hungBuses[busName]
if !ok {
return false
}
if at.Sub(since) > providerHungCooldown {
delete(adapter.hungBuses, busName)
return false
}
return true
}
func (adapter *Adapter) capture(ctx context.Context, reviewFocus ObjectID, captureName string, document, documentText bool) (tree *AccessibleTree, err error) {
if ctx == nil {
return nil, fmt.Errorf("capture AT-SPI %s: nil context", captureName)
}
ctx, cancel := adapter.secureRequestContext(ctx)
defer cancel()
if document && adapter.providerHung(reviewFocus.BusName, time.Now()) {
return nil, fmt.Errorf("%w: bus %q", ErrProviderHung, reviewFocus.BusName)
}
var trace *captureDiagnostics
if reviewFocus.Valid() {
ctx, trace = new_capture_diagnostics(ctx, adapter.captureLogger.Load(), reviewFocus)
@@ -400,8 +445,14 @@ func (adapter *Adapter) capture(ctx context.Context, reviewFocus ObjectID, captu
}
select {
case result := <-request.result:
if document && errors.Is(result.err, context.DeadlineExceeded) {
adapter.markProviderHung(reviewFocus.BusName, time.Now())
}
return result.tree, result.err
case <-ctx.Done():
if document && errors.Is(ctx.Err(), context.DeadlineExceeded) {
adapter.markProviderHung(reviewFocus.BusName, time.Now())
}
return nil, ctx.Err()
}
}
@@ -410,6 +461,124 @@ func (adapter *Adapter) installTree(root ObjectID, nodes []TreeNode) (*Accessibl
return adapter.installCapturedTree(root, nodes, false, false)
}
const maxStashedDocuments = 3
type stashedDocument struct {
tree, textTree *AccessibleTree
sources map[ObjectID]*C.AtspiAccessible
}
// rotateDocumentSlots makes root's retained trees and native sources the
// active document slots, stashing the previously active document. Capture
// calls this on the GLib thread once the capture root is known, before
// preparing the incremental-refresh cache.
func (adapter *Adapter) rotateDocumentSlots(root ObjectID) {
if !root.Valid() {
return
}
adapter.treeMutex.Lock()
defer adapter.treeMutex.Unlock()
if root == adapter.documentSlotRoot {
return
}
if adapter.documentSlotRoot.Valid() &&
(adapter.documentTree != nil || adapter.documentTextTree != nil) {
adapter.stashDocumentLocked(adapter.documentSlotRoot, stashedDocument{
tree: adapter.documentTree, textTree: adapter.documentTextTree,
sources: adapter.nativeDocument,
})
} else {
for _, source := range adapter.nativeDocument {
C.g_object_unref(C.gpointer(source))
}
}
if stashed, ok := adapter.stashedDocuments[root]; ok {
delete(adapter.stashedDocuments, root)
for index, id := range adapter.stashedDocumentIDs {
if id == root {
adapter.stashedDocumentIDs = append(adapter.stashedDocumentIDs[:index], adapter.stashedDocumentIDs[index+1:]...)
break
}
}
adapter.documentTree = stashed.tree
adapter.documentTextTree = stashed.textTree
adapter.nativeDocument = stashed.sources
} else {
adapter.documentTree = nil
adapter.documentTextTree = nil
adapter.nativeDocument = nil
}
adapter.documentSlotRoot = root
}
func (adapter *Adapter) stashDocumentLocked(root ObjectID, document stashedDocument) {
if adapter.stashedDocuments == nil {
adapter.stashedDocuments = make(map[ObjectID]stashedDocument)
}
if old, ok := adapter.stashedDocuments[root]; ok {
for _, source := range old.sources {
C.g_object_unref(C.gpointer(source))
}
for index, id := range adapter.stashedDocumentIDs {
if id == root {
adapter.stashedDocumentIDs = append(adapter.stashedDocumentIDs[:index], adapter.stashedDocumentIDs[index+1:]...)
break
}
}
}
adapter.stashedDocuments[root] = document
adapter.stashedDocumentIDs = append(adapter.stashedDocumentIDs, root)
for len(adapter.stashedDocumentIDs) > maxStashedDocuments {
oldest := adapter.stashedDocumentIDs[0]
for _, source := range adapter.stashedDocuments[oldest].sources {
C.g_object_unref(C.gpointer(source))
}
delete(adapter.stashedDocuments, oldest)
adapter.stashedDocumentIDs = adapter.stashedDocumentIDs[1:]
}
}
// clearStashedDocuments releases every stashed document's native sources.
// Callers run on the GLib thread (secure reset, shutdown).
func (adapter *Adapter) clearStashedDocuments() {
adapter.treeMutex.Lock()
stashed := adapter.stashedDocuments
adapter.stashedDocuments = nil
adapter.stashedDocumentIDs = nil
adapter.documentSlotRoot = ObjectID{}
adapter.treeMutex.Unlock()
for _, document := range stashed {
for _, source := range document.sources {
C.g_object_unref(C.gpointer(source))
}
}
}
func (adapter *Adapter) ownedTrees() []*AccessibleTree {
adapter.treeMutex.Lock()
defer adapter.treeMutex.Unlock()
return adapter.ownedTreesLocked()
}
func (adapter *Adapter) ownedTreesLocked() []*AccessibleTree {
trees := make([]*AccessibleTree, 0, 3+2*len(adapter.stashedDocuments))
for _, tree := range []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} {
if tree != nil {
trees = append(trees, tree)
}
}
for _, id := range adapter.stashedDocumentIDs {
stashed := adapter.stashedDocuments[id]
if stashed.tree != nil {
trees = append(trees, stashed.tree)
}
if stashed.textTree != nil {
trees = append(trees, stashed.textTree)
}
}
return trees
}
func (adapter *Adapter) installCapturedTree(root ObjectID, nodes []TreeNode, document, documentText bool) (*AccessibleTree, error) {
adapter.treeMutex.Lock()
defer adapter.treeMutex.Unlock()
@@ -436,10 +605,7 @@ func (adapter *Adapter) installCapturedTree(root ObjectID, nodes []TreeNode, doc
func (adapter *Adapter) invalidateTree() {
adapter.treeMutex.Lock()
defer adapter.treeMutex.Unlock()
for _, tree := range []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree} {
if tree == nil {
continue
}
for _, tree := range adapter.ownedTreesLocked() {
root, err := tree.Root()
if err == nil {
adapter.treeInvalidations.Add(uint64(tree.Invalidate(root.ID())))