Pacing keeps trusting an app after a front switch through the desktop#177

opened10d agobySSamanyu Das

Found by the final Sol review of the #163 stack. Seen in the engine code and in a probe log, not on screen.

What happens

  1. Helium is focused, and pacing counts it as quick (Tracked::prompt in vendor/paneru/src/ecs/pacing.rs).
  2. Focus moves to an empty display. Finder comes to the front with only its desktop window, which the engine does not track, so Helium keeps FocusedMarker.
  3. Focus moves back to the same Helium window. No tracked window gains or loses focus, so forget_switched_apps resets nothing.
  4. macOS did switch apps. If Helium then applies its first move late, the window ahead can run off and show desktop, as in #163.

Evidence

  • A probe build logged that pacing still treated Helium's windows as quick at the first move after the round trip, on engine e7742b8.
  • On 31 October 2026, the visible gap did not show in 24 round trips on Sam's row. Helium applied its moves on time each time.

Tried and dropped

paneru#93 also reset pacing on every ApplicationFrontSwitched. It fixed the mechanism, but an ordinary app switch then reset pacing twice. In 4 alternating rounds of 12 presses, quick apps were held back 90 times instead of 65, and the slowest window took 662 to 999 ms to land instead of 487 to 628 ms. Sam dropped it.

A narrower fix would reset on a front switch only when the focus change did not already reset that app. Worth doing only if the gap is seen on screen.

Evidence: /tmp/s1desk/live/ on Sam's Mac (local only).

Note

🤖 Claude Opus 5.5 responding on behalf of Sam (samanyudas)

This bug did not reproduce on main, so there is no fix yet.

I tried to reproduce this on a Miri Developer build of main at 70ad7f0f, with engine ffe0673, on 2 November 2026. The desktop gap did not show. The instrumented run below shows why: on this build, pacing almost never trusts a moving window, so the state this issue needs rarely exists.

What I ran

  • I arranged the strip on the 27-inch display as two windows of a stand-in app, then an AXFixture window, then T3 Code. The built-in display was empty.
  • The stand-in shows each move 250 ms late after macOS brings it to the front, and its main thread stays free. The pacing notes say that Helium is late by up to 250 ms after a focus change.
  • Each trial pressed ⌃⌥⌘← from the first column, waited 0.8 s, pressed ⌃⌥1 to come back, and then pressed ⌃⌥= at once to grow the first column. The grow moves the stand-in's second window right, with the fixture ahead of it.
  • A sampler read the frames that the window server showed every 4 ms.

What I saw

  • In 14 round trips with this stand-in, no extra desktop showed between its second window and the fixture: 0 px each time. 8 earlier round trips with a stand-in that blocked its main thread for 250 ms also showed 0 px. Each time, the fixture waited until the late window had moved.
  • In 6 positive controls, I made the stand-in late with no front switch. That also showed 0 px, so the window was not trusted even before any round trip.
  • A build of the same commit with info logs on Tracked::prompt showed the cause. The engine stepped every 3 to 10 ms, and the window server showed each move 1 to 3 engine frames later. So Tracked::see marked the window late in nearly every motion, the quick fixture included. In 24 motions, the stand-in's second window ended prompt 2 times and the fixture 3 times. Those were motions where the window barely moved.
  • The round trip itself worked as this issue describes: macOS brought another app to the front (com.apple.WindowManager), and the window kept its focus.

What would unblock a fix

A screen recording or frame log of the gap on Sam's row, on a build of main or later. The narrower fix is ready to build if the gap shows: on a front switch, clear prompt for the windows of the switched apps, and keep the record of the motion in flight. Clearing a flag that is already clear changes nothing, so an ordinary app switch would pace exactly as it does now. That avoids the double reset that made paneru#93 slower.

Found on the way

  • ⌃⌥⌘← toward the empty built-in display never made that display active, in all 26 tries. The engine logged macOS did not make display 1 active after the engine focused its desktop, and Miri flashed "macOS did not switch to the other display". PROGRESS.md records this working on 31 October. Filed as #253.
  • Pacing that almost never trusts a moving window means that a row of quick apps is always paced from where macOS shows it. That is the cost #163's pacing meant to avoid for quick apps. I have not measured whether anyone can see it.

The evidence is on Sam's Mac only: ~/.cache/miri/verify-miri/runs/20261102-104617/ and ~/.cache/miri/verify-miri/runs/20261102-105611/, which hold pacing-trust.log and the frame samples.

Note

🤖 Claude Opus 5.5 responding on behalf of Sam (samanyudas)

The mechanism this issue describes is still in the engine on main at ffefedfa, engine a20f56e. An engine test reproduces it. The gap it causes is limited to the first animation step, and it is still not seen on screen.

Root cause

forget_switched_apps (vendor/paneru/src/ecs/pacing.rs:540) finds an app switch only when FocusedMarker moves from a window of one app to a window of another. FocusedMarker moves only when a tracked window gains it (maintain_focus_singleton, src/ecs/focus.rs:338). A switch to an app with no tracked window moves nothing on the way out or back:

  • Since #253, focusing an empty display brings Finder to the front with only its desktop window, which the engine does not track.
  • On the way back, ApplicationFrontSwitched reports the window that already holds FocusedMarker, so no window gains it.

So macOS deactivates and reactivates the app, but pacing keeps its prompt flag. The same path applies to ⌘-Tab to any app with no tracked window and back. I checked the other user of focus changes, overlapping stack ordering in focus.rs. It handles a windowless front app on purpose, so the fault is only in pacing.

How far the effect goes

Tracked::see (pacing.rs:279) clears prompt on the first frame it reads the window away from where the engine asked for it. So trust lasts one engine frame of a late motion. In that frame the window ahead takes one full ease step. After that, hold_right_of and hold_left_of keep it where it is, so desktop as wide as that step stays visible until the late app catches up. The doc comment on AnimationStep says a 16 ms first step covers 20% of the move at speed 14, which is Sam's setting. I inferred that size and did not measure it on screen.

Reproduction (engine test, not on screen)

I added three temporary tests to src/tests/pacing.rs. In each one, window 1's app first shows a whole motion in time. Then window 1 is centered while its app holds its writes. I recorded the widest desktop between windows 0 and 1 beyond the rest gap:

Before the late moveExtra desktop
No switch (control, trust kept by design)5 px
Round trip through a tracked window of another app0 px
Round trip through an app with no tracked window (this issue)5 px, then the test fails

The 5 px is the first ease step in the test harness. It stays for all 10 frames that the app holds its writes. The patch is in ~/.cache/miri/verify-miri/runs/20261106-issue177-triage/ on Sam's Mac.

Why it still has not shown on screen

The bug needs the app to count as prompt first. On 2 November an instrumented build showed that windows on Sam's Mac show each move 1 to 3 frames late, so only 2 to 3 of 24 motions ended prompt. Tracked::see and the window writer have not changed since then, but I did not measure that again today.

Fix options

  1. Clear prompt for an app's windows on each ApplicationFrontSwitched, and keep the record of the motion in flight. Clearing a flag that is already clear changes nothing, so an ordinary switch paces as it does now, which avoids the double reset that slowed paneru#93. The third test above becomes the failing test first.
  2. Close as not planned. The worst case is one first ease step of desktop, and only right after a round trip through a windowless app, while the app counts as prompt.

I recommend option 1, because it is small and makes the test pass. Option 2 is reasonable if one frame of trust is acceptable. I left the labels as they are until Sam picks one.