Keep failed cursor trials and native recapture anchored to the original document, and restore the requested cached root before LRU eviction. Base braille retry resets on established connection time rather than stalled startup. Poll private AT-SPI daemon ownership without triggering service activation and clean up partial startup failures. Add focused regressions and update browser testing notes.
12 KiB
Ticking-page (incremental game) navigation
Perpetually updating pages recreate their accessible text leaves on a timer. The maintainer's 2026-11-03 debug log showed the same failure on three incremental games, which made arrow-key reading useless while focus (link/button) navigation still worked:
- Exotic Matter Dimensions: every Down paid a full recapture and re-spoke
the first line (14 repeats of one counter line,
moved=true), ending in "Could not find current location." - Ordinal Pringles: 16 Downs re-spoke a single-letter fragment, then the focused anchor itself was destroyed and six consecutive moves reported "Could not find current location." until Tab supplied a new anchor. Moving among buttons was 1 ms; crossing the ticking static region cost a 20–25 ms recapture per key on a 129-node document.
- Prestige Tree: ~20 accessible-name events per second while idle. Speech coalescing already handled these; no change was needed there.
Root causes were positional, not per-game: the saved browse position was an
AT-SPI object identity that Chromium retires on every tick, the
generation-checked sibling boundaries retired with it, recovery restarted
at the surviving parent's first line, and capture was keyed on the destroyed
focus anchor so recapture itself failed with ErrObjectNotFound forever.
Changes
- Replacement recovery (
internal/review/document_replacement.go) now also retains the child's index and the text parent's child count. When every saved sibling reference has retired but the same generation-checked parent still has the same child count, the child at the same index is the same navigation line (replaced-text-child-indexin diagnostics). A changed count, or any saved sibling that still lives (reorder, single replaced neighbor), stays ambiguous and falls back to the surviving parent, as before. Activation never uses either recovery. - Document capture for moves (
captureDocumentForMove) falls back to the cursor's saved document when capture keyed on the focus anchor returnsErrObjectNotFound, so the existing anchor-recovery path can rebind focus within the same document generation. - Down after structural navigation to a heading no longer descends into the
heading's embedded link and repeats its title as a new line
(
internal/review/document_caret_line.go). The structural target's own first text range keeps the previous within-object line logic. - Identical structural moves queued while the worker is busy coalesce into one batch (one sync, capture cycle, placement and result, full step count). Caret steps are excluded: queued character/word/line steps keep their own ordered results.
Caret validation on ticking text
A follow-up log showed residual traps at dense counter lines (Exotic Matter's "Progress to Luck Shards" repeated nine times, three location losses). Attempt errors were all identity/text/children invalidation — no stale-geometry errors — so this was not Xorg/Wayland degradation; the AT-SPI tree mutates identically under either backend. The trap: caret moves validated captured text/geometry freshness for every traversed node before committing, while the move's whole retry budget (one attempt plus two captures) races a game tick that re-invalidates text several times per second. Recovery succeeded on every attempt and still lost.
Caret validation (ValidateDocumentCaretRange) now tolerates text,
geometry, and child-order staleness, because caret moves read text and
line bounds live and generation-check every endpoint; identity, semantic
snapshots (names are spoken from the snapshot), and hypertext
embedded-object ranges stay strict. This deliberately changes the older
contracts in TestDocumentCaretDefersUnrelatedUpdatesUntilTraversed and
the children case of TestDocumentCaretKeepsOffsetAfterDocumentRefresh:
dirty target text and changed child order no longer force a recapture
before a caret move commits, and the tests now prove the live provider
text is what the move reports. Structural navigation keeps strict
validation everywhere. Capture root selection also learned to resolve a
focus identity from retained document sources (a document root after a
load may have no focus-event history). A dead browse focus recovers only
through the root of the capture that retained that source; other tabs on
the same application bus cannot supply its recovery anchor. Provider-side
stalls remain possible: one log ends with two 15 s capture deadlines from
an unresponsive game tab, which no validation change can fix.
Graph-guided follow-ups (Orca comparison)
A later comparison against knowledge graphs of Orca and at-spi2-core
(verified in source) produced two more changes. First, index-based
recovery now also covers recreated non-text children such as button rows
(replaced-child / replaced-child-index), under the same bounded
evidence as text leaves and without widening their stricter rules — Orca
recovers removed children from parent plus removed index in the same
spirit. Second, a bus whose document capture just missed its deadline is
marked hung for a two-second cooldown (ErrProviderHung, announced as
"Application is not responding."), so a frozen tab fails fast instead of
stalling every following keypress; Orca likewise remembers hung objects
briefly and propagates the mark to the application. The gate covers all
document operations (reads, placement, activation, context), not just
captures — a live freeze on switching to the Exotic Matter tab showed
reads re-entering the stuck provider after the capture deadline — and
speculative warm-up captures are capped at three seconds. One in-flight
capture can still occupy the provider thread for its full deadline;
the gates keep everything after it responsive.
Tab/workspace-switch retention
Returning to a browser tab or workspace used to pay a cold whole-document
capture (2.9 s for a 4226-node YouTube page in the 2026-11-03 log) before
the first keypress worked. Three causes stacked: load/tab resets bumped
the document epoch and discarded the browse cursor, the adapter kept a
single document tree slot so any other tab's capture evicted it, and with
the root changed nativeRefresh had nothing to reuse.
Now the adapter retains a bounded LRU of per-root document trees, text
trees, and native sources (rotateDocumentSlots, active plus three
stashed), events keep invalidating stashed trees while inactive, and the
worker retains a browse cursor per retained tree. A move first trials
retained cursors (cheap in-memory syncs) before capturing, so an unchanged
document resumes with no capture at all and a changed one recaptures warm
(only dirty nodes re-read). Failed trials preserve the original cursor for
recapture, including when its focused child was removed. Restoring the
oldest retained slot at capacity removes it before stashing the active
document, so restoration cannot evict its own target.
Tab switches suspend instead of hard-resetting
documents; page loads still hard-reset, and requests carry a load epoch so
a cursor captured before a same-path load is never reused afterwards
(TestWorkerDocumentLoadRetiresOutstandingResult still guards this).
Load-complete and tab-select also enqueue a best-effort warm-up capture so
even a first visit starts capturing before the first keypress.
Still deferred: subtree-scoped capture. The first-ever capture of a huge document remains whole-document (now usually hidden by warm-up), and a document that changed a lot while away still re-reads its dirty nodes over a full walk. The validation invariant (dirty traversed objects and ancestors must refresh before a move may skip them) is deliberate and covered by the replacement/generated-link fixtures. Revisit subtree capture only if large ticking documents still feel unresponsive after retention.
Automated checks (2026-11-03)
- New Go regressions, each observed failing before its fix:
TestDocumentCaretRecoversRecreatedTextSiblings,TestDocumentCaretRecapturesThroughDocumentWhenFocusAnchorDies,TestDocumentCaretContinuesFromStructuralLinkedHeading,TestWorkerCoalescesQueuedIdenticalDocumentMoves,TestDocumentSlotsRetainTreesAcrossRoots,TestWorkerRetainsDocumentCursorsAcrossSwitches,TestWorkerWarmDocumentPreparesFirstMove,TestValidateDocumentCaretRangeToleratesTextAndGeometryStaleness, andTestDocumentCaretMoveCrossesTextInvalidatedLines. go test ./... -count=1andgo vet ./...pass. One sandbox-only exception:internal/speechunix-socket tests fail inside the restricted sandbox ("socket: operation not permitted") and pass outside it.go test -race ./internal/review/ ./internal/atspi/ ./internal/app/ -count=1passes in the private native session.BenchmarkDocument*(200 ms,-benchmem) matches the medians inbrowser-navigation-performance.md(heading pair ≈ 0.036 ms, snapshot ≈ 5.9 ms, line assembly ≈ 3.5 ms at 10,000 nodes). Informational only.- New native proof
ticking(TestNativeBrowserTickingNavigation, plustests/browser-ticking.htmlfor manual runs) is registered intests/native_browser.pyand passes: Down moves visit rebuilt lines 1, 2, 3 in order across ticks, then reach the following button. The guarding proofslinkedheadings,dynamic,replacement,replaced-feed,generatedlinks, anddocumentalso pass. Getting there required a harness fix first: inside the private D-Bus session, AT-SPI activation delegated to a systemd user unit (org.freedesktop.systemd1 ... unit failed), which cannot work with a disposable runtime dir and must not be required on non-systemd systems.tests/native_browser.pynow launchesat-spi-bus-launcher --launch-immediatelyandat-spi2-registryddirectly inside its private Xvfb/D-Bus session. The host session (compositor, buses, AT-SPI) is never touched. Observed but not addressed: empty fragment stops between the fixture's<br>-separated spans, a pre-existing line-assembly quirk.
Retention boundaries have focused regressions:
TestDocumentSlotsRestoreOldestAtCapacity,
TestRetainedCursorTrialKeepsDeadFocusInOriginalDocument, and
TestRetainedDocumentRootDoesNotCrossTabsOnSameBus. The browser harness
waits for daemon bus-name ownership without activating services, bounds
each readiness query, and joins both daemons on startup failure.
Run its startup checks with python3 tests/test_native_browser.py -v.
Live acceptance (pending, maintainer)
Do not replace the running reader without approval; build to a side path
(e.g. /tmp/cthulhu-ticking) or rebuild through the usual switcher after
review. With a fresh debug log:
- Exotic Matter Dimensions and Ordinal Pringles: traverse the previously
stuck regions by Down/Up only, reach a button, and activate it. Expect
progressing distinct lines, no repeated counter line, and no "Could not
find current location." Diagnostics should show
replaced-text-child-indexrecoveries. - Prestige Tree: H/Down through the main view; idle name churn stays silent except for the focused control.
- YouTube: H onto a video heading, then one Down. Expect the channel/meta line, not the title again as a link. Mash H three times during a slow load; expect one landing three headings on. Note your heading position, switch to another tab/workspace and back, then press H: expect to continue from the noted heading promptly, with no multi-second stall, instead of restarting from the top after a cold capture.
- Compare log counters against the 2026-11-03 log: duplicate consecutive
document-caretlines, location losses, recaptures per 100 commands. - Report audible speech and physical Braille separately from the automated results above.