7df2905c
Add frame-callback throttling design spec
a73x 2026-04-16 10:31
Commit message
docs/superpowers/specs/2026-04-16-frame-callback-throttling-design.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,265 @@ | |||
| 1 | # Frame-Callback Throttling & Wayland Loop Rearchitecture | ||
| 2 | |||
| 3 | **Date:** 2026-04-16 | ||
| 4 | **Status:** Approved design | ||
| 5 | **Motivation:** waystty freezes when its window is moved to a hidden sway workspace. Root cause: the render path makes blocking Vulkan WSI calls (`vkWaitForFences`, `vkAcquireNextImageKHR`) with infinite timeouts while the compositor holds swapchain buffers without releasing them on an unmapped surface. This is a symptom of a deeper architectural gap — waystty renders eagerly on dirty state rather than pacing via `wl_surface.frame` callbacks, which is the canonical Wayland pattern. | ||
| 6 | |||
| 7 | ## Goals | ||
| 8 | |||
| 9 | 1. **Eliminate the freeze.** waystty must stay responsive (keyboard, pty, pointer, clipboard) when its surface is hidden or suspended. | ||
| 10 | 2. **Follow the canonical Wayland client pattern.** Render only when the compositor signals readiness via frame callback. Treat compositor signals (enter/leave, configure states, frame callbacks) as authoritative for visibility and pacing. | ||
| 11 | 3. **Consolidate duplication.** Today there are four near-identical Wayland loops (main terminal, text-coverage compare, draw-smoke, benchmark). They've already drifted. Extract a shared readiness primitive. | ||
| 12 | 4. **Preserve testability.** The pacing logic should be unit-testable without a live compositor. | ||
| 13 | 5. **Preserve benchmark validity.** Per-frame section timings (`snapshot_us`, `row_rebuild_us`, `atlas_upload_us`, `instance_upload_us`, `gpu_submit_us`) remain identical under the throttle. Wall-clock aggregates become vsync-capped; an opt-out env var (`WAYSTTY_BENCH_UNTHROTTLED=1`) preserves raw-throughput measurement at the cost of freeze-safety. | ||
| 14 | |||
| 15 | ## Non-goals | ||
| 16 | |||
| 17 | - Rewriting the terminal state machine, selection model, or PTY handling. | ||
| 18 | - Adding damage tracking (partial commits) — out of scope, but this rearchitecture doesn't foreclose it. | ||
| 19 | - Multithreaded rendering. | ||
| 20 | - Adopting `wp_fractional_scale_v1` / `wp_viewporter` — integer scales only (matches current behavior; see MEMORY notes). | ||
| 21 | - `wp_presentation_time` feedback — bench measurements rely on per-frame section timings, not presentation latency. | ||
| 22 | - `cursor-shape-v1`, `xdg-activation-v1`, `xdg-decoration` — none present today, none introduced here. | ||
| 23 | |||
| 24 | ## Architecture | ||
| 25 | |||
| 26 | Three layers, each independently testable: | ||
| 27 | |||
| 28 | ``` | ||
| 29 | ┌───────────────────────────────────────────────┐ | ||
| 30 | │ app code (main, text-compare, smoke, bench) │ mutates state, owns render fn | ||
| 31 | └───────────────────────────────────────────────┘ | ||
| 32 | │ uses | ||
| 33 | ┌───────────────────────────────────────────────┐ | ||
| 34 | │ FrameLoop (new: src/frame_loop.zig) │ pacing + readiness | ||
| 35 | │ - pending_callback, armed │ | ||
| 36 | │ - waitForWork, canRender, commitRender │ | ||
| 37 | └───────────────────────────────────────────────┘ | ||
| 38 | │ uses | ||
| 39 | ┌───────────────────────────────────────────────┐ | ||
| 40 | │ Surface lifecycle (wayland.zig, extended) │ configure, enter/leave, suspended | ||
| 41 | │ - SurfaceState { configured, suspended, │ | ||
| 42 | │ pending_configure, tracker }│ | ||
| 43 | └───────────────────────────────────────────────┘ | ||
| 44 | ``` | ||
| 45 | |||
| 46 | **Key invariants:** | ||
| 47 | |||
| 48 | - `FrameLoop` never touches Vulkan or terminal state. It is a pure readiness primitive over a wl_display + wl_surface. | ||
| 49 | - `canRender()` returns `armed && state.visible()`, where `state.visible() == configured && !suspended && entered_outputs > 0`. All four conditions gate every render. | ||
| 50 | - `commitRender()` is called by the app *after* it has committed the surface with new content. It requests the next `wl_surface.frame()` callback and flips `armed` to false. | ||
| 51 | - State transitions that change visibility (enter/leave, configure, suspended flag) notify the FrameLoop via `onSurfaceHidden()` / `onSurfaceShown()`. These update the pending-callback bookkeeping but do not force a render. | ||
| 52 | |||
| 53 | ## Components | ||
| 54 | |||
| 55 | ### `src/frame_loop.zig` (new, ~200 LOC) | ||
| 56 | |||
| 57 | ```zig | ||
| 58 | pub const FrameLoop = struct { | ||
| 59 | display: *wl.Display, | ||
| 60 | surface: *wl.Surface, | ||
| 61 | state: *const SurfaceState, // borrowed | ||
| 62 | |||
| 63 | pending_callback: ?*wl.Callback = null, | ||
| 64 | armed: bool = true, // first render is unconditionally allowed | ||
| 65 | |||
| 66 | pub fn init(display: *wl.Display, surface: *wl.Surface, state: *const SurfaceState) FrameLoop; | ||
| 67 | pub fn deinit(self: *FrameLoop) void; // destroys pending callback if any | ||
| 68 | |||
| 69 | // Blocks until wl_display or any extra_fd is readable, or timeout_ms elapses. | ||
| 70 | // Dispatches any wl events that arrive. Safe to call even if not armed — | ||
| 71 | // visibility/state changes are still processed. | ||
| 72 | pub fn waitForWork(self: *FrameLoop, extra: []std.posix.pollfd, timeout_ms: i32) !void; | ||
| 73 | |||
| 74 | pub fn canRender(self: *const FrameLoop) bool; | ||
| 75 | |||
| 76 | // Caller has already committed the surface with new content. | ||
| 77 | // Requests the next frame callback, flips armed=false. | ||
| 78 | pub fn commitRender(self: *FrameLoop) !void; | ||
| 79 | |||
| 80 | // State-transition hooks called from Window listeners. | ||
| 81 | // onSurfaceHidden drops the pending callback but leaves `armed` unchanged; | ||
| 82 | // canRender() will still be false because state.visible() is false. | ||
| 83 | // onSurfaceShown sets armed=true unconditionally (the compositor may or | ||
| 84 | // may not redeliver a pre-hide callback; re-arming is idempotent). | ||
| 85 | pub fn onSurfaceHidden(self: *FrameLoop) void; | ||
| 86 | pub fn onSurfaceShown(self: *FrameLoop) void; | ||
| 87 | |||
| 88 | // Recovery path for OUT_OF_DATE: no commit happened, so bypass the callback gate. | ||
| 89 | pub fn forceArm(self: *FrameLoop) void; | ||
| 90 | }; | ||
| 91 | ``` | ||
| 92 | |||
| 93 | ### `src/wayland.zig` additions | ||
| 94 | |||
| 95 | ```zig | ||
| 96 | pub const SurfaceState = struct { | ||
| 97 | configured: bool = false, | ||
| 98 | suspended: bool = false, | ||
| 99 | tracker: *ScaleTracker, | ||
| 100 | |||
| 101 | pub fn visible(self: *const SurfaceState) bool { | ||
| 102 | return self.configured | ||
| 103 | and !self.suspended | ||
| 104 | and self.tracker.enteredCount() > 0; | ||
| 105 | } | ||
| 106 | }; | ||
| 107 | |||
| 108 | // Added to ScaleTracker: | ||
| 109 | pub fn enteredCount(self: *const ScaleTracker) usize; | ||
| 110 | ``` | ||
| 111 | |||
| 112 | **`ack_configure` stays inline** (as today at wayland.zig:1044). The earlier draft proposed deferring ack until the next render commit; on review, deferral adds protocol risk (configure serials pile up while hidden, some compositors treat long ack delays as hostile) without any real batching benefit. Inline ack on receipt is spec-compliant, well-tested in the current codebase, and makes `SurfaceState` simpler. | ||
| 113 | |||
| 114 | Listener changes: | ||
| 115 | |||
| 116 | - `xdgSurfaceListener` continues to call `ackConfigure` inline; additionally sets `state.configured = true` on first configure. | ||
| 117 | - `xdgToplevelListener.configure` scans `cfg.states` for `.suspended` and sets `state.suspended` accordingly. | ||
| 118 | - `surfaceListener.enter/leave` continues to update the scale tracker and additionally invokes the FrameLoop's `onSurfaceShown` / `onSurfaceHidden` when the visibility boolean transitions. | ||
| 119 | - `wm_base` bind bumped from version 5 → version 6 in `registryListener` (wayland.zig:1092). The `.suspended` state is a v6 feature; older compositors simply never set it, in which case frame callbacks alone still correctly throttle — belt-and-suspenders. | ||
| 120 | - `scale_generation` (wayland.zig:1055, :1060) is unchanged. Its purpose remains "was there a visibility/scale transition since last check" and it is still consumed only by the main loop. A scale change while hidden increments the counter; the deferred resize path (see below) picks it up when visible. | ||
| 121 | - `xdg_wm_base.ping` is already handled by the existing `wmBaseListener`. Because `FrameLoop.waitForWork` calls `dispatchPending` on every iteration regardless of `armed`, pings are answered promptly even while hidden. | ||
| 122 | |||
| 123 | ### `src/main.zig` main-loop body | ||
| 124 | |||
| 125 | Shrinks from ~350 LOC to ~150. **All Vulkan-touching work is gated on `canRender()`** — not just `drawCells`, but also `deviceWaitIdle`, `recreateSwapchain`, and the `rebuildFaceForScale` path (which itself calls `deviceWaitIdle`). Without this gate, a configure arriving on a hidden surface would still trigger swapchain teardown/recreate and block on in-flight acquires held by the compositor. | ||
| 126 | |||
| 127 | Shape: | ||
| 128 | |||
| 129 | ```zig | ||
| 130 | while (!window.should_close and pty.isChildAlive()) { | ||
| 131 | try frame_loop.waitForWork(&extra_fds, repeat_timeout); | ||
| 132 | applyPtyOutput(...); // non-Vulkan: reads pty, updates term | ||
| 133 | applyKeyboardEvents(...); // non-Vulkan | ||
| 134 | applyPointerEvents(...); // non-Vulkan | ||
| 135 | observeResize(...); // non-Vulkan: records pending resize from | ||
| 136 | // listeners into local state | ||
| 137 | if (!frame_loop.canRender()) continue; // hidden: no Vulkan work at all | ||
| 138 | if (dirty) { | ||
| 139 | applyPendingResize(...); // Vulkan: deviceWaitIdle + recreateSwapchain | ||
| 140 | // + rebuildFaceForScale if scale changed | ||
| 141 | renderFrame(...) catch |err| switch (err) { | ||
| 142 | error.OutOfDateKHR => { | ||
| 143 | try ctx.recreateSwapchain(...); | ||
| 144 | frame_loop.forceArm(); | ||
| 145 | continue; | ||
| 146 | }, | ||
| 147 | else => return err, | ||
| 148 | }; | ||
| 149 | try frame_loop.commitRender(); | ||
| 150 | dirty = false; | ||
| 151 | } | ||
| 152 | } | ||
| 153 | ``` | ||
| 154 | |||
| 155 | `observeResize` detects `window.width/height/bufferScale` changes and records a "resize pending" flag + the new values; it does not call any Vulkan API. `applyPendingResize` performs the actual Vulkan work and runs only when `canRender()`. This separation guarantees no blocking call executes on a hidden surface. | ||
| 156 | |||
| 157 | Bench, text-compare, and draw-smoke modes follow the same structure with their own render function and extra_fds. | ||
| 158 | |||
| 159 | ## Data flow | ||
| 160 | |||
| 161 | **Startup.** Registry roundtrip → wm_base v6 bound → outputs discovered → window created → initial empty surface commit → `xdg_surface.configure` stores serial → first loop iteration: `armed=true`, `configured=true`, `entered>0` once the compositor places the surface on an output → first render commits with content → `commitRender` requests the first frame callback. | ||
| 162 | |||
| 163 | **Steady-state typing.** pty_fd readable → term.write → dirty=true. `waitForWork` returns. `canRender` false while waiting on callback — loop re-enters wait. `wl_callback.done` fires → `armed=true`. Next iteration renders, commits, re-arms. | ||
| 164 | |||
| 165 | **Workspace hidden.** sway sends `wl_surface.leave` for all outputs (and/or `xdg_toplevel.configure` with `.suspended` on v6). Window listener detects visibility transition and calls `frame_loop.onSurfaceHidden()`, which destroys the pending callback. pty activity continues; dirty flips to true repeatedly; `canRender` is false every iteration (state.visible()=false). No Vulkan calls. Loop remains responsive to keyboard/pointer/pty/clipboard. | ||
| 166 | |||
| 167 | **Workspace visible again.** `wl_surface.enter` (and/or suspended cleared). Window listener calls `frame_loop.onSurfaceShown()` → `armed=true`. Next iteration renders and requests a fresh callback. Normal pacing resumes. | ||
| 168 | |||
| 169 | **Resize while awaiting callback.** `xdg_surface.configure` is acked inline by the listener; `xdg_toplevel.configure` updates width/height. `observeResize` records a pending resize; dirty flips true. `canRender` is false (callback still pending) — wait. Callback fires → `canRender` true → `applyPendingResize` runs Vulkan work → `renderFrame` → `commitRender`. | ||
| 170 | |||
| 171 | **Configure while hidden.** Listener acks inline (no-op from the client's perspective beyond sending the ack). `observeResize` records the new dimensions. `canRender` is false — no Vulkan work runs. When the surface becomes visible again, `applyPendingResize` executes, catching up the swapchain/atlas to the compositor-configured size in one go before the next render. | ||
| 172 | |||
| 173 | **OUT_OF_DATE.** `renderFrame` returns `error.OutOfDateKHR`. No commit happened; `deviceWaitIdle` + `recreateSwapchain`; `forceArm()`; keep `dirty=true`; `continue`. Next iteration re-renders at new swapchain dims. | ||
| 174 | |||
| 175 | ## Error handling | ||
| 176 | |||
| 177 | - **Wayland disconnect / protocol error.** `readEvents` / `dispatchPending` errors propagate up. Main loop treats as fatal (same failure class as child pty death). No recovery. | ||
| 178 | - **Vulkan OUT_OF_DATE / SUBOPTIMAL.** Handled in the app render function: rebuild swapchain, `forceArm`, retry. SUBOPTIMAL treated as OUT_OF_DATE. | ||
| 179 | - **Pending callback when surface hidden.** `onSurfaceHidden` destroys the orphaned wl_callback client-side. Compositor won't fire it. Prevents a stale callback from firing after re-arm. | ||
| 180 | - **Hidden→visible transition.** `onSurfaceShown` unconditionally sets `armed=true`. We can't know whether a callback is queued for us; re-arming is idempotent from the compositor's view. | ||
| 181 | - **Stale frame callbacks.** `wl_callback.destroy()` is client-side; the compositor may still deliver a `done` event for a destroyed callback if it was already queued on the wire. The `wl_callback.done` listener therefore must verify identity before acting: | ||
| 182 | ```zig | ||
| 183 | fn frameCallbackListener(cb: *wl.Callback, _: wl.Callback.Event, loop: *FrameLoop) void { | ||
| 184 | if (loop.pending_callback != cb) return; // stale — we already moved on | ||
| 185 | cb.destroy(); | ||
| 186 | loop.pending_callback = null; | ||
| 187 | loop.armed = true; | ||
| 188 | } | ||
| 189 | ``` | ||
| 190 | - **FrameLoop deinit.** Destroys pending callback before surface teardown. Order: `FrameLoop.deinit → Window.deinit`. | ||
| 191 | - **Thread safety.** Single-threaded. All mutation happens in the main thread; listeners run synchronously inside `dispatchPending`. | ||
| 192 | |||
| 193 | ## Testing | ||
| 194 | |||
| 195 | ### Unit tests — `FrameLoop` | ||
| 196 | |||
| 197 | FrameLoop is parameterized over a `DisplayOps` trait (fn pointers for `prepareRead`, `readEvents`, `dispatchPending`, `flush`, `surface.frame`, and `callback.setListener`). Production wraps `*wl.Display` + `*wl.Surface` + `*wl.Callback` thinly; tests inject a mock that synthesizes `done` events by calling the stored listener directly. Budget: ~80 LOC of indirection including a `MockCallback` shim type (zig-wayland's `*wl.Callback` is an opaque concrete type, not an interface, so the mock's callback handle is a separate type that satisfies the trait). Unlocks: | ||
| 198 | |||
| 199 | - `initial state: armed, no pending callback` | ||
| 200 | - `commitRender stores pending callback and flips armed=false` | ||
| 201 | - `simulated callback.done flips armed=true and clears pending` | ||
| 202 | - `onSurfaceHidden destroys pending callback without firing` | ||
| 203 | - `onSurfaceShown force-arms regardless of previous state` | ||
| 204 | - `canRender requires armed && state.visible()` | ||
| 205 | - `forceArm bypasses the callback gate` | ||
| 206 | |||
| 207 | ### Unit tests — `SurfaceState` / `ScaleTracker` | ||
| 208 | |||
| 209 | - `visible requires configured && !suspended && enteredCount > 0` | ||
| 210 | - `suspended set/cleared based on xdg_toplevel.configure.states` | ||
| 211 | - `enteredCount reflects entered-output set` (extends existing tracker tests) | ||
| 212 | |||
| 213 | ### Integration test — hidden-freeze regression | ||
| 214 | |||
| 215 | Two forms, both supported: | ||
| 216 | |||
| 217 | 1. **Synthetic automated variant** (no compositor required). Uses the mock `DisplayOps` from the unit tests. Simulates: startup → commit frame → simulated `onSurfaceHidden` → pty write loop (100 iterations, asserts loop body executes each time without blocking) → simulated `onSurfaceShown` → simulated `wl_callback.done` → assert `canRender()` true → simulated render. Runs in the normal `zig build test` pass. | ||
| 218 | |||
| 219 | 2. **Manual mode** under sway with two workspaces. Opt-in mode `--hidden-freeze-regression` (gated like `--text-coverage-compare`): spawns waystty, prints "move this window to another workspace; I will flood pty for 5s; move it back", waits for stdin confirmation, runs the flood, exits 0 on responsiveness. Documented in the mode's help text. | ||
| 220 | |||
| 221 | ### Preserved coverage | ||
| 222 | |||
| 223 | All existing tests in `main.zig`, `wayland.zig`, `scale_tracker.zig`, `vt.zig`, `pty.zig` continue to pass unmodified. The dirty-row / selection / PTY / VT paths are untouched — only the driver around them moves. | ||
| 224 | |||
| 225 | ### Benchmark mode | ||
| 226 | |||
| 227 | Per-frame section timings (`snapshot_us`, `row_rebuild_us`, `atlas_upload_us`, `instance_upload_us`, `gpu_submit_us`) measure work *inside* a frame and stay identical under the throttle — they're load-independent. Aggregate wall-clock numbers ("frames per wall-second", average end-to-end loop time) become vsync-capped at ~60–144 Hz and lose their value for comparing changes that only affect per-frame cost. | ||
| 228 | |||
| 229 | Escape hatch: `WAYSTTY_BENCH_UNTHROTTLED=1` bypasses `FrameLoop` in bench mode and reverts to today's eager loop for wall-clock measurements. Unthrottled bench is explicitly not freeze-safe — workspace-change during an unthrottled bench will still deadlock. Benchmark output includes a header line indicating which mode was used. | ||
| 230 | |||
| 231 | ## Scope | ||
| 232 | |||
| 233 | Files touched: | ||
| 234 | |||
| 235 | - `src/frame_loop.zig` (new, ~200 LOC + tests) | ||
| 236 | - `src/wayland.zig` (+ `SurfaceState`, + `enteredCount`, listener changes, wm_base v5 → v6) — ~100 LOC delta | ||
| 237 | - `src/scale_tracker.zig` (+ `enteredCount`) — ~10 LOC delta | ||
| 238 | - `src/main.zig` — four loop sites refactored, ~500 LOC net delta (mostly reduction) | ||
| 239 | |||
| 240 | No changes to: `src/renderer.zig`, `src/vt.zig`, `src/pty.zig`, `src/font.zig`, `src/config.zig`, shaders. | ||
| 241 | |||
| 242 | ## Rollout | ||
| 243 | |||
| 244 | Single-commit change is too large. Plan to split into ordered steps: | ||
| 245 | |||
| 246 | 1. Add `SurfaceState` + `enteredCount` + tests (no behavior change yet). | ||
| 247 | 2. Bump wm_base to v6; add `suspended` handling. | ||
| 248 | 3. Introduce FrameLoop module + tests (not yet used). | ||
| 249 | 4. Migrate main terminal loop to FrameLoop; verify manual test (switch workspaces, no freeze). | ||
| 250 | 5. Migrate text-coverage-compare loop. | ||
| 251 | 6. Migrate draw-smoke loop. | ||
| 252 | 7. Migrate benchmark loop; verify bench output still readable. | ||
| 253 | |||
| 254 | Each step compiles and passes tests. Step 4 is the earliest point the freeze is fixed; 5–7 complete the duplication cleanup. | ||
| 255 | |||
| 256 | ## Open questions | ||
| 257 | |||
| 258 | None as of approval. All design decisions resolved in brainstorming + review: | ||
| 259 | |||
| 260 | - Gating signal: pure frame-callback + visibility flags, no polling fallback. | ||
| 261 | - Benchmark: throttled by default; `WAYSTTY_BENCH_UNTHROTTLED=1` opts out (not freeze-safe). | ||
| 262 | - Configure ack: inline on receipt (reverted from an earlier "defer to commit" draft — deferral added protocol risk without benefit). | ||
| 263 | - xdg_toplevel `.suspended`: added as secondary signal, not required (compositors without v6 still handled by frame callbacks). | ||
| 264 | - All Vulkan work (including `deviceWaitIdle`, `recreateSwapchain`, `rebuildFaceForScale`) gated on `canRender()`, not just `drawCells`. | ||
| 265 | - `wl_callback.done` listener verifies callback identity before acting, to defend against stale callbacks already on the wire at destroy time. | ||