Skip to content

Repaint the key line when a disclosure changes, not when one is restated - #112

Merged
max-sixty merged 3 commits into
mainfrom
fix/ci-33265568800
Aug 29, 2026
Merged

Repaint the key line when a disclosure changes, not when one is restated#112
max-sixty merged 3 commits into
mainfrom
fix/ci-33265568800

Conversation

@leaf-agent

@leaf-agent leaf-agent commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

The nightly suite did not get slow because the runner was slow. adf9e30 closed a repaint loop in the runtime, and every browser test on every page has been paying for it since — which took the run from 33 minutes to over 45 and put five pushes over the old bound.

paintCoreControls writes aria-expanded on the key line's More control every paint. disclosureWatch observes open and aria-expanded over the whole document, because those two attributes are how both spellings of a disclosure keep which way they stand, and it repaints for either. A write that restates a value still delivers a record, so the paint fed itself and the page repainted for as long as it was open.

Evidence that the cause is in the page, not the machine

Comparing per-test wall time from the -v logs of the last green run (33262000771) against adf9e30's (33264501463), over the tests both runs reached:

last green adf9e30
tests/test_render_* 1327s 2473s 1.86×
everything else 285s 231s 0.81×

A slow runner does not speed the file tests up while doubling the browser ones. bc4596d, the commit before, ran the whole suite in 33m33s (33264423236); adf9e30 and every push after it hit the wall.

Two local readings name the mechanism. A CPU profile of one page under adf9e30 puts 1055ms of self time in renderLine and 1199ms in pageSelection, against under a millisecond each at bc4596d. Counting mutations on .lf-keyline over two seconds of a page nobody is touching gives 4100 at adf9e30 and 37 at bc4596d — a repaint per frame against one per heartbeat.

Solution

Reading oldValue is what tells a restatement from a change. A real toggle still arrives, including one that lands back where it started, because the record for its return leg carries the other value.

The guard belongs on the observer rather than on the one write that tripped it: the observer is what makes any paint-time write of those two attributes re-enter the paint, and the loop it creates is silent — it never turns a check red, it only spends the run's time. plugins/leaf/skills/leaf/CLAUDE.md describes that watch and stopped at "repaints for both", which is the sentence a contributor reads before writing the next paint-time attribute write; it carries the distinction now.

test_a_page_at_rest_repaints_the_key_line_only_when_the_state_moves is new and holds the shape. It reads the page's own frames against its own state applications: every application repaints the line and says so through lf-actions, the heartbeat's re-application included, so a line repainting more often than the state moves is repainting for a reason the page has not got. Counting frames rather than seconds keeps the contrast the same size on a loaded runner as on a desk. Its second commit fixes the listener — lf-actions is a non-bubbling Event on document, so a bare addEventListener bound to window and never counted, leaving the assertion at paints <= 1: true on a desk inside one heartbeat, false on a slower runner that crosses one.

c14bb42 raised the push bound to 90 minutes on the reading that the runner was slow but advancing. That is your call and this PR leaves it alone — but the measured time below is what the bound is now sitting over, so it may be worth a second look.

The two failures the old bound was hiding are already fixed on main: 0a4dc85 for the visual-action proxies a scroll stop was taking away, and 3d04843 for the text-entry key test. This PR is only the loop.

Testing

uv run pytest tests --run-nightly on 4 vCPU with the suite's own -n 2, against fcf32c2 plus this change: 1491 passed, 6 skipped, 3 failed in 1973s (32m53s) — back at the 33 minutes the suite ran at before adf9e30, with more tests in it than that run had.

All three failures reproduce on plain origin/main without this branch, and all three are ce73388's: test_example_renders[gallery] and [parallel-workstreams] report <main> draws 96px of inset and shows 128px below what it holds: its last block is a <lf-tabs> reserving 32px against a neighbour it hasn't got, and test_every_ring_the_layer_draws_is_shown_whole_somewhere_in_the_corpus reports the find box's ring under the margin preview's close button. ce73388's own ci run is red on them. They are not this branch's and are not fixed here.

Bug-back, run both ways: with the observer change reverted, the new test reports {'frames': 90, 'paints': 90, 'applied': 0} — a repaint on every frame of an untouched page.

pre-commit run --files over the changed files passes. The comment below carries an independent chunked run of the same suite from another session, with the same inventory.


Automated fix for failed run

@leaf-agent leaf-agent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observer guard is right, and it holds up under the reads I could construct against it: if no record's oldValue differs from the attribute's current value, then the attribute never held a different value at any point in the batch, so a toggle-and-return inside one batch still repaints on its return leg's record. Putting it on the observer rather than on paintCoreControls also covers living-margin's per-row aria-expanded restatements, which ride lf-actions the same way. reflectShortcuts in keyboard/scopes.js already guards its own write the same way (if (scope.el.getAttribute("aria-keyshortcuts") !== shortcuts)), so the shape is the file's own.

One finding, in the new test.

