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

+80 -18
View File
@@ -54,12 +54,23 @@ type Worker struct {
cancel context.CancelFunc
done chan struct{}
wakeFocus chan struct{}
requests chan request
results chan Result
lineSequence atomic.Uint64
documentEpoch atomic.Uint64
documentBraille atomic.Bool
wakeFocus chan struct{}
requests chan request
results chan Result
lineSequence atomic.Uint64
documentEpoch atomic.Uint64
// documentLoadEpoch bumps only on hard document resets (page loads).
// A retained cursor stamped with an older load epoch must never be
// reused: its tree may be the previous page at reused paths.
documentLoadEpoch atomic.Uint64
documentBraille atomic.Bool
// Retained browse cursors per captured document tree, so returning to
// a tab resumes its place instead of restarting cold. Owned by the
// run goroutine (document moves run there); no lock.
documentCursors map[*atspi.AccessibleTree]*DocumentCursor
documentCursorEpochs map[*atspi.AccessibleTree]uint64
documentCursorOrder []*atspi.AccessibleTree
}
// SetDocumentBrailleEnabled avoids extra native line reads while braille is off.
@@ -68,13 +79,30 @@ func (worker *Worker) SetDocumentBrailleEnabled(enabled bool) {
}
// ResetDocument retires queued work and cursors for the previous page, even
// when the provider reuses accessible paths after navigation.
// when the provider reuses accessible paths after navigation. It is the
// hard reset for page loads: retained cursors from before it must not be
// reused, because a new page can reuse the same accessible paths.
func (worker *Worker) ResetDocument(focus atspi.ObjectID) {
worker.resetDocument(focus, true)
}
// SuspendDocument retires queued work for the previously selected tab
// without retiring its browse cursors, so returning to the tab (or its
// workspace) resumes each document's place. Only for focus/tab switches
// where no page (re)loaded.
func (worker *Worker) SuspendDocument(focus atspi.ObjectID) {
worker.resetDocument(focus, false)
}
func (worker *Worker) resetDocument(focus atspi.ObjectID, hard bool) {
worker.stateMutex.Lock()
worker.latestFocus = focus
worker.focusRevision++
worker.flatOrigin = atspi.ObjectID{}
worker.documentEpoch.Add(1)
if hard {
worker.documentLoadEpoch.Add(1)
}
worker.stateMutex.Unlock()
select {
case worker.wakeFocus <- struct{}{}:
@@ -251,20 +279,47 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
explicitInterest := implicitInterest
flat := &FlatCursor{}
var flatTree *atspi.AccessibleTree
// held is the first non-identical request found while coalescing
// queued identical document moves; it runs next, in order.
var held *request
takeRepeat := func(first request) int {
repeat := 1
for {
select {
case next := <-worker.requests:
if sameDocumentRepeat(first, next) {
repeat++
continue
}
held = &next
return repeat
default:
return repeat
}
}
}
for {
// A ready request/result channel can win a select even after Stop.
// Do not drain queued work or start further captures during shutdown.
if ctx.Err() != nil {
return
}
select {
case <-ctx.Done():
return
case <-worker.wakeFocus:
if next, err := worker.syncLatestFocus(ctx, cursor, false); err == nil {
cursor = next
var request request
if held != nil {
request, held = *held, nil
} else {
select {
case <-ctx.Done():
return
case <-worker.wakeFocus:
if next, err := worker.syncLatestFocus(ctx, cursor, false); err == nil {
cursor = next
}
continue
case request = <-worker.requests:
}
case request := <-worker.requests:
}
{
worker.stateMutex.Lock()
focus, revision := worker.latestFocus, worker.focusRevision
worker.stateMutex.Unlock()
@@ -283,6 +338,12 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
request.documentList.result <- documentListReply{list, err}
continue
}
if request.warmDocument {
if request.documentEpoch == documentEpoch {
document = worker.warmDocument(ctx, document, request.documentLoadEpoch)
}
continue
}
if request.interest != nil {
interest, err := resolve_interest(request.interest.ctx, interestFocus, explicitInterest, cursor, flat, flatTree, document)
worker.stateMutex.Lock()
@@ -338,16 +399,16 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
result, documentTextTree = worker.readDocumentLine(ctx, documentTextTree, request.lineRequest, request.lineFocus, request.lineOffset)
result.Elapsed = time.Since(request.queuedAt)
} else if request.target != 0 {
result, document = worker.moveDocument(ctx, document, request.target, request.level, request.backwards, request.documentEpoch)
result, document = worker.moveDocumentRepeated(ctx, document, request.target, request.level, request.backwards, request.documentEpoch, request.documentLoadEpoch, takeRepeat(request))
result.Elapsed = time.Since(request.queuedAt)
} else if request.caretCommand != 0 {
result, document = worker.moveDocumentCaret(ctx, document, request.caretCommand, request.documentEpoch)
result, document = worker.moveDocumentCaret(ctx, document, request.caretCommand, request.documentEpoch, request.documentLoadEpoch)
result.Elapsed = time.Since(request.queuedAt)
} else if request.activate {
result, document = worker.activateDocumentCaret(ctx, document, request.documentEpoch)
result.Elapsed = time.Since(request.queuedAt)
} else if request.fragment != "" {
result, document = worker.jumpDocumentFragment(ctx, document, request.fragment, request.documentEpoch)
result, document = worker.jumpDocumentFragment(ctx, document, request.fragment, request.documentEpoch, request.documentLoadEpoch)
result.Elapsed = time.Since(request.queuedAt)
} else if request.unit != 0 {
result, flatTree = worker.readFlat(ctx, flat, flatTree, request.unit, request.step)
@@ -373,7 +434,8 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
worker.stateMutex.Unlock()
}
result.DocumentEpoch = request.documentEpoch
if documentCommand && worker.CurrentDocumentResult(result) && errors.Is(result.Err, context.DeadlineExceeded) {
if documentCommand && worker.CurrentDocumentResult(result) &&
(errors.Is(result.Err, context.DeadlineExceeded) || errors.Is(result.Err, atspi.ErrProviderHung)) {
// Keys pressed while the provider was unavailable must not each
// start another full timeout. A command issued after this failure
// can try again; successful navigation keeps its ordered steps.