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
@@ -31,14 +31,177 @@ func (worker *Worker) MoveDocument(target DocumentTarget, level int, backwards b
|
||||
return ErrInvalidDirection
|
||||
}
|
||||
select {
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), target: target, backwards: backwards, level: level, queuedAt: time.Now()}:
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), documentLoadEpoch: worker.documentLoadEpoch.Load(), target: target, backwards: backwards, level: level, queuedAt: time.Now()}:
|
||||
return nil
|
||||
default:
|
||||
return ErrRequestQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor, target DocumentTarget, level int, backwards bool, epoch uint64) (Result, *DocumentCursor) {
|
||||
// sameDocumentRepeat reports whether two queued requests are the same
|
||||
// structural move in the same document generation, so they can execute as
|
||||
// one batch in order. Caret steps are excluded: every queued character,
|
||||
// word and line step keeps its own ordered result.
|
||||
func sameDocumentRepeat(first, next request) bool {
|
||||
if first.documentEpoch != next.documentEpoch {
|
||||
return false
|
||||
}
|
||||
return first.target != 0 && first.target == next.target &&
|
||||
first.level == next.level && first.backwards == next.backwards
|
||||
}
|
||||
|
||||
const maxRetainedDocumentCursors = 4
|
||||
|
||||
// cursorForTree returns the retained browse cursor for a captured tree,
|
||||
// creating and retaining one on first use. The adapter retains one tree
|
||||
// object per document root, so the pointer identifies the document.
|
||||
func (worker *Worker) cursorForTree(tree *atspi.AccessibleTree, loadEpoch uint64) (*DocumentCursor, bool, error) {
|
||||
if tree == nil {
|
||||
return nil, false, errors.New("document cursor: nil tree")
|
||||
}
|
||||
if cursor := worker.documentCursors[tree]; cursor != nil && worker.documentCursorEpochs[tree] == loadEpoch {
|
||||
worker.touchDocumentCursor(tree)
|
||||
return cursor, false, nil
|
||||
}
|
||||
cursor, err := NewDocumentCursor(tree)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
worker.rememberDocumentCursor(cursor, loadEpoch)
|
||||
return cursor, true, nil
|
||||
}
|
||||
|
||||
func (worker *Worker) rememberDocumentCursor(cursor *DocumentCursor, loadEpoch uint64) {
|
||||
if cursor == nil || cursor.tree == nil {
|
||||
return
|
||||
}
|
||||
if worker.documentCursors == nil {
|
||||
worker.documentCursors = make(map[*atspi.AccessibleTree]*DocumentCursor)
|
||||
worker.documentCursorEpochs = make(map[*atspi.AccessibleTree]uint64)
|
||||
}
|
||||
worker.documentCursors[cursor.tree] = cursor
|
||||
worker.documentCursorEpochs[cursor.tree] = loadEpoch
|
||||
worker.touchDocumentCursor(cursor.tree)
|
||||
for len(worker.documentCursorOrder) > maxRetainedDocumentCursors {
|
||||
oldest := worker.documentCursorOrder[0]
|
||||
delete(worker.documentCursors, oldest)
|
||||
delete(worker.documentCursorEpochs, oldest)
|
||||
worker.documentCursorOrder = worker.documentCursorOrder[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) touchDocumentCursor(tree *atspi.AccessibleTree) {
|
||||
for index, retained := range worker.documentCursorOrder {
|
||||
if retained == tree {
|
||||
worker.documentCursorOrder = append(worker.documentCursorOrder[:index], worker.documentCursorOrder[index+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
worker.documentCursorOrder = append(worker.documentCursorOrder, tree)
|
||||
}
|
||||
|
||||
// retainedCursorsExcept lists retained cursors most recently used first.
|
||||
func (worker *Worker) retainedCursorsExcept(current *DocumentCursor, loadEpoch uint64) []*DocumentCursor {
|
||||
cursors := make([]*DocumentCursor, 0, len(worker.documentCursorOrder))
|
||||
for index := len(worker.documentCursorOrder) - 1; index >= 0; index-- {
|
||||
tree := worker.documentCursorOrder[index]
|
||||
if cursor := worker.documentCursors[tree]; cursor != nil && cursor != current && worker.documentCursorEpochs[tree] == loadEpoch {
|
||||
cursors = append(cursors, cursor)
|
||||
}
|
||||
}
|
||||
return cursors
|
||||
}
|
||||
|
||||
// trialRetainedCursors retries move on retained cursors for other
|
||||
// documents before paying for a capture. Returning to a tab or workspace
|
||||
// usually validates against its retained tree with no provider work at
|
||||
// all; a cursor whose tree changed meanwhile simply falls through to the
|
||||
// caller's capture path.
|
||||
func (worker *Worker) trialRetainedCursors(cursor **DocumentCursor, loadEpoch uint64, result *Result, move func() error) {
|
||||
if !documentNeedsCapture(result.Err) {
|
||||
return
|
||||
}
|
||||
for _, retained := range worker.retainedCursorsExcept(*cursor, loadEpoch) {
|
||||
*cursor = retained
|
||||
result.Err = move()
|
||||
if !documentNeedsCapture(result.Err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// warmDocument captures and syncs the focused document so the first
|
||||
// navigation key afterwards is warm. It returns the cursor to keep as the
|
||||
// active document; failures (including focus in browser chrome) keep the
|
||||
// previous cursor and stay silent.
|
||||
func (worker *Worker) warmDocument(ctx context.Context, cursor *DocumentCursor, loadEpoch uint64) *DocumentCursor {
|
||||
focus := worker.focus()
|
||||
if !focus.Valid() {
|
||||
return cursor
|
||||
}
|
||||
candidates := worker.retainedCursorsExcept(cursor, loadEpoch)
|
||||
if cursor != nil {
|
||||
candidates = append([]*DocumentCursor{cursor}, candidates...)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := candidate.SyncFocus(ctx, focus); err == nil {
|
||||
worker.touchDocumentCursor(candidate.tree)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// Warm-up is speculative: never let it occupy the provider thread for
|
||||
// the full interactive capture budget on an unresponsive document.
|
||||
warmContext, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
tree, _, err := worker.captureDocumentForMove(warmContext, cursor, focus)
|
||||
if err != nil || tree == nil {
|
||||
return cursor
|
||||
}
|
||||
warm, _, err := worker.cursorForTree(tree, loadEpoch)
|
||||
if err != nil {
|
||||
return cursor
|
||||
}
|
||||
if _, err := warm.SyncFocus(ctx, focus); err != nil {
|
||||
return cursor
|
||||
}
|
||||
return warm
|
||||
}
|
||||
|
||||
// captureDocumentForMove captures through the focus anchor. When the page
|
||||
// destroyed that anchor, capture keyed on it fails with ErrObjectNotFound on
|
||||
// every keypress; fall back to the cursor's saved document so the existing
|
||||
// anchor-recovery path can rebind focus within the same generation.
|
||||
func (worker *Worker) captureDocumentForMove(ctx context.Context, cursor *DocumentCursor, focus atspi.ObjectID) (*atspi.AccessibleTree, time.Duration, error) {
|
||||
capture := func(anchor atspi.ObjectID) (*atspi.AccessibleTree, time.Duration, error) {
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, anchor)
|
||||
return tree, time.Since(started), err
|
||||
}
|
||||
tree, duration, err := capture(focus)
|
||||
if err == nil || !errors.Is(err, atspi.ErrObjectNotFound) {
|
||||
return tree, duration, err
|
||||
}
|
||||
anchor, ok := cursor.capture_anchor(focus)
|
||||
if !ok {
|
||||
return tree, duration, err
|
||||
}
|
||||
fallbackTree, fallbackDuration, fallbackErr := capture(anchor)
|
||||
if fallbackErr != nil {
|
||||
return tree, duration + fallbackDuration, err
|
||||
}
|
||||
return fallbackTree, duration + fallbackDuration, nil
|
||||
}
|
||||
|
||||
func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor, target DocumentTarget, level int, backwards bool, epoch, loadEpoch uint64) (Result, *DocumentCursor) {
|
||||
return worker.moveDocumentRepeated(ctx, cursor, target, level, backwards, epoch, loadEpoch, 1)
|
||||
}
|
||||
|
||||
// moveDocumentRepeated executes coalesced identical keys as one batch: one
|
||||
// focus sync, one capture cycle and one native placement for the final
|
||||
// position, landing the full step count from the original position.
|
||||
func (worker *Worker) moveDocumentRepeated(ctx context.Context, cursor *DocumentCursor, target DocumentTarget, level int, backwards bool, epoch, loadEpoch uint64, repeat int) (Result, *DocumentCursor) {
|
||||
result := Result{DocumentEpoch: epoch, DocumentTarget: target, DocumentBackwards: backwards, DocumentLevel: level, DocumentFocus: worker.focus()}
|
||||
if !result.DocumentFocus.Valid() {
|
||||
result.Err = ErrNoCurrent
|
||||
@@ -57,13 +220,27 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
|
||||
return err
|
||||
}
|
||||
attempt.Stage = "move"
|
||||
move, err := cursor.Move(ctx, target, level, backwards)
|
||||
result.DocumentPosition = move.Position
|
||||
result.Moved = move.Moved
|
||||
result.DocumentWrapped = move.Wrapped
|
||||
return err
|
||||
moved := false
|
||||
for range repeat {
|
||||
move, err := cursor.Move(ctx, target, level, backwards)
|
||||
if err != nil {
|
||||
if moved {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
result.DocumentPosition = move.Position
|
||||
result.Moved = move.Moved
|
||||
result.DocumentWrapped = result.DocumentWrapped || move.Wrapped
|
||||
if !move.Moved {
|
||||
break
|
||||
}
|
||||
moved = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result.Err = move()
|
||||
worker.trialRetainedCursors(&cursor, loadEpoch, &result, move)
|
||||
if !documentNeedsCapture(result.Err) {
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
}
|
||||
@@ -71,11 +248,8 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
|
||||
// allowing a constantly mutating page to loop indefinitely.
|
||||
result.CaptureReason = result.Err
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
captureStarted := time.Now()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
|
||||
result.CaptureDuration = time.Since(captureStarted)
|
||||
cancel()
|
||||
tree, captureDuration, err := worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
||||
result.CaptureDuration = captureDuration
|
||||
if err != nil {
|
||||
result.Err = err
|
||||
return result, cursor
|
||||
@@ -86,8 +260,9 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
|
||||
}
|
||||
recovered := worker.recoverCapturedDocumentFocus(cursor, tree, &result)
|
||||
if cursor == nil || cursor.tree != tree {
|
||||
result.DocumentDiagnostics.CursorRecreated = true
|
||||
cursor, _ = NewDocumentCursor(tree)
|
||||
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
||||
result.DocumentDiagnostics.CursorRecreated = created
|
||||
cursor = retained
|
||||
}
|
||||
if !recovered {
|
||||
result.DocumentFocus = worker.focus()
|
||||
@@ -98,15 +273,14 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
|
||||
// A second replacement can race the first capture. Retry once while
|
||||
// the navigation has not moved, so the heading is not skipped.
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
retryContext, retryCancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
retryStarted := time.Now()
|
||||
tree, err = worker.capturer.CaptureDocument(retryContext, result.DocumentFocus)
|
||||
result.CaptureDuration += time.Since(retryStarted)
|
||||
retryCancel()
|
||||
var retryDuration time.Duration
|
||||
tree, retryDuration, err = worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
||||
result.CaptureDuration += retryDuration
|
||||
if err == nil && tree != nil && worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus {
|
||||
if cursor == nil || cursor.tree != tree {
|
||||
result.DocumentDiagnostics.CursorRecreated = true
|
||||
cursor, _ = NewDocumentCursor(tree)
|
||||
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
||||
result.DocumentDiagnostics.CursorRecreated = created
|
||||
cursor = retained
|
||||
}
|
||||
result.Err = move()
|
||||
}
|
||||
@@ -114,7 +288,7 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
}
|
||||
|
||||
func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *DocumentCursor, fragment string, epoch uint64) (Result, *DocumentCursor) {
|
||||
func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *DocumentCursor, fragment string, epoch, loadEpoch uint64) (Result, *DocumentCursor) {
|
||||
result := Result{DocumentEpoch: epoch, DocumentFragment: true, DocumentFocus: worker.focus()}
|
||||
if !result.DocumentFocus.Valid() {
|
||||
result.Err = ErrNoCurrent
|
||||
@@ -133,7 +307,7 @@ func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *Document
|
||||
result.Err = errors.New("capture document tree: nil tree")
|
||||
return result, cursor
|
||||
}
|
||||
cursor, err = NewDocumentCursor(tree)
|
||||
cursor, _, err = worker.cursorForTree(tree, loadEpoch)
|
||||
if err != nil {
|
||||
result.Err = err
|
||||
return result, cursor
|
||||
@@ -171,11 +345,8 @@ func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *Docume
|
||||
// capture; repeating the navigation command could skip to another target.
|
||||
oldPosition := result.DocumentPosition
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
captureStarted := time.Now()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
|
||||
result.CaptureDuration += time.Since(captureStarted)
|
||||
cancel()
|
||||
tree, captureDuration, err := worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
||||
result.CaptureDuration += captureDuration
|
||||
if err != nil || tree == nil || !worker.CurrentDocumentResult(result) {
|
||||
return result
|
||||
}
|
||||
@@ -211,7 +382,7 @@ func documentCaretNeedsRecapture(err error) bool {
|
||||
errors.Is(err, atspi.ErrStaleSnapshot) || errors.Is(err, atspi.ErrDeadObject)
|
||||
}
|
||||
|
||||
func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCursor, command DocumentCaretCommand, epoch uint64) (Result, *DocumentCursor) {
|
||||
func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCursor, command DocumentCaretCommand, epoch, loadEpoch uint64) (Result, *DocumentCursor) {
|
||||
result := Result{DocumentEpoch: epoch, DocumentCaretCommand: command, DocumentFocus: worker.focus()}
|
||||
if !result.DocumentFocus.Valid() {
|
||||
result.Err = ErrNoCurrent
|
||||
@@ -266,16 +437,14 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
||||
return err
|
||||
}
|
||||
result.Err = move()
|
||||
worker.trialRetainedCursors(&cursor, loadEpoch, &result, move)
|
||||
if !documentNeedsCapture(result.Err) {
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
}
|
||||
result.CaptureReason = result.Err
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
captureStarted := time.Now()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
|
||||
result.CaptureDuration = time.Since(captureStarted)
|
||||
cancel()
|
||||
tree, captureDuration, err := worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
||||
result.CaptureDuration = captureDuration
|
||||
if err != nil {
|
||||
result.Err = err
|
||||
return result, cursor
|
||||
@@ -286,8 +455,9 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
||||
}
|
||||
recovered := worker.recoverCapturedDocumentFocus(cursor, tree, &result)
|
||||
if cursor == nil || cursor.tree != tree {
|
||||
result.DocumentDiagnostics.CursorRecreated = true
|
||||
cursor, _ = NewDocumentCursor(tree)
|
||||
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
||||
result.DocumentDiagnostics.CursorRecreated = created
|
||||
cursor = retained
|
||||
}
|
||||
if !recovered {
|
||||
result.DocumentFocus = worker.focus()
|
||||
@@ -300,17 +470,16 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
||||
// A dynamic page can replace the captured target between recapture and
|
||||
// SyncFocus. Retry once only if the first attempt did not move the caret.
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
retryContext, retryCancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
retryStarted := time.Now()
|
||||
tree, err = worker.capturer.CaptureDocument(retryContext, result.DocumentFocus)
|
||||
result.CaptureDuration += time.Since(retryStarted)
|
||||
retryCancel()
|
||||
var retryDuration time.Duration
|
||||
tree, retryDuration, err = worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
||||
result.CaptureDuration += retryDuration
|
||||
if err != nil || tree == nil || !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
|
||||
return result, cursor
|
||||
}
|
||||
if cursor == nil || cursor.tree != tree {
|
||||
result.DocumentDiagnostics.CursorRecreated = true
|
||||
cursor, _ = NewDocumentCursor(tree)
|
||||
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
||||
result.DocumentDiagnostics.CursorRecreated = created
|
||||
cursor = retained
|
||||
}
|
||||
result.Err = move()
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
@@ -436,6 +605,7 @@ func (worker *Worker) refresh_document_cursor(ctx context.Context, cursor *Docum
|
||||
if cursor != nil && len(cursor.places) > 0 {
|
||||
saved = cursor.places[len(cursor.places)-1]
|
||||
}
|
||||
loadEpoch := worker.documentLoadEpoch.Load()
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
defer cancel()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, focus)
|
||||
@@ -473,6 +643,7 @@ func (worker *Worker) refresh_document_cursor(ctx context.Context, cursor *Docum
|
||||
place := &fresh.places[len(fresh.places)-1]
|
||||
place.object, place.offset, place.origin = target, saved.offset, saved.origin
|
||||
}
|
||||
worker.rememberDocumentCursor(fresh, loadEpoch)
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user