Skip to content

fix: memoize snapshot occlusion coverage checks to stop a daemon CPU-spin - #1541

Merged
thymikee merged 3 commits into
mainfrom
fix/snapshot-occlusion-memoization
Aug 1, 2026
Merged

fix: memoize snapshot occlusion coverage checks to stop a daemon CPU-spin#1541
thymikee merged 3 commits into
mainfrom
fix/snapshot-occlusion-memoization

Conversation

@thymikee

@thymikee thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member

Cherry-picked from the #1536 branch so main gets it without waiting on the stack: annotateCoveredSnapshotNodes (src/snapshot/snapshot-occlusion.ts) had an unmemoized mutual recursion — findCoveringNodecanCoverPointvisibleCoverRectfindCoveringNode — that is O(2^K) in the number of overlay-classified nodes. An Android snapshot taken while the IME keyboard is open classifies each key as an overlay (~39 mutually-adjacent overlay nodes observed live), which wedges the daemon at ~99% CPU indefinitely. This code is reachable on main today; the #1478 stack merely shifted when the trigger state occurs, which is how the live test-app:replay evidence caught it.

Fix: memoize findCoveringNode per position for the lifetime of one pass (a position's answer is stable within a pass), turning O(2^K) into O(K^2).

Test: synthetic 40-node keyboard case asserting sub-second completion plus two correctness cases — verified to hang (not merely fail) with the memoization reverted.

Live verification on the discovery branch: the deterministic two-fill repro went from a 180s timeout with an orphaned spinning daemon to 6.9s green, and the full checkout+gesture Android replay pair passes in 75s.

Refs #1478

…spin

Root-caused a deterministic daemon hang reported against this branch's
`.ad` test/replay path (a two-fill Android form wedges the daemon at
~99% CPU indefinitely, blocking the checkout-form-android.ad live evidence).

Mechanism: `annotateCoveredSnapshotNodes` (src/snapshot/snapshot-occlusion.ts)
asks, for every overlay-classified candidate cover, whether THAT candidate is
itself covered by something later — via a recursive call back into
`findCoveringNode` (through `visibleCoverRect`). That recursive question was
never memoized: resolving position P's answer required resolving every later
position Q > P from scratch, and resolving Q required resolving every
position after IT from scratch again, giving O(2^overlayPositions.length)
work with no bound. A live CDP pause on the wedged daemon (`kill -USR1`,
`Debugger.pause` over the inspector) landed repeatedly inside exactly this
recursive triad (`findCoveringNode` -> `canCoverPoint` -> `visibleCoverRect`
-> `findCoveringNode`), matching the reporter's own `sample` profile
(role-normalization / `normalizeType` hot, called from inside this loop).

The pathological input is real, not synthetic: the second `fill` in a
two-field Android form runs while the on-screen IME keyboard is open, and
each individual key is classified `isAdditionalOverlayNode` — roughly 40
mutually-adjacent "overlay-like" nodes, confirmed by instrumenting the
function directly against the live repro (`nodes=62 overlayPositions=39`
right where the daemon stops responding). A/B against the p4a branch head
with matching instrumentation shows the equivalent snapshot there carries
zero overlay-classified nodes at the same point in the script and completes
in ~7s; extending the gap between fills with a genuine (non-instant) real
wait on p4a does not reproduce the 39-overlay state either, ruling out a
pure timing race as the sole explanation. `snapshot-occlusion.ts` itself is
untouched by the codec extraction, so the exponential blowup is a
pre-existing latent algorithmic defect — this PR's consumer rewiring is the
first thing to reliably land the fill-resolution snapshot in the
39-overlay-node regime; the exact mechanism connecting the codec/target-
identity import changes to that timing shift was not pinned to a single
line, and is called out as a residual question in the PR body.

Fix: cache `findCoveringNode`'s answer per position on the scan object,
scoped to one `annotateCoveredSnapshotNodes` call. The scan's own node list
is immutable input for the duration of one pass (byIndex is only ever
extended forward, never revised for a position already resolved), so a given
position's covered-by-something-later answer is provably stable across every
path that asks it — caching turns the unbounded double recursion into O(K)
resolutions of O(K) work each, i.e. O(K^2) instead of O(2^K).

Regression test (src/snapshot/__tests__/snapshot-occlusion.test.ts):
constructs a synthetic 40-node "keyboard" (mutually non-overlapping,
same-kind overlay-classified nodes, matching the live scale) and asserts
`annotateCoveredSnapshotNodes` returns well under a second. Verified the test
actually catches the regression: with the memoization reverted, the same
test times out (never returns) instead of failing an assertion — it hangs
exactly like the daemon did. Two existing-behavior sanity cases (a covered
touch target, an uncovered one) guard against a memoization bug silently
changing output.

Live verification: `node bin/agent-device.mjs test /tmp/m6.ad --platform
android` (the reported minimal repro) now passes in ~7.5s with no orphaned
daemon, down from a 180s timeout at ~99% CPU. The full two-script run
(`checkout-form-android.ad` + `gesture-lab-android.ad --platform android`)
passes in ~75s with no orphan.

Refs #1478

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.92 MB 1.92 MB +173 B
JS gzip 615.5 kB 615.5 kB +61 B
npm tarball 733.5 kB 733.6 kB +38 B
npm unpacked 2.57 MB 2.57 MB +173 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.1 ms 27.5 ms +0.3 ms
CLI --help 57.2 ms 58.2 ms +1.0 ms

Top changed chunks: no changes in the largest emitted chunks.

@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Blocking at 9a85582: coverCache is keyed only by position, but the pass mutates scan.nodes and scan.byIndex as covered nodes are annotated, while visibleCoverRect re-evaluates caller-supplied isAdditionalOverlayNode and ancestor-root classification against those mutable nodes. A result cached recursively before an ancestor gains covered annotations can therefore be reused after an uncached evaluation could classify it differently. The current Android predicate reads stable identifiers, but the exported option contract does not enforce that invariant. Please make occlusion lookup use immutable input state with a separate annotated output (preferred), or explicitly enforce a stable-field predicate contract, and add a mutation-sensitive regression. The 40-node performance test is otherwise non-vacuous, all CI is green, and the live evidence applies byte-for-byte to this cherry-pick.

The memoized pass cached findCoveringNode by position while the annotation
loop mutated scan.nodes and scan.byIndex, so a cached answer could predate
annotations the caller predicate or ancestor classification would observe —
first-evaluation-wins order dependence (present, unmemoized, in the original
too). Decisions now evaluate exclusively against the caller's input; covered
positions are collected read-only and annotations applied in a separate
output pass that never feeds back.

Two invariants pinned: the input array and its nodes are never mutated, and
a chain (target under a covered sheet under a dialog) resolves identically
regardless of evaluation order.

Co-Authored-By: Claude <noreply@anthropic.com>
@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Fixed in 59edc2615 — took the preferred shape: occlusion decisions now read immutable input only. scan.nodes/byIndex are the caller's arrays, never the annotated output; the pass collects covered positions read-only and applies annotations in a separate output step that cannot feed back into isAdditionalOverlayNode, ancestor classification, or any cached answer. The memo is now sound by construction rather than by predicate discipline, and the exported option contract needs no new invariant.

Note this also removes the order dependence that existed before the memoization (the original mutated byIndex mid-pass, so identical inputs could annotate differently by evaluation order — the cache made it first-evaluation-wins, but the hazard predates it).

Two new invariant tests: (1) the input array and its nodes are byte-identical after the pass and the output is a fresh array; (2) a cover chain (target under a covered sheet under a dialog) resolves identically regardless of order — the covered sheet is disqualified as a cover, the target is covered by the dialog directly. 15/15 snapshot tests green including the 40-node keyboard performance case; typecheck/lint/format clean.

Generated by Claude Code

@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

At 59edc2615, the immutable two-pass implementation addresses the mutable-cache concern and the original 40-node performance regression remains non-vacuous. One test gap remains: the two new invariants both pass against 9a85582—the old implementation already leaves the caller's input objects untouched, and the chain case never exercises isAdditionalOverlayNode or mutable ancestor classification. Please add a mutation-sensitive callback/ancestor regression that fails when cover decisions read the annotated byIndex.

Also rerun iOS smoke: the exact-head failure is a runner-side wait_capture_stalled on the fixture-home snapshot and appears unrelated, but the required check is currently red.

… predicate

The prior invariant tests pass against the mutable implementation too (it
copied the array up front, and the chain case never exercised the caller
predicate). This one fails against it: a predicate that also matches
annotated nodes would, through the ancestor walk over a mutable byIndex,
declassify a child overlay mid-pass and flip a later target's outcome.

Co-Authored-By: Claude <noreply@anthropic.com>
@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Addressed in cae69aed3: added the mutation-sensitive regression — a caller predicate that (pathologically) also matches annotated nodes, exercised through the ancestor walk: parent P gets covered and annotated; against a mutable byIndex the predicate would then see annotated-P as a renderable overlay ancestor, declassify its child overlay mid-pass, and flip a later target's outcome. Counterfactually verified in both directions: with snapshot-occlusion.ts reverted to 9a85582 the test fails at exactly that flip (target not covered); at head it passes 6/6. You were right that the earlier two invariants were satisfiable by the old implementation — this one is not.

The fresh push retriggers the full check set including iOS smoke.

Generated by Claude Code

@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Re-review at cae69aed3 is code-clean. The new callback/ancestor regression is genuinely mutation-sensitive and fails against the old mutable 9a85582 path at the intended classification flip; the immutable two-pass implementation and cache semantics now have direct coverage. iOS smoke is green on this head. Android smoke is still red on an unrelated-looking helper failure (Active application interaction viewport is unavailable during scroll, before occlusion processing), so please rerun that required check before merge.

@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Android smoke rerun at cae69aed3 is green — the roving Active application interaction viewport is unavailable failure didn't recur (it hit during a scroll before any occlusion processing, consistent with the known rerun-once live-smoke flake class, and the delta here is test-only relative to the already-reviewed fix). All checks on this PR are now green at head.

Generated by Claude Code

@thymikee

thymikee commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Final readiness check at unchanged head cae69aed3 is clean. The prior Android helper flake cleared on rerun, so all exact-head checks are now green. The immutable occlusion pass, cache semantics, non-vacuous CPU-spin regression, and mutation-sensitive callback/ancestor regression remain sound. Mergeable/CLEAN and ready for human merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 1, 2026
@thymikee
thymikee merged commit 26246ea into main Aug 1, 2026
30 of 31 checks passed
@thymikee
thymikee deleted the fix/snapshot-occlusion-memoization branch August 1, 2026 13:12
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-01 13:12 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant