AppMonitor prepares complete hint models for the focused app. Each model
carries the app’s dirty token, configuration revision, and completion time.
Lookup and publication check those identities; invalid or stale models are
never served. Activation can run a complete walk on demand. Background work
never publishes a partial walk or changes provider ordering.
PreparedModelScheduler owns refresh and maintenance tickets on the main
thread. It takes uptime explicitly, so its timing policy is tested with a fake
clock. One refresh wake coalesces a burst; further events extend its deadline.
An earlier focus, configuration, or user-action request replaces a throttled
wake with a new ticket. Later noisy events cannot demote that request. Each
callback must consume its own ticket, so a cancelled or replaced callback
cannot clear or execute newly scheduled work for the same PID.
AX and queued refreshes debounce for 80 ms and observe a 2.5-second minimum interval between automatic walks. Maintenance has a separate identity and bypasses that minimum interval: it wakes 250 ms before the model’s freshness ceiling, then uses the same 80-ms debounce. Each model carries its own ceiling: it starts at 1.5 s and doubles (up to 30 s) every time a maintenance walk reproduces the previous model unchanged — same dirty token, configuration revision, and target geometry/role digest — and resets to 1.5 s on any other outcome. A maintenance wake is skipped outright after 60 s without keyboard or pointer input; the model then expires and the next activation walks on demand.
Maintenance is the one self-renewing wake — each stored model arms the next —
so it is a deadline registration on the shared PollScheduler
(core:prepared_model_maintenance) rather than a timer of its own: it
coalesces with the process’s other wake-ups and is held while the displays
sleep or the session is locked. Only the frontmost app’s model is stored, so
one registration serves, and a cancelled maintenance releases it. Its
.normal priority allows 100 ms of slack, inside the 250-ms lead, so a late
wake still starts before the ceiling. Debounce and readiness wakes re-arm
themselves too — later events extend a debounce, each readiness step arms the
next — so they ride the same clock at .normal, one registration per app and
kind (core:prepared_model_refresh:<pid>, core:prepared_model_readiness:<pid>)
that cancelling the app’s refresh work releases; an activation that arrives
first walks on demand rather than waiting on them. The lead allows eligible
cheap walks to finish before the current model expires. A newer completed
model replaces the prior maintenance ticket even when its dirty token and
configuration revision are unchanged.
AX observer sources are hosted on a dedicated AXObserverThread; callbacks
enqueue and the main thread drains each burst in one hop, bumping every dirty
token in arrival order. Observer registration, its retry ladder, focused-window
re-observation and teardown run on a serial queue of their own, so a walk, a
readiness probe or a commit on the AX queue never waits behind an app slow to
answer a registration; a multi-app screen-scope walk steps one app per turn
and asks main between apps whether its activation still stands. AX storms and automatic walks taking at least 50 ms
suppress AX, queued, and maintenance warming. Focus and configuration requests can reassess those apps;
explicit activation remains available and complete. A degenerate walk (see
below) is fast because there was nothing to read, so it never lifts the
slow-walk suppression. Suppression, invalidation,
termination, and shutdown revoke the applicable tickets. No timer is a second
source of model validity: maintenance also rechecks focus, dirty token, and
configuration before requesting a refresh.
A walk first computes the focused window’s visible region from the window
list (WindowSnapshot). When nothing of the window is visible — another
process’s floating window, the Dock or a lock screen covers all of it — the
walk is occluded and reads nothing. An occluded walk is never cached:
whether the window is covered is window-list state that no AX event reports,
so nothing would invalidate an empty model once the covering window left. It
evicts the current model instead (that model no longer describes the screen)
and arms no maintenance; nor does it count toward the slow-walk backoff, the
empty-walk gate or a readiness rewalk. An activation that finds no model
recomputes the visible region and walks on demand, so the same window list
always gives the same hints, and a window still fully covered at activation
stays silent. A partly covered window is the same window-list state: every
stored model carries the visible region its walk saw — the window’s frame and
the fragments left of it — and a lookup recomputes the region from the
activation’s own window-list read, through the builder the walk used, and
treats a difference as a miss. A notification banner, a floating player or a
panel moving over or off the window therefore costs one walk, never a stale
set of hints drawn over the cover or missing from what it uncovered.
Chromium and Flutter build their accessibility tree asynchronously after the
enhanced-UI wake, and Gecko once its tree is read
(AppTraits.buildsAccessibilityTreeAsynchronously). A walk that runs first finds an empty
or half-built tree in a few milliseconds and yields a degenerate result:
empty, or below a tenth of the app’s last healthy count
(discoveryLooksDegenerate). The healthy count is recorded from activations
and from stored background walks that are not degenerate, and forgotten when
the app quits.
Readiness is decided by a bounded probe, AccessibilityReadiness: one
batched read (role and children) per element, breadth-first under the walked
window, at most 32 elements, descending only into containers. A web area with
children is ready and an empty one is not; without a web area in reach, a
window with more than five direct children, or a tree larger than the probe
reads, is ready. The probe runs on the AX queue (Gecko inside
GeckoAccessibility.withTree) and never decides the hints; it only decides
when the one walk that follows is worth doing. Waits between probes follow
ReadinessLadder: 50, 100, 200, 400 ms, then a final 750 ms after which the
walk runs regardless, 1.5 s at most. Every wait is a deadline on the shared
PollScheduler (.system while an activation waits on it, .normal for a
background ladder); nothing sleeps on the main thread or the AX queue.
readiness refresh. That reason is owed, not
speculative, so event storms and the slow-walk backoff do not suppress it,
and it is bounded to two ladders per focus. An app whose tree is built on
demand skips the probes and is simply walked again.Gecko discards its tree whenever its accessibility mode is switched off, so
switching it off after every operation makes walks and probes wake a tree that
may not be built yet — the Firefox background walks that come back empty in
1–5 ms. The mode Flash turns on therefore stays on while the app is focused
(GeckoAccessibility; see runtime ownership): one wake per
focus builds the tree, the ladder’s probes watch that same tree fill, and
later walks read it built, the two Gecko scopes of one walk (the walked
window’s frame, then the tree) included. Focus leaving the app, a Space change
and every window move switch it off, so the first walk after one of those can
still find the tree unbuilt, and is repaired as above.
A readiness step has its own ticket kind beside refresh and maintenance. A
probe holds the ticket while it runs, and its verdict applies only if the hold
survives: focus leaving the app, a walk starting, termination, a newer
ladder or cancelRefreshWork revoke it.
A volatile provider (tmux) owns a terminal’s hints; the prepared model is its
Accessibility fallback. When the provider declines and the app’s tree has
never produced targets, a degenerate activation walk is the answer: it is not
repeated, and the activation ends silently. After five consecutive empty
automatic walks of an app whose provider plan includes a volatile provider,
the app gets no automatic walks at all — focus, AX events, maintenance,
readiness — until an activation finds targets in its tree, the configuration
changes, or it quits (EmptyBackgroundWalkGate, logged once as
[ax] model_refresh_gated). The judgement is the walks, never the bundle
identifier: terminals that expose real Accessibility targets keep warming.