client: pull the next grid frame only after a frame was presented #415

Merged
natsukium merged 2 commits from client/pace-pulls-when-unpresentable into main 2026-09-25 04:51:54 +09:00
Owner

A window that cannot present fails its surface acquire at once instead of waiting for vsync. On macOS, wgpu returns Occluded for a covered or minimized window or a locked screen, and Timeout for a sleeping display. on_redraw skipped the frame but still sent NextGridFrame, so the client pulled and decoded grid cycles as fast as the acquire failed. On an M4 Max under DOOM-fire that cost 0.2 to 0.7 of a core, and none of it reached the screen.

Change

  • The client sends a pull only after a frame is actually presented (PullScheduler::after_frame(presented)).
  • A frame that does not present starts a redraw retry (PresentRetry, felis-client-core). The first retry waits one refresh interval, and each further failure doubles the wait, up to 1 s. WindowEvent::Occluded(false) makes the retry due at once.
  • While a retry is pending, every other redraw request waits for it: cursor blink, the shader clock, eager-push frames, glyph repaint. The event loop sleeps until the retry deadline.
  • The first frame that presents sends one pull. The daemon answers it with everything that changed since, so the window catches up without a replay. Input, resize and session exit use channels that pulls do not gate.
  • The client now logs one warning when a stall begins, not one per failed frame.
  • Docs: added a paragraph to docs/explanation/rendering/pipeline.md "Demand-driven emission", and a CHANGELOG "Fixed" entry.

Measured

M4 Max, release builds, 10 s of DOOM-fire at 35x120, isolated HOME and daemon. Figures are client CPU seconds per run.

state main (b5204672) branch load avg (1 min)
display on, login window on console 6.88-7.00 0.01 3.2-4.1
display asleep, screen locked 1.86-1.96 0.00-0.02 2.4-4.5
idle, display asleep 0.01 0.01 3.5-3.9

A fixed refresh-interval retry, without the doubling, cost 0.17-0.27 s per idle 10 s. The daemon's CPU did not change. The screen was locked for every run, so none of them presented a frame. The resume path is covered by unit tests only.

Tests

The decision is tested in crates/felis-client-core/src/pull.rs:

  • a failed acquire sends no pull
  • retries are held between deadlines and back off up to the cap
  • a visibility change makes the retry due at once
  • after a stall, the first presented frame sends exactly one pull

The App-level wiring has no harness (#150). just check passes.

Closes #407

A window that cannot present fails its surface acquire at once instead of waiting for vsync. On macOS, wgpu returns `Occluded` for a covered or minimized window or a locked screen, and `Timeout` for a sleeping display. `on_redraw` skipped the frame but still sent `NextGridFrame`, so the client pulled and decoded grid cycles as fast as the acquire failed. On an M4 Max under DOOM-fire that cost 0.2 to 0.7 of a core, and none of it reached the screen. ## Change - The client sends a pull only after a frame is actually presented (`PullScheduler::after_frame(presented)`). - A frame that does not present starts a redraw retry (`PresentRetry`, felis-client-core). The first retry waits one refresh interval, and each further failure doubles the wait, up to 1 s. `WindowEvent::Occluded(false)` makes the retry due at once. - While a retry is pending, every other redraw request waits for it: cursor blink, the shader clock, eager-push frames, glyph repaint. The event loop sleeps until the retry deadline. - The first frame that presents sends one pull. The daemon answers it with everything that changed since, so the window catches up without a replay. Input, resize and session exit use channels that pulls do not gate. - The client now logs one warning when a stall begins, not one per failed frame. - Docs: added a paragraph to `docs/explanation/rendering/pipeline.md` "Demand-driven emission", and a CHANGELOG "Fixed" entry. ## Measured M4 Max, release builds, 10 s of DOOM-fire at 35x120, isolated HOME and daemon. Figures are client CPU seconds per run. | state | main (b5204672) | branch | load avg (1 min) | |---|---|---|---| | display on, login window on console | 6.88-7.00 | 0.01 | 3.2-4.1 | | display asleep, screen locked | 1.86-1.96 | 0.00-0.02 | 2.4-4.5 | | idle, display asleep | 0.01 | 0.01 | 3.5-3.9 | A fixed refresh-interval retry, without the doubling, cost 0.17-0.27 s per idle 10 s. The daemon's CPU did not change. The screen was locked for every run, so none of them presented a frame. The resume path is covered by unit tests only. ## Tests The decision is tested in `crates/felis-client-core/src/pull.rs`: - a failed acquire sends no pull - retries are held between deadlines and back off up to the cap - a visibility change makes the retry due at once - after a stall, the first presented frame sends exactly one pull The App-level wiring has no harness (#150). `just check` passes. Closes #407
A window that cannot present fails its surface acquire at once instead
of blocking on vsync: on macOS wgpu returns `Occluded` whenever the
window's occlusion state is not visible (a covered or minimized window,
a locked screen), and `Timeout` when `nextDrawable` gives up (a sleeping
display). `on_redraw` skipped such a frame but still sent
`NextGridFrame`, so nothing paced the pull: the client pulled and
decoded grid cycles as fast as the acquire failed, 500 to 2,100 failed
acquires per second on an M4 Max under DOOM-fire, painting none of
them.

The pull now goes out only after a presented frame. A frame that did
not present arms a redraw retry one refresh interval out (the monitor's
rate, 60 Hz if it reports none), each further failure doubles the wait
up to one second, and `WindowEvent::Occluded(false)` makes it due at
once. winit raises that event from the same AppKit occlusion state wgpu
checks. The retry is needed because with no pull out no cycle arrives
to request the next redraw; it re-arms rather than clearing, so a
redraw the paint gate drops is retried. The first frame that presents
sends one pull, which the daemon answers with everything dirty since,
so the window catches up in one cycle rather than replaying. Input,
resize and session exit are unaffected: they ride the input and control
channels, which no pull gates.

Rejected alternatives:

- Retrying at once: the same unpaced loop, minus the decode.
- Retrying at a fixed refresh interval: every retry runs the frame's
  CPU preparation before the acquire fails, so an idle hidden window
  cost 0.17-0.27 s of CPU per 10 s against 0.00-0.01 s on main.
- No timer while winit reports the window occluded: the window would
  stall for good if the matching `Occluded(false)` never came, and a
  sleeping display fails the acquire with no occlusion report at all.

While stalled, every redraw request (cursor blink, a post shader's
clock, an eager-push frame, a glyph repaint) is held for the next
retry, and the event loop sleeps until the retry deadline alone:
otherwise those sources would fail the acquire at their own rate. The
per-frame warning becomes one warning when the stall begins; repeats
log at debug.

The retry clock lives beside `PullScheduler` in felis-client-core and
is owned by the window, not the connection, since a reconnect does not
make the surface presentable. The tests pin the decision at that seam
(a failed acquire issues no pull; a later present or visibility change
resumes with one pull); the App-level event loop has no harness yet.

Measured on an M4 Max, release builds, DOOM-fire for 10 s at 35x120
(Menlo 14, no font features), isolated HOME and daemon, CPU seconds of
the client per pass:

| state | main | this change | load avg (1 min) |
|---|---|---|---|
| display on, login window on console | 6.88-7.00 | 0.01 | 3.2-4.1 |
| display asleep, screen locked | 1.86-1.96 | 0.00-0.02 | 2.4-4.5 |
| idle, display asleep | 0.01 | 0.01 | 3.5-3.9 |

The daemon's CPU is unchanged (15.2-17.5 s), and DOOM-fire's frame
rate rises (display on: 1,979-2,035 to 2,246-2,259 fps) with the
client off the CPU. No run presented a frame (the screen was
locked throughout), so the resume path is covered by the tests only.

Assisted-by: Claude Code
Merge remote-tracking branch 'origin/main' into client/pace-pulls-when-unpresentable
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m18s
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 54s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m10s
pr / nix flake check (pull_request) Successful in 38s
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m49s
pr / wire schema is compatible with the base (pull_request) Successful in 11s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m17s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 3s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 3m33s
windows / frontend smoke (Windows) (pull_request) Successful in 1m26s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
73918225aa
# Conflicts:
#	CHANGELOG.md
natsukium deleted branch client/pace-pulls-when-unpresentable 2026-09-25 04:51:54 +09:00
Commenting is not possible because the repository is archived.
No description provided.