Files
cthulhu/docs/testing/browser-ticking-pages.md
Storm Dragon 4da61465b0 Fix document retention and helper retry regressions
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.
2026-11-03 11:37:36 -05:00

12 KiB
Raw Permalink Blame History

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.