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

+214 -43
View File
@@ -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
}