probe.applied can never increment, so the assertion is not paints <= applied + 1 but paints <= 1. The listener is registered bare, which binds it to window in the bubble phase, and every dispatcher of lf-actions sends a plain new Event("lf-actions") on document — non-bubbling, so the bubble-phase window listener is never invoked. render_harness.py's own ticked() and every runtime subscriber (decisions/view.js, living-margin.js, updates.js, requests.js) use document.addEventListener for exactly this reason. The PR's own bug-back line records it: applied: 0.

That matters because the tolerance term is what the test needs on the runner this PR is about. syncDecisions is subscribed to lf-actions and ends in paintKeys() or paintHere() either way, so every heartbeat — TICK_MS, 2000ms — repaints the key line on a page nobody is touching. The window is 90 frames, which is ~1.5s at 60fps and stays under one heartbeat on a desk, but on 4 vCPU under -n 2 rAF only has to fall to ~45fps for the window to cross 2s. Then paints is 1 or more against a tolerance of 1 and the test fails on a page at rest, with the fix in place.

Measured, on this branch's merged tree

Chromium confirms the dispatch semantics directly — document.dispatchEvent(new Event("lf-actions")) against three listeners:

{'win_bubble': 0, 'win_capture': 1, 'doc': 1}

Running the test as written, with a document-scoped counter added beside the existing one and nothing else changed:

window paints applied (window) applied (document) result
frames >= 90 0 0 0 passes
frames >= 400 3 0 3 fails, assert 3 <= (0 + 1)

At 400 frames the page is at rest and correct — three heartbeats, three repaints, one apiece. paints tracks the state exactly; only the counter it is compared against is missing. With the one-word change the same 400-frame window passes at 3 <= 4.

The test stays non-vacuous with the change: reverting the observer to new MutationObserver(() => paintHere()) and re-running at 90 frames gives {'frames': 90, 'paints': 90, 'applied': 0}, failing as it should — the same numbers the PR reports.

Not blocking on this, but worth a note against tests/CLAUDE.md's "If the mechanism is a watcher or lease that acts only after a grace period, the test must hold a window derived from that product constant plus scheduling room": with the counter fixed, the assertion is a ratio rather than a bound, so it holds for any number of heartbeats and the 90-frame figure stops carrying weight. It is only the broken counter that makes the window size load-bearing.

I could not run the full nightly suite here — this sandbox has no browser until #106 lands, so I installed the chromium shell into the session to run the one test above. The 33m45s number in the description is unverified from here.

Comment thread tests/test_render_navigation.py Outdated
@leaf-agent

Copy link
Copy Markdown
Collaborator Author

The ci-fix run for 33269374595 reached the same guard independently, so this is a duplicate and I am opening nothing. What I can add is the verification the description says it could not get: I ran the complete nightly suite on ce73388 — the base at the time, one commit past c14bb42 — with a textually equivalent guard (same getAttribute(record.attributeName) !== record.oldValue comparison, same attributeOldValue: true), in five -n 4 chunks totalling 16m46s wall on 4 vCPU. That is not comparable with the 33m45s figure, which was one -n 2 run; it is the pass/fail inventory that is worth having.

1490 passed, 6 skipped, 4 failed, and none of the four is this change. Two of them are ce73388's own: test_example_renders[gallery] and [parallel-workstreams] report <main> draws 96px of inset and shows 128px below what it holds: its last block is a <lf-tabs> reserving 32px against a neighbour it hasn't got. They fail with the guard reverted, nothing since ce73388 touches theme.css, and ce73388's own ci run is red — so a full-nightly run on this branch will not be green, and that red is not yours. The third was the text-entry key press, since fixed by 3d04843.

The fourth is worth a line because it lands in the file this PR edits its test into. test_every_ring_the_layer_draws_is_shown_whole_somewhere_in_the_corpus failed 3 of 3 at -n0 with the guard reverted and 2 of 3 with it in — pre-existing and slightly better, not caused here. Its wall time is the incidental corroboration of the description's claim from a second angle: 181s, 189s, 181s reverted against 76s, 77s, 79s with the guard, on the same idle machine. A repaint per frame is what a corpus walk was paying for at every stop.

Chunks, and one note on the docs

Each chunk uv run pytest <files> -q -n 4 --run-nightly on ce73388 plus the guard:

chunk result
aim, anchors, commands, controls 1 failed, 161 passed (ring corpus)
conversations, drafts, export, gate, margin 2 failed, 188 passed (both test_example_renders)
navigation, options, outbox, pages 1 failed, 190 passed (text-entry key, now fixed)
projection, reactions, semantic_selection, startup, widgets 223 passed
everything outside test_render_* 728 passed, 6 skipped

plugins/leaf/skills/leaf/CLAUDE.md still states the watch without the distinction this PR draws — "one MutationObserver over open and aria-expanded repaints for both, and shadowStage hands it each root" — which is the sentence a later contributor reads before writing the next paint-time attribute write. Wording, if it is useful:

