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.
200 lines
12 KiB
Markdown
200 lines
12 KiB
Markdown
# 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-index` in 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 returns
|
||
`ErrObjectNotFound`, 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`, and
|
||
`TestDocumentCaretMoveCrossesTextInvalidatedLines`.
|
||
- `go test ./... -count=1` and `go vet ./...` pass. One sandbox-only
|
||
exception: `internal/speech` unix-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=1` passes in the private native session.
|
||
- `BenchmarkDocument*` (200 ms, `-benchmem`) matches the medians in
|
||
`browser-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`, plus
|
||
`tests/browser-ticking.html` for manual runs) is registered in
|
||
`tests/native_browser.py` and passes: Down moves visit rebuilt lines
|
||
1, 2, 3 in order across ticks, then reach the following button. The
|
||
guarding proofs `linkedheadings`, `dynamic`, `replacement`,
|
||
`replaced-feed`, `generatedlinks`, and `document` also 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.py` now launches `at-spi-bus-launcher
|
||
--launch-immediately` and `at-spi2-registryd` directly 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:
|
||
|
||
1. 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-index` recoveries.
|
||
2. Prestige Tree: H/Down through the main view; idle name churn stays
|
||
silent except for the focused control.
|
||
3. 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.
|
||
4. Compare log counters against the 2026-11-03 log: duplicate consecutive
|
||
`document-caret` lines, location losses, recaptures per 100 commands.
|
||
5. Report audible speech and physical Braille separately from the automated
|
||
results above.
|