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:
1 parent
8ce5e5cdad
commit
7eef6e8be6
30 files changed
+1484
-174
No files matched your search
+194
-28
@@ -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())))
|
||||
|
||||
Reference in new issue
Block a user