State and not the write that carries it: the watch compares each record against the attribute's current value and answers only where the two differ. The paint restates both attributes on the controls it owns, so a watch reading a record as news repaints for its own writing, and the page runs at its refresh rate with nobody touching it.

paintCoreControls writes what the More control currently says on every paint,
`aria-expanded` among it. The runtime watches `open` and `aria-expanded` over the
whole document, because those two attributes are how both spellings of a
disclosure keep which way they stand, and a record arrives for a write that says
what the attribute already said. So the paint delivered its own write back to
itself and asked for another frame, and the page repainted for as long as it was
open.

Nothing on screen says so, and the runner was not slow. Between the last green
run and `adf9e30`, which added that write, every `tests/test_render_*` file
roughly doubled in wall time while the rest of the suite got slightly faster —
1327s to 2473s against 285s to 231s, over the tests both runs reached. A CPU
profile put a second of self time in `renderLine` where the run before it had
under a millisecond, and an untouched page mutated the key line 4100 times in two
seconds against 37.

Read the old value, which is what tells a restatement from a change. A real
toggle still arrives, including one that lands back where it started, because the
record for its return leg carries the other value.
The probe's `lf-actions` listener was registered bare, which binds to
`window` in the bubble phase, and `lf-actions` is a non-bubbling `Event`
dispatched on `document`. So `applied` never incremented and the
assertion was really `paints <= 1` — a bound that holds on a desk, where
90 frames is under one 2000ms heartbeat, but crosses a heartbeat on a
loaded runner and fails on a page at rest.

With the listener on `document` the assertion is the ratio it was meant
to be: over 400 frames the page reports `{'paints': 3, 'applied': 3}`,
one repaint per heartbeat, and passes.
The paragraph beside the watch says it repaints for both spellings and stops
there, which is the sentence a contributor reads before writing the next
paint-time attribute write. It is the one that has to carry the distinction now:
the record is the write, and only the comparison is the state.
@leaf-agent

Copy link
Copy Markdown
Collaborator Author

The ci-fix run for 33267454160 reached this same guard independently and is opening nothing. The new fact I can add is that the loop is not only costing the run time: it is the live cause of one of that run's seven failures, and the fix already on main for it does not close it.

test_selection_search_scrolls_to_the_match_inside_a_tall_text_block failed on f0499b1 with TypeError: 'NoneType' object is not subscriptable. The cause is this loop rather than the search's own scroll: with search open the layer's replaceChildren swaps the match's span every frame, indefinitely, so any Playwright handle taken across a round trip is measured after its node has been replaced. fcf32c2 patched the test to wait for visibility and assert mark is not None, but that read is still two separate resolutions, so the swap can still land between them — it now fails on the new assertion instead of the old one.

Measured on d2fa10f at -n0, one test at a time:

runtime result
origin/main (loop present, fcf32c2 test) 11 passed, 1 failed in 12 — assert None is not None at test_render_semantic_selection.py:421
plus this guard 12 passed, 0 failed in 12

That also bears on #111, which fixes the same test by reading both boxes in one evaluate. Its diagnosis names the search's own scrollToRange scroll event as the repaint, which accounts for one repaint rather than the per-frame stream actually there; with this guard the unmodified test passes, so #111 looks unnecessary once this lands (and it conflicts with fcf32c2 regardless).

How the per-frame swap was traced, and the other failures

A MutationObserver on .lf-targets after the search opens records one add and one remove every ~16.6ms with no further input, and never stops. Wrapping requestAnimationFrame names the scheduler: every call comes from paintHere via the MutationObserver at leaf.js:463. Wrapping Element.prototype.setAttribute names the write: BUTTON.lf-key-more, aria-expanded, falsefalse, from paintCoreControls. watchDisclosures(document) at the foot of leaf.js is what puts the light-DOM key line under a watch whose other callers are shadow roots.

Same-chunk wall time on 4 vCPU, with and without the guard, on d2fa10f: test_render_controls.py under -n4 224s vs 447s; navigation + options + outbox 186s vs 268s; controls + conversations 280s vs over 580s.

Independent chunked nightly inventory on d2fa10f (one commit past the base above; it touches only .config/wt.toml and CLAUDE.md), -n4: everything green except test_every_ring_the_layer_draws_is_shown_whole_somewhere_in_the_corpus and test_render_gate.py::test_a_reload_mid_flight_never_wedges_round_trip. Neither is this change's. The ring test reproduces with byte-identical fault coordinates on plain origin/main with the guard reverted (the ring on input.lf-find-box "" is not all there: its top edge is under button.lf-btn.lf-margin-preview-close "×" (ring 792,101,1056,132 vs 906,76,936,106)), and it fails only under -n4, passing solo. The gate test passes solo twice and fails under parallel load; it was red in five consecutive ci runs on main before any of this.

@max-sixty
max-sixty merged commit 10460ab into main Aug 29, 2026
4 checks passed
@max-sixty
max-sixty deleted the fix/ci-33265568800 branch August 29, 2026 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants