Add native coverage for ticking browser pages

Incremental-game style fixture whose AT-SPI leaves recreate every 250 ms, native navigation test, private-session AT-SPI daemon launch in the harness (no systemd, no host session required), and testing notes.
This commit is contained in:
hjozwiak committed 2026-11-03 10:35:00 -05:00
1 parent 7eef6e8be6
commit 6c10ed0517
5 files changed
+472 -8

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
# 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) and to keep scanning focus history
past chrome entries when recovering a dead browse focus. 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). 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.
## 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.