a73x

0c91e8f4

Add frame-callback throttling implementation plan

a73x   2026-04-16 10:38

Commit message
Add frame-callback throttling implementation plan

14-task plan following the spec's rollout order. Task 8 is where the
hidden-workspace freeze gets fixed; tasks 9-13 consolidate the remaining
Wayland modes and add regression tests.

docs/superpowers/plans/2026-04-16-frame-callback-throttling-implementation.md
Old New
@@ -0,0 +1,1793 @@
1 # Frame-Callback Throttling Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Eliminate the hidden-workspace freeze in waystty by adopting canonical `wl_surface.frame`-callback throttling, and consolidate four near-duplicate Wayland main loops into a shared `FrameLoop` readiness primitive.
6
7 **Architecture:** Introduce a `FrameLoop` module that owns the poll/dispatch/armed/pending-callback bookkeeping. It is parameterized over a `DisplayOps` trait (fn-pointer vtable) so it can be unit-tested with a mock compositor. Introduce a `SurfaceState` struct on the window that tracks `configured`/`suspended`/entered-outputs. The main loop gates all Vulkan work on `frame_loop.canRender()` — including `deviceWaitIdle`, `recreateSwapchain`, and `rebuildFaceForScale`, not just `drawCells`. Each of the four Wayland modes (`runTerminal`, `runTextCoverageCompare`, `runDrawSmokeTest`, bench mode via `WAYSTTY_BENCH`) migrates to the shared loop. Bench mode gets an opt-out env var.
8
9 **Tech Stack:** Zig, zig-wayland bindings (already at xdg_wm_base v6 via build.zig:31 scanner call — only the `registry.bind` version needs bumping), Vulkan WSI via vulkan-zig, xkbcommon.
10
11 **Spec:** `docs/superpowers/specs/2026-04-16-frame-callback-throttling-design.md`
12
13 ---
14
15 ## File Structure
16
17 **New files:**
18 - `src/frame_loop.zig` — FrameLoop + DisplayOps trait + MockDisplayOps for tests (~250 LOC)
19
20 **Modified files:**
21 - `src/scale_tracker.zig` — add `enteredCount()` (~10 LOC)
22 - `src/wayland.zig` — add `SurfaceState`, bump wm_base bind v5→v6, handle xdg_toplevel `.suspended`, wire FrameLoop transitions into surfaceListener (~120 LOC delta)
23 - `src/main.zig` — four loop sites migrated; split resize handling into observeResize/applyPendingResize (~600 LOC net reduction)
24 - `build.zig` — wire `frame_loop` module and its test step (~30 LOC)
25
26 **Unchanged:** `src/renderer.zig`, `src/vt.zig`, `src/pty.zig`, `src/font.zig`, `src/config.zig`, shaders.
27
28 ---
29
30 ## Task 1: Add `enteredCount()` to ScaleTracker
31
32 **Files:**
33 - Modify: `src/scale_tracker.zig`
34
35 - [ ] **Step 1: Write failing test**
36
37 Append to the existing test block in `src/scale_tracker.zig`:
38
39 ```zig
40 test "enteredCount reflects the entered-output set" {
41 var t = ScaleTracker.init(std.testing.allocator);
42 defer t.deinit();
43
44 try std.testing.expectEqual(@as(usize, 0), t.enteredCount());
45
46 try t.addOutput(1);
47 try t.addOutput(2);
48 try std.testing.expectEqual(@as(usize, 0), t.enteredCount());
49
50 try t.enterOutput(1);
51 try std.testing.expectEqual(@as(usize, 1), t.enteredCount());
52
53 try t.enterOutput(2);
54 try std.testing.expectEqual(@as(usize, 2), t.enteredCount());
55
56 t.leaveOutput(1);
57 try std.testing.expectEqual(@as(usize, 1), t.enteredCount());
58
59 t.removeOutput(2);
60 try std.testing.expectEqual(@as(usize, 0), t.enteredCount());
61 }
62 ```
63
64 - [ ] **Step 2: Run to verify failure**
65
66 Run: `make test`
67 Expected: compile error — `enteredCount` not defined on `ScaleTracker`.
68
69 - [ ] **Step 3: Implement**
70
71 Insert in `src/scale_tracker.zig` after the existing `bufferScale` method (around line 54):
72
73 ```zig
74 pub fn enteredCount(self: *const ScaleTracker) usize {
75 return self.entered.count();
76 }
77 ```
78
79 - [ ] **Step 4: Run to verify pass**
80
81 Run: `make test`
82 Expected: all tests pass, including the new `enteredCount reflects the entered-output set`.
83
84 - [ ] **Step 5: Commit**
85
86 ```bash
87 git add src/scale_tracker.zig
88 git commit -m "$(cat <<'EOF'
89 Add ScaleTracker.enteredCount
90
91 Exposes the size of the entered-output set so higher layers can gate
92 rendering on surface visibility.
93 EOF
94 )"
95 ```
96
97 ---
98
99 ## Task 2: Add `SurfaceState` struct
100
101 **Files:**
102 - Modify: `src/wayland.zig`
103
104 - [ ] **Step 1: Write failing test**
105
106 Append to the tests at the bottom of `src/wayland.zig`:
107
108 ```zig
109 test "SurfaceState.visible requires configured && !suspended && enteredCount > 0" {
110 var tracker = ScaleTracker.init(std.testing.allocator);
111 defer tracker.deinit();
112 try tracker.addOutput(1);
113
114 var state = SurfaceState{ .tracker = &tracker };
115
116 // Unconfigured → not visible
117 try std.testing.expect(!state.visible());
118
119 state.configured = true;
120 // No entered outputs yet → not visible
121 try std.testing.expect(!state.visible());
122
123 try tracker.enterOutput(1);
124 // Configured + entered + not suspended → visible
125 try std.testing.expect(state.visible());
126
127 state.suspended = true;
128 try std.testing.expect(!state.visible());
129
130 state.suspended = false;
131 tracker.leaveOutput(1);
132 try std.testing.expect(!state.visible());
133 }
134 ```
135
136 - [ ] **Step 2: Run to verify failure**
137
138 Run: `make test`
139 Expected: compile error — `SurfaceState` undefined.
140
141 - [ ] **Step 3: Implement**
142
143 Add near the top of `src/wayland.zig` (after imports, before `pub const Connection`):
144
145 ```zig
146 pub const SurfaceState = struct {
147 configured: bool = false,
148 suspended: bool = false,
149 tracker: *ScaleTracker,
150
151 pub fn visible(self: *const SurfaceState) bool {
152 return self.configured
153 and !self.suspended
154 and self.tracker.enteredCount() > 0;
155 }
156 };
157 ```
158
159 - [ ] **Step 4: Run to verify pass**
160
161 Run: `make test`
162 Expected: all tests pass.
163
164 - [ ] **Step 5: Commit**
165
166 ```bash
167 git add src/wayland.zig
168 git commit -m "$(cat <<'EOF'
169 Add SurfaceState struct for visibility tracking
170
171 Single source of truth for whether the surface is currently mapped,
172 configured, and not suspended. Not yet wired into Window — that happens
173 in a follow-up.
174 EOF
175 )"
176 ```
177
178 ---
179
180 ## Task 3: Wire SurfaceState into Window
181
182 **Files:**
183 - Modify: `src/wayland.zig`
184
185 - [ ] **Step 1: Add SurfaceState to Window**
186
187 In `src/wayland.zig`, modify the `Window` struct to own a `SurfaceState` and drop the existing `configured: bool` field. Find the struct definition (around line 545, look for `pub const Window = struct`). Replace the old `configured` field with:
188
189 ```zig
190 state: SurfaceState,
191 ```
192
193 - [ ] **Step 2: Update Window initialization**
194
195 In `createWindow` (around wayland.zig:658), after setting up the window fields but before returning, initialize `state`:
196
197 ```zig
198 window.* = .{
199 .alloc = alloc,
200 .surface = try compositor.createSurface(),
201 .xdg_surface = undefined,
202 .xdg_toplevel = undefined,
203 .tracker = &self.scale_tracker,
204 .outputs = &self.outputs,
205 .state = .{ .tracker = &self.scale_tracker },
206 // ... preserve other existing fields (width, height, etc.)
207 };
208 ```
209
210 (Keep all other existing fields. Just add `.state = .{ .tracker = &self.scale_tracker }`.)
211
212 - [ ] **Step 3: Update xdgSurfaceListener to set configured**
213
214 In `src/wayland.zig`, locate `fn xdgSurfaceListener` (around line 1041) and update:
215
216 ```zig
217 fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void {
218 switch (event) {
219 .configure => |cfg| {
220 surface.ackConfigure(cfg.serial);
221 window.state.configured = true;
222 },
223 }
224 }
225 ```
226
227 (Removes the old `window.configured = true` assignment if it existed under a different name. If the old code used a boolean field called `configured`, replace all references throughout `wayland.zig` with `window.state.configured`.)
228
229 - [ ] **Step 4: Remove or redirect any remaining `window.configured` references**
230
231 Search for `window.configured` and `self.configured` in `src/wayland.zig` and update to `window.state.configured` / `self.state.configured`. Run: `grep -n "\.configured" src/wayland.zig` from the shell to find them.
232
233 In `src/main.zig`, search for any `window.configured` references and update similarly. Run: `grep -n "window\.configured" src/main.zig`.
234
235 - [ ] **Step 5: Run tests + build**
236
237 Run: `make test && make build`
238 Expected: all tests pass, main binary compiles.
239
240 - [ ] **Step 6: Commit**
241
242 ```bash
243 git add src/wayland.zig src/main.zig
244 git commit -m "$(cat <<'EOF'
245 Wire SurfaceState into Window
246
247 Replaces the bare Window.configured flag with a SurfaceState embedded
248 on the Window. Visibility queries now go through state.visible(), which
249 is a no-op change today but will gate rendering once FrameLoop lands.
250 EOF
251 )"
252 ```
253
254 ---
255
256 ## Task 4: Bump wm_base to v6 and handle `xdg_toplevel.configure.states.suspended`
257
258 **Files:**
259 - Modify: `src/wayland.zig`
260
261 - [ ] **Step 1: Write failing test**
262
263 Append to the tests at the bottom of `src/wayland.zig`:
264
265 ```zig
266 test "SurfaceState.suspended toggles from xdg_toplevel.configure.states" {
267 var tracker = ScaleTracker.init(std.testing.allocator);
268 defer tracker.deinit();
269 try tracker.addOutput(1);
270 try tracker.enterOutput(1);
271
272 var state = SurfaceState{ .tracker = &tracker, .configured = true };
273 try std.testing.expect(state.visible());
274
275 // Helper function under test — applies xdg_toplevel state array to SurfaceState.
276 const states_suspended = [_]u32{@intFromEnum(xdg.Toplevel.State.suspended)};
277 applyToplevelStates(&state, std.mem.sliceAsBytes(&states_suspended));
278 try std.testing.expect(state.suspended);
279 try std.testing.expect(!state.visible());
280
281 const states_none = [_]u32{};
282 applyToplevelStates(&state, std.mem.sliceAsBytes(&states_none));
283 try std.testing.expect(!state.suspended);
284 try std.testing.expect(state.visible());
285 }
286 ```
287
288 - [ ] **Step 2: Run to verify failure**
289
290 Run: `make test`
291 Expected: compile error — `applyToplevelStates` undefined.
292
293 - [ ] **Step 3: Bump wm_base bind version**
294
295 In `src/wayland.zig` at line 1092, change:
296
297 ```zig
298 } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) {
299 conn.globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return;
300 ```
301
302 to:
303
304 ```zig
305 } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) {
306 conn.globals.wm_base = registry.bind(g.name, xdg.WmBase, 6) catch return;
307 ```
308
309 (The scanner already generates v6 bindings per build.zig:31. Only the runtime bind version changes.)
310
311 - [ ] **Step 4: Implement `applyToplevelStates`**
312
313 Add to `src/wayland.zig` (near `xdgToplevelListener`, around line 1067):
314
315 ```zig
316 fn applyToplevelStates(state: *SurfaceState, state_bytes: []const u8) void {
317 // xdg_toplevel.configure delivers the state array as a wl_array of u32.
318 // Re-interpret to u32 slice and scan for `.suspended`.
319 const u32_count = state_bytes.len / @sizeOf(u32);
320 const states = std.mem.bytesAsSlice(u32, state_bytes[0 .. u32_count * @sizeOf(u32)]);
321 var suspended = false;
322 for (states) |raw| {
323 if (raw == @intFromEnum(xdg.Toplevel.State.suspended)) {
324 suspended = true;
325 break;
326 }
327 }
328 state.suspended = suspended;
329 }
330 ```
331
332 - [ ] **Step 5: Wire into xdgToplevelListener**
333
334 Update `xdgToplevelListener` (around wayland.zig:1067):
335
336 ```zig
337 fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
338 switch (event) {
339 .configure => |cfg| {
340 if (cfg.width > 0) window.width = @intCast(cfg.width);
341 if (cfg.height > 0) window.height = @intCast(cfg.height);
342 applyToplevelStates(&window.state, std.mem.sliceAsBytes(cfg.states.slice()));
343 },
344 .close => window.should_close = true,
345 .configure_bounds => {},
346 .wm_capabilities => {},
347 }
348 }
349 ```
350
351 (The exact access pattern for `cfg.states` depends on the zig-wayland binding shape. If `cfg.states` is already a `[]u32` or similar, skip the `sliceAsBytes` and pass directly; adjust `applyToplevelStates` to match. Verify by reading the generated binding in `zig-cache/` or by attempting both and keeping whichever compiles.)
352
353 - [ ] **Step 6: Run tests**
354
355 Run: `make test`
356 Expected: all tests pass, including `SurfaceState.suspended toggles from xdg_toplevel.configure.states`.
357
358 - [ ] **Step 7: Build**
359
360 Run: `make build`
361 Expected: clean compile. Older sway versions simply never send `.suspended` — backwards-compatible.
362
363 - [ ] **Step 8: Commit**
364
365 ```bash
366 git add src/wayland.zig
367 git commit -m "$(cat <<'EOF'
368 Bump xdg_wm_base to v6 and honor toplevel suspended state
369
370 The scanner already generates v6 bindings (build.zig:31); this commit
371 bumps the runtime bind version and hooks xdg_toplevel.configure.states
372 into SurfaceState.suspended. Compositors that don't send .suspended
373 (pre-v6 impls) see no behavior change.
374 EOF
375 )"
376 ```
377
378 ---
379
380 ## Task 5: Create `DisplayOps` trait and real-world shim
381
382 **Files:**
383 - Create: `src/frame_loop.zig`
384 - Modify: `build.zig`
385
386 - [ ] **Step 1: Wire new module into build.zig**
387
388 In `build.zig`, after the `scale_tracker_mod` creation (around line 16), add:
389
390 ```zig
391 const frame_loop_mod = b.createModule(.{
392 .root_source_file = b.path("src/frame_loop.zig"),
393 .target = target,
394 .optimize = optimize,
395 });
396 frame_loop_mod.addImport("wayland", wayland_generated_mod);
397 frame_loop_mod.addImport("scale_tracker", scale_tracker_mod);
398 ```
399
400 Wire it as a dependency of `wayland_mod` (so wayland.zig can call into FrameLoop hooks) — after `wayland_mod.addImport("scale_tracker", scale_tracker_mod);`:
401
402 ```zig
403 wayland_mod.addImport("frame_loop", frame_loop_mod);
404 ```
405
406 And make `frame_loop` importable by main:
407
408 ```zig
409 exe_mod.addImport("frame_loop", frame_loop_mod);
410 ```
411
412 Then add a test step for it. After the `scale_tracker_tests` block (around line 121):
413
414 ```zig
415 // Test frame_loop.zig
416 const frame_loop_test_mod = b.createModule(.{
417 .root_source_file = b.path("src/frame_loop.zig"),
418 .target = target,
419 .optimize = optimize,
420 });
421 frame_loop_test_mod.addImport("wayland", wayland_generated_mod);
422 frame_loop_test_mod.addImport("scale_tracker", scale_tracker_mod);
423 const frame_loop_tests = b.addTest(.{
424 .root_module = frame_loop_test_mod,
425 });
426 test_step.dependOn(&b.addRunArtifact(frame_loop_tests).step);
427 ```
428
429 - [ ] **Step 2: Create initial `src/frame_loop.zig` with the DisplayOps trait**
430
431 Create `src/frame_loop.zig`:
432
433 ```zig
434 const std = @import("std");
435 const wl = @import("wayland").client.wl;
436 const scale_tracker = @import("scale_tracker");
437
438 // Mirror of wayland.SurfaceState. Using a structural duplicate here instead of
439 // importing wayland.zig avoids a circular module dependency (wayland depends on
440 // frame_loop). The real wayland.SurfaceState embeds one of these by reference.
441 pub const SurfaceStateView = struct {
442 configured_ptr: *const bool,
443 suspended_ptr: *const bool,
444 tracker: *const scale_tracker.ScaleTracker,
445
446 pub fn visible(self: SurfaceStateView) bool {
447 return self.configured_ptr.*
448 and !self.suspended_ptr.*
449 and self.tracker.enteredCount() > 0;
450 }
451 };
452
453 // Opaque handle to a frame callback. Real path holds a *wl.Callback cast here;
454 // mock holds a usize cast here. Identity is pointer equality.
455 pub const CallbackToken = *const anyopaque;
456
457 pub const DisplayOps = struct {
458 ctx: *anyopaque,
459
460 // All fn pointers receive the same ctx so the caller can carry whatever
461 // concrete objects it needs (wl.Display, wl.Surface, test mock, etc).
462 flushFn: *const fn (*anyopaque) void,
463 prepareReadFn: *const fn (*anyopaque) bool,
464 readEventsFn: *const fn (*anyopaque) void,
465 dispatchPendingFn: *const fn (*anyopaque) void,
466 getFdFn: *const fn (*anyopaque) std.posix.fd_t,
467
468 // Requests a wl_surface.frame() and sets its done listener. Returns the
469 // token identifying the new callback. FrameLoop compares the token
470 // delivered by onFrameCallbackDone against pending_token.
471 requestFrameFn: *const fn (*anyopaque, *FrameLoop) anyerror!CallbackToken,
472
473 // Destroys a previously issued frame callback (for hide-path cleanup).
474 // Must tolerate being called on a token the compositor has already
475 // consumed — real path: wl.Callback.destroy is idempotent on the client.
476 destroyCallbackFn: *const fn (*anyopaque, CallbackToken) void,
477 };
478
479 pub const FrameLoop = struct {
480 ops: DisplayOps,
481 state: SurfaceStateView,
482
483 pending_token: ?CallbackToken = null,
484 armed: bool = true,
485
486 pub fn init(ops: DisplayOps, state: SurfaceStateView) FrameLoop {
487 return .{ .ops = ops, .state = state };
488 }
489
490 pub fn deinit(self: *FrameLoop) void {
491 if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
492 self.pending_token = null;
493 }
494
495 pub fn canRender(self: *const FrameLoop) bool {
496 return self.armed and self.state.visible();
497 }
498
499 pub fn commitRender(self: *FrameLoop) !void {
500 std.debug.assert(self.canRender());
501 if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
502 self.pending_token = try self.ops.requestFrameFn(self.ops.ctx, self);
503 self.armed = false;
504 }
505
506 pub fn onFrameCallbackDone(self: *FrameLoop, token: CallbackToken) void {
507 if (self.pending_token == null or self.pending_token.? != token) return;
508 self.ops.destroyCallbackFn(self.ops.ctx, token);
509 self.pending_token = null;
510 self.armed = true;
511 }
512
513 pub fn onSurfaceHidden(self: *FrameLoop) void {
514 if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
515 self.pending_token = null;
516 // armed unchanged — canRender() is false while hidden regardless.
517 }
518
519 pub fn onSurfaceShown(self: *FrameLoop) void {
520 self.armed = true;
521 }
522
523 pub fn forceArm(self: *FrameLoop) void {
524 if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
525 self.pending_token = null;
526 self.armed = true;
527 }
528
529 /// Blocks on wl_fd + extra pollfds with `timeout_ms`, then reads + dispatches
530 /// any pending Wayland events. Safe to call in any state.
531 pub fn waitForWork(
532 self: *FrameLoop,
533 extra: []std.posix.pollfd,
534 timeout_ms: i32,
535 ) !void {
536 self.ops.flushFn(self.ops.ctx);
537
538 const wl_fd = self.ops.getFdFn(self.ops.ctx);
539 // Build a small on-stack pollfd array: wl_fd + extras.
540 // Cap extras at 8 — waystty never polls more than pty+wl.
541 var all: [9]std.posix.pollfd = undefined;
542 all[0] = .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 };
543 std.debug.assert(extra.len <= all.len - 1);
544 for (extra, 0..) |fd, i| all[i + 1] = fd;
545 const total = 1 + extra.len;
546
547 _ = std.posix.poll(all[0..total], timeout_ms) catch {};
548
549 // Propagate revents back into caller's extra slice.
550 for (extra, 0..) |*fd, i| fd.* = all[i + 1];
551
552 if (all[0].revents & std.posix.POLL.IN != 0) {
553 if (self.ops.prepareReadFn(self.ops.ctx)) {
554 self.ops.readEventsFn(self.ops.ctx);
555 }
556 }
557 self.ops.dispatchPendingFn(self.ops.ctx);
558 }
559 };
560 ```
561
562 - [ ] **Step 3: Build to verify module wires up**
563
564 Run: `make build`
565 Expected: clean compile. The file has no tests yet.
566
567 - [ ] **Step 4: Commit**
568
569 ```bash
570 git add build.zig src/frame_loop.zig
571 git commit -m "$(cat <<'EOF'
572 Introduce FrameLoop module and DisplayOps trait
573
574 Pure readiness primitive for the wl_surface.frame-callback pacing
575 pattern. Not yet used by any loop — next commits add a mock DisplayOps
576 for tests, then migrate the real loops one at a time.
577 EOF
578 )"
579 ```
580
581 ---
582
583 ## Task 6: Add MockDisplayOps and unit tests for FrameLoop
584
585 **Files:**
586 - Modify: `src/frame_loop.zig`
587
588 - [ ] **Step 1: Add MockDisplayOps and failing tests**
589
590 Append to `src/frame_loop.zig`:
591
592 ```zig
593 // ---------------------------------------------------------------------------
594 // Test-only mock below this line.
595 // ---------------------------------------------------------------------------
596
597 const Mock = struct {
598 next_token: usize = 1,
599 frame_requests: usize = 0,
600 callbacks_destroyed: usize = 0,
601 flushed: usize = 0,
602 dispatched: usize = 0,
603
604 // A fake fd that never becomes ready — tests call waitForWork with a
605 // 0ms timeout so poll returns immediately.
606 pipe_fds: [2]std.posix.fd_t = .{ -1, -1 },
607
608 fn init() !Mock {
609 const pipes = try std.posix.pipe();
610 return .{ .pipe_fds = pipes };
611 }
612
613 fn deinit(self: *Mock) void {
614 if (self.pipe_fds[0] >= 0) std.posix.close(self.pipe_fds[0]);
615 if (self.pipe_fds[1] >= 0) std.posix.close(self.pipe_fds[1]);
616 }
617
618 fn flushThunk(ctx: *anyopaque) void {
619 const self: *Mock = @ptrCast(@alignCast(ctx));
620 self.flushed += 1;
621 }
622 fn prepareReadThunk(_: *anyopaque) bool { return false; }
623 fn readEventsThunk(_: *anyopaque) void {}
624 fn dispatchPendingThunk(ctx: *anyopaque) void {
625 const self: *Mock = @ptrCast(@alignCast(ctx));
626 self.dispatched += 1;
627 }
628 fn getFdThunk(ctx: *anyopaque) std.posix.fd_t {
629 const self: *Mock = @ptrCast(@alignCast(ctx));
630 return self.pipe_fds[0];
631 }
632 fn requestFrameThunk(ctx: *anyopaque, _: *FrameLoop) anyerror!CallbackToken {
633 const self: *Mock = @ptrCast(@alignCast(ctx));
634 const tok: CallbackToken = @ptrFromInt(self.next_token);
635 self.next_token += 1;
636 self.frame_requests += 1;
637 return tok;
638 }
639 fn destroyCallbackThunk(ctx: *anyopaque, _: CallbackToken) void {
640 const self: *Mock = @ptrCast(@alignCast(ctx));
641 self.callbacks_destroyed += 1;
642 }
643
644 fn ops(self: *Mock) DisplayOps {
645 return .{
646 .ctx = self,
647 .flushFn = flushThunk,
648 .prepareReadFn = prepareReadThunk,
649 .readEventsFn = readEventsThunk,
650 .dispatchPendingFn = dispatchPendingThunk,
651 .getFdFn = getFdThunk,
652 .requestFrameFn = requestFrameThunk,
653 .destroyCallbackFn = destroyCallbackThunk,
654 };
655 }
656 };
657
658 const TestState = struct {
659 configured: bool = true,
660 suspended: bool = false,
661 tracker: scale_tracker.ScaleTracker,
662
663 fn init(alloc: std.mem.Allocator) !TestState {
664 var t = scale_tracker.ScaleTracker.init(alloc);
665 try t.addOutput(1);
666 try t.enterOutput(1);
667 return .{ .tracker = t };
668 }
669
670 fn deinit(self: *TestState) void {
671 self.tracker.deinit();
672 }
673
674 fn view(self: *const TestState) SurfaceStateView {
675 return .{
676 .configured_ptr = &self.configured,
677 .suspended_ptr = &self.suspended,
678 .tracker = &self.tracker,
679 };
680 }
681 };
682
683 test "initial state: armed and can render when visible" {
684 var mock = try Mock.init();
685 defer mock.deinit();
686 var ts = try TestState.init(std.testing.allocator);
687 defer ts.deinit();
688
689 var loop = FrameLoop.init(mock.ops(), ts.view());
690 defer loop.deinit();
691
692 try std.testing.expect(loop.armed);
693 try std.testing.expect(loop.canRender());
694 try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
695 }
696
697 test "commitRender requests a frame and disarms" {
698 var mock = try Mock.init();
699 defer mock.deinit();
700 var ts = try TestState.init(std.testing.allocator);
701 defer ts.deinit();
702
703 var loop = FrameLoop.init(mock.ops(), ts.view());
704 defer loop.deinit();
705
706 try loop.commitRender();
707
708 try std.testing.expect(!loop.armed);
709 try std.testing.expect(!loop.canRender());
710 try std.testing.expect(loop.pending_token != null);
711 try std.testing.expectEqual(@as(usize, 1), mock.frame_requests);
712 }
713
714 test "onFrameCallbackDone re-arms when token matches" {
715 var mock = try Mock.init();
716 defer mock.deinit();
717 var ts = try TestState.init(std.testing.allocator);
718 defer ts.deinit();
719
720 var loop = FrameLoop.init(mock.ops(), ts.view());
721 defer loop.deinit();
722
723 try loop.commitRender();
724 const tok = loop.pending_token.?;
725 loop.onFrameCallbackDone(tok);
726
727 try std.testing.expect(loop.armed);
728 try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
729 try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
730 }
731
732 test "onFrameCallbackDone ignores stale token" {
733 var mock = try Mock.init();
734 defer mock.deinit();
735 var ts = try TestState.init(std.testing.allocator);
736 defer ts.deinit();
737
738 var loop = FrameLoop.init(mock.ops(), ts.view());
739 defer loop.deinit();
740
741 try loop.commitRender();
742 const stale_tok: CallbackToken = @ptrFromInt(0xDEAD);
743 loop.onFrameCallbackDone(stale_tok);
744
745 try std.testing.expect(!loop.armed);
746 try std.testing.expect(loop.pending_token != null);
747 }
748
749 test "onSurfaceHidden destroys pending callback and leaves armed unchanged" {
750 var mock = try Mock.init();
751 defer mock.deinit();
752 var ts = try TestState.init(std.testing.allocator);
753 defer ts.deinit();
754
755 var loop = FrameLoop.init(mock.ops(), ts.view());
756 defer loop.deinit();
757
758 try loop.commitRender();
759 const armed_before = loop.armed;
760 loop.onSurfaceHidden();
761
762 try std.testing.expectEqual(armed_before, loop.armed); // false, unchanged
763 try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
764 try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
765 }
766
767 test "onSurfaceShown force-arms" {
768 var mock = try Mock.init();
769 defer mock.deinit();
770 var ts = try TestState.init(std.testing.allocator);
771 defer ts.deinit();
772
773 var loop = FrameLoop.init(mock.ops(), ts.view());
774 defer loop.deinit();
775
776 try loop.commitRender();
777 try std.testing.expect(!loop.armed);
778 loop.onSurfaceShown();
779 try std.testing.expect(loop.armed);
780 }
781
782 test "canRender requires both armed and visible" {
783 var mock = try Mock.init();
784 defer mock.deinit();
785 var ts = try TestState.init(std.testing.allocator);
786 defer ts.deinit();
787
788 var loop = FrameLoop.init(mock.ops(), ts.view());
789 defer loop.deinit();
790
791 try std.testing.expect(loop.canRender()); // armed + visible
792
793 ts.suspended = true;
794 try std.testing.expect(!loop.canRender()); // visibility gate
795
796 ts.suspended = false;
797 try loop.commitRender();
798 try std.testing.expect(!loop.canRender()); // armed gate
799 }
800
801 test "forceArm recovers without a callback (OUT_OF_DATE path)" {
802 var mock = try Mock.init();
803 defer mock.deinit();
804 var ts = try TestState.init(std.testing.allocator);
805 defer ts.deinit();
806
807 var loop = FrameLoop.init(mock.ops(), ts.view());
808 defer loop.deinit();
809
810 try loop.commitRender();
811 try std.testing.expect(!loop.armed);
812 loop.forceArm();
813 try std.testing.expect(loop.armed);
814 try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
815 }
816
817 test "deinit destroys any pending callback" {
818 var mock = try Mock.init();
819 defer mock.deinit();
820 var ts = try TestState.init(std.testing.allocator);
821 defer ts.deinit();
822
823 {
824 var loop = FrameLoop.init(mock.ops(), ts.view());
825 defer loop.deinit();
826 try loop.commitRender();
827 }
828
829 try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
830 }
831 ```
832
833 - [ ] **Step 2: Run tests**
834
835 Run: `make test`
836 Expected: all 9 new FrameLoop tests pass.
837
838 - [ ] **Step 3: Commit**
839
840 ```bash
841 git add src/frame_loop.zig
842 git commit -m "$(cat <<'EOF'
843 Add MockDisplayOps + FrameLoop unit tests
844
845 9 tests covering: initial state, commit disarms, callback done re-arms,
846 stale-token rejection, hidden cleanup, show force-arm, canRender
847 gating, forceArm recovery, deinit cleanup.
848 EOF
849 )"
850 ```
851
852 ---
853
854 ## Task 7: Real-path DisplayOps adapter in wayland.zig
855
856 **Files:**
857 - Modify: `src/wayland.zig`
858
859 - [ ] **Step 1: Add adapter at the top of `src/wayland.zig`**
860
861 After the existing imports, add:
862
863 ```zig
864 const frame_loop_mod = @import("frame_loop");
865 pub const FrameLoop = frame_loop_mod.FrameLoop;
866 pub const DisplayOps = frame_loop_mod.DisplayOps;
867 pub const SurfaceStateView = frame_loop_mod.SurfaceStateView;
868 pub const CallbackToken = frame_loop_mod.CallbackToken;
869 ```
870
871 - [ ] **Step 2: Add the real-path adapter on Window**
872
873 Append to the `Window` struct methods (in `src/wayland.zig`, inside the `pub const Window = struct { ... };` block, after the existing methods):
874
875 ```zig
876 /// Build a DisplayOps vtable that drives the real compositor.
877 pub fn displayOps(self: *Window, display: *wl.Display) DisplayOps {
878 // Carry both display and surface via an owned adapter struct.
879 self.display_adapter = .{
880 .display = display,
881 .surface = self.surface,
882 .loop_ref = null,
883 };
884 return .{
885 .ctx = &self.display_adapter,
886 .flushFn = DisplayAdapter.flushThunk,
887 .prepareReadFn = DisplayAdapter.prepareReadThunk,
888 .readEventsFn = DisplayAdapter.readEventsThunk,
889 .dispatchPendingFn = DisplayAdapter.dispatchPendingThunk,
890 .getFdFn = DisplayAdapter.getFdThunk,
891 .requestFrameFn = DisplayAdapter.requestFrameThunk,
892 .destroyCallbackFn = DisplayAdapter.destroyCallbackThunk,
893 };
894 }
895
896 pub fn surfaceStateView(self: *const Window) SurfaceStateView {
897 return .{
898 .configured_ptr = &self.state.configured,
899 .suspended_ptr = &self.state.suspended,
900 .tracker = self.tracker,
901 };
902 }
903 ```
904
905 And add a field to `Window`:
906
907 ```zig
908 display_adapter: DisplayAdapter = undefined,
909 ```
910
911 - [ ] **Step 3: Implement the DisplayAdapter type**
912
913 Add to `src/wayland.zig`, before the `pub const Window = struct` definition:
914
915 ```zig
916 pub const DisplayAdapter = struct {
917 display: *wl.Display,
918 surface: *wl.Surface,
919 loop_ref: ?*FrameLoop,
920
921 fn flushThunk(ctx: *anyopaque) void {
922 const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
923 _ = self.display.flush();
924 }
925 fn prepareReadThunk(ctx: *anyopaque) bool {
926 const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
927 return self.display.prepareRead();
928 }
929 fn readEventsThunk(ctx: *anyopaque) void {
930 const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
931 _ = self.display.readEvents();
932 }
933 fn dispatchPendingThunk(ctx: *anyopaque) void {
934 const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
935 _ = self.display.dispatchPending();
936 }
937 fn getFdThunk(ctx: *anyopaque) std.posix.fd_t {
938 const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
939 return self.display.getFd();
940 }
941
942 fn requestFrameThunk(ctx: *anyopaque, loop: *FrameLoop) anyerror!CallbackToken {
943 const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
944 self.loop_ref = loop;
945 const cb = try self.surface.frame();
946 cb.setListener(*FrameLoop, frameCallbackListener, loop);
947 return @ptrCast(cb);
948 }
949
950 fn destroyCallbackThunk(_: *anyopaque, token: CallbackToken) void {
951 const cb: *wl.Callback = @constCast(@ptrCast(@alignCast(token)));
952 cb.destroy();
953 }
954 };
955
956 fn frameCallbackListener(cb: *wl.Callback, event: wl.Callback.Event, loop: *FrameLoop) void {
957 switch (event) {
958 .done => {
959 const tok: CallbackToken = @ptrCast(cb);
960 loop.onFrameCallbackDone(tok);
961 },
962 }
963 }
964 ```
965
966 - [ ] **Step 4: Verify build**
967
968 Run: `make build`
969 Expected: clean compile. No new tests yet — integration is exercised in Task 8+.
970
971 - [ ] **Step 5: Commit**
972
973 ```bash
974 git add src/wayland.zig
975 git commit -m "$(cat <<'EOF'
976 Add real-path DisplayOps adapter on Window
977
978 DisplayAdapter thinly wraps wl.Display + wl.Surface and satisfies the
979 DisplayOps vtable. frameCallbackListener receives wl_callback.done and
980 forwards it to FrameLoop.onFrameCallbackDone, which performs the
981 identity check.
982 EOF
983 )"
984 ```
985
986 ---
987
988 ## Task 8: Migrate main terminal loop to FrameLoop (fixes the freeze)
989
990 **Files:**
991 - Modify: `src/main.zig`
992
993 This is the largest task. It does three things: (1) creates a `FrameLoop` in `runTerminal`, (2) splits the scale/resize handling into observe (non-Vulkan) and apply (Vulkan) halves, (3) gates all Vulkan work on `canRender()`.
994
995 - [ ] **Step 1: Add imports and FrameLoop instantiation**
996
997 At the top of `src/main.zig`, ensure these imports exist (add missing):
998
999 ```zig
1000 const wayland_client = @import("wayland-client");
1001 const frame_loop_mod = @import("frame_loop");
1002 ```
1003
1004 In `runTerminal`, after the `window` creation block and after `conn.display.roundtrip()` (around main.zig:137), add:
1005
1006 ```zig
1007 var frame_loop = frame_loop_mod.FrameLoop.init(
1008 window.displayOps(conn.display),
1009 window.surfaceStateView(),
1010 );
1011 defer frame_loop.deinit();
1012 ```
1013
1014 - [ ] **Step 2: Wire FrameLoop into surface enter/leave listeners**
1015
1016 In `src/wayland.zig`, extend `surfaceListener` (around line 1050) so it can notify a FrameLoop. Add a FrameLoop pointer field on `Window`:
1017
1018 ```zig
1019 frame_loop: ?*FrameLoop = null,
1020 ```
1021
1022 Update `surfaceListener` to call show/hide hooks when visibility transitions:
1023
1024 ```zig
1025 fn surfaceListener(_: *wl.Surface, event: wl.Surface.Event, window: *Window) void {
1026 const was_visible = window.state.visible();
1027 switch (event) {
1028 .enter => |e| {
1029 const wl_out = e.output orelse return;
1030 window.handleSurfaceEnter(wl_out);
1031 window.scale_generation += 1;
1032 },
1033 .leave => |e| {
1034 const wl_out = e.output orelse return;
1035 window.handleSurfaceLeave(wl_out);
1036 window.scale_generation += 1;
1037 },
1038 .preferred_buffer_scale => {},
1039 .preferred_buffer_transform => {},
1040 }
1041 const now_visible = window.state.visible();
1042 if (window.frame_loop) |loop| {
1043 if (was_visible and !now_visible) loop.onSurfaceHidden();
1044 if (!was_visible and now_visible) loop.onSurfaceShown();
1045 }
1046 }
1047 ```
1048
1049 Do the same for `xdgToplevelListener` (so the suspended flag transition fires the hook):
1050
1051 ```zig
1052 fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
1053 const was_visible = window.state.visible();
1054 switch (event) {
1055 .configure => |cfg| {
1056 if (cfg.width > 0) window.width = @intCast(cfg.width);
1057 if (cfg.height > 0) window.height = @intCast(cfg.height);
1058 applyToplevelStates(&window.state, std.mem.sliceAsBytes(cfg.states.slice()));
1059 },
1060 .close => window.should_close = true,
1061 .configure_bounds => {},
1062 .wm_capabilities => {},
1063 }
1064 const now_visible = window.state.visible();
1065 if (window.frame_loop) |loop| {
1066 if (was_visible and !now_visible) loop.onSurfaceHidden();
1067 if (!was_visible and now_visible) loop.onSurfaceShown();
1068 }
1069 }
1070 ```
1071
1072 And for `xdgSurfaceListener` (configured transitions from false→true):
1073
1074 ```zig
1075 fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void {
1076 const was_visible = window.state.visible();
1077 switch (event) {
1078 .configure => |cfg| {
1079 surface.ackConfigure(cfg.serial);
1080 window.state.configured = true;
1081 },
1082 }
1083 const now_visible = window.state.visible();
1084 if (window.frame_loop) |loop| {
1085 if (!was_visible and now_visible) loop.onSurfaceShown();
1086 }
1087 }
1088 ```
1089
1090 Back in `src/main.zig`, right after `frame_loop` init, set:
1091
1092 ```zig
1093 window.frame_loop = &frame_loop;
1094 defer window.frame_loop = null;
1095 ```
1096
1097 - [ ] **Step 3: Split the main loop's scale/resize block**
1098
1099 Locate the main loop in `src/main.zig` (starts around line 246 `while (!window.should_close and p.isChildAlive())`). Identify the two blocks:
1100 - Scale-change block (around line 317-346): `if (current_scale != last_scale) { ... deviceWaitIdle ... recreateSwapchain ... }`
1101 - Size-change block (around line 348-380): `if (window.width != last_window_w or window.height != last_window_h) { ... deviceWaitIdle ... recreateSwapchain ... resize term/pty ... }`
1102
1103 Replace the entire main loop body (from `while (!window.should_close ...)` to the closing `}` around line 596) with the shape below. Keep all the non-Vulkan logic unchanged (pty read, keyboard, pointer, selection, snapshot/render). The key change: Vulkan calls move inside an `if (frame_loop.canRender())` gate.
1104
1105 ```zig
1106 var pollfds_extra = [_]std.posix.pollfd{
1107 .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
1108 };
1109 var read_buf: [8192]u8 = undefined;
1110 var key_buf: [32]u8 = undefined;
1111 var last_window_w = window.width;
1112 var last_window_h = window.height;
1113 var last_scale: i32 = geom.buffer_scale;
1114 var render_pending = true;
1115 var resize_pending = false;
1116 var scale_pending = false;
1117
1118 while (!window.should_close and p.isChildAlive()) {
1119 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
1120 const timeout = computePollTimeoutMs(repeat_timeout_ms, render_pending and frame_loop.canRender());
1121 try frame_loop.waitForWork(&pollfds_extra, timeout);
1122
1123 // PTY output
1124 if (pollfds_extra[0].revents & std.posix.POLL.IN != 0) {
1125 while (true) {
1126 const n = p.read(&read_buf) catch |err| switch (err) {
1127 error.WouldBlock => break,
1128 error.InputOutput => break,
1129 else => return err,
1130 };
1131 if (n == 0) break;
1132 term.write(read_buf[0..n]);
1133 render_pending = true;
1134 }
1135 }
1136
1137 // Pointer events
1138 const ptr_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale));
1139 const ptr_cell_h = cell_h / @as(u32, @intCast(geom.buffer_scale));
1140 const prev_selection = activeSelectionSpan(selection);
1141 for (pointer.event_queue.items) |ev| {
1142 handlePointerSelectionEvent(&selection, ev, ptr_cell_w, ptr_cell_h, cols, rows);
1143 }
1144 const selection_changed = !std.meta.eql(activeSelectionSpan(selection), prev_selection);
1145 if (pointer.event_queue.items.len > 0) {
1146 pointer.event_queue.clearRetainingCapacity();
1147 render_pending = true;
1148 }
1149
1150 // Keyboard events (identical to existing body — paste/copy/encode)
1151 keyboard.tickRepeat();
1152 for (keyboard.event_queue.items) |ev| {
1153 if (ev.action == .release) continue;
1154 if (isClipboardPasteEvent(ev)) {
1155 if (clipboard) |cb| {
1156 if (try cb.receiveSelectionText(alloc)) |text| {
1157 defer alloc.free(text);
1158 const encoded = term.encodePaste(text);
1159 for (encoded) |chunk| {
1160 if (chunk.len == 0) continue;
1161 _ = try p.write(chunk);
1162 }
1163 }
1164 }
1165 continue;
1166 }
1167 if (isClipboardCopyEvent(ev)) {
1168 _ = try copySelectionText(alloc, clipboard, term, activeSelectionSpan(selection), ev.serial);
1169 continue;
1170 }
1171 if (ev.utf8_len > 0) {
1172 _ = try p.write(ev.utf8[0..ev.utf8_len]);
1173 } else if (try encodeKeyboardEvent(term, ev, &key_buf)) |encoded| {
1174 _ = try p.write(encoded);
1175 }
1176 }
1177 keyboard.event_queue.clearRetainingCapacity();
1178
1179 // observeResize — detect changes, record pending flag, no Vulkan.
1180 const current_scale = window.bufferScale();
1181 if (current_scale != last_scale) {
1182 scale_pending = true;
1183 render_pending = true;
1184 }
1185 if (window.width != last_window_w or window.height != last_window_h) {
1186 resize_pending = true;
1187 render_pending = true;
1188 }
1189
1190 if (!shouldRenderFrame(render_pending, false, false)) continue;
1191 if (!frame_loop.canRender()) continue; // hidden — no Vulkan at all
1192
1193 // applyPendingResize / applyPendingScale — Vulkan work, gated.
1194 if (scale_pending) {
1195 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1196 geom = try rebuildFaceForScale(
1197 &face,
1198 &atlas,
1199 font_lookup.path,
1200 font_lookup.index,
1201 font_size,
1202 window.bufferScale(),
1203 );
1204 cell_w = geom.cell_w_px;
1205 cell_h = geom.cell_h_px;
1206 baseline = geom.baseline_px;
1207 render_cache.invalidateAfterResize();
1208 term.render_state.dirty = .full;
1209 window.surface.setBufferScale(geom.buffer_scale);
1210 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
1211 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
1212 try ctx.recreateSwapchain(buf_w, buf_h);
1213 last_scale = geom.buffer_scale;
1214 scale_pending = false;
1215 }
1216 if (resize_pending) {
1217 const surf_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale));
1218 const surf_cell_h = cell_h / @as(u32, @intCast(geom.buffer_scale));
1219 const new_grid = gridSizeForWindow(window.width, window.height, surf_cell_w, surf_cell_h);
1220 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
1221 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
1222 if (new_grid.cols != cols or new_grid.rows != rows) {
1223 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1224 try ctx.recreateSwapchain(buf_w, buf_h);
1225 try term.resize(new_grid.cols, new_grid.rows);
1226 try p.resize(new_grid.cols, new_grid.rows);
1227 cols = new_grid.cols;
1228 rows = new_grid.rows;
1229 term.setReportedSize(.{
1230 .rows = rows,
1231 .columns = cols,
1232 .cell_width = cell_w,
1233 .cell_height = cell_h,
1234 });
1235 selection.committed = if (selection.committed) |span| clampSelectionSpan(span, cols, rows) else null;
1236 selection.active = if (selection.active) |span| clampSelectionSpan(span, cols, rows) else null;
1237 selection.anchor = if (selection.anchor) |point| clampGridPoint(point, cols, rows) else null;
1238 selection.hover = if (selection.hover) |point| clampGridPoint(point, cols, rows) else null;
1239 } else {
1240 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1241 try ctx.recreateSwapchain(buf_w, buf_h);
1242 }
1243 last_window_w = window.width;
1244 last_window_h = window.height;
1245 resize_pending = false;
1246 }
1247
1248 // === render === (identical to existing body from snapshot through clearConsumedDirtyFlags)
1249 var frame_timing: FrameTiming = .{};
1250 const previous_cursor = term.render_state.cursor;
1251 var section_timer = std.time.Timer.start() catch unreachable;
1252 try term.snapshot();
1253 frame_timing.snapshot_us = usFromTimer(&section_timer);
1254
1255 section_timer = std.time.Timer.start() catch unreachable;
1256 const default_bg = term.backgroundColor();
1257 const bg_uv = atlas.cursorUV();
1258 const term_rows = term.render_state.row_data.items(.cells);
1259 const dirty_rows = term.render_state.row_data.items(.dirty);
1260 try render_cache.resizeRows(alloc, term_rows.len);
1261 const refresh_plan = planRowRefresh(
1262 if (term.render_state.dirty == .full or selection_changed) .full else .partial,
1263 dirty_rows,
1264 .{
1265 .cursor = .{
1266 .old_row = if (previous_cursor.viewport) |cursor| @intCast(cursor.y) else null,
1267 .new_row = if (term.render_state.cursor.viewport) |cursor| @intCast(cursor.y) else null,
1268 .old_col = if (previous_cursor.viewport) |cursor| @intCast(cursor.x) else null,
1269 .new_col = if (term.render_state.cursor.viewport) |cursor| @intCast(cursor.x) else null,
1270 .old_visible = previous_cursor.visible,
1271 .new_visible = term.render_state.cursor.visible,
1272 },
1273 },
1274 );
1275
1276 // PRESERVE the existing render body from line ~415 ("var rows_rebuilt")
1277 // through line ~584 ("frame_timing.gpu_submit_us = ..."). Those lines
1278 // are unchanged — copy them verbatim from the pre-refactor main loop.
1279 // The only change is the OUT_OF_DATE handler at the end of drawCells:
1280
1281 // ctx.drawCells(...) catch |err| switch (err) {
1282 // error.OutOfDateKHR => {
1283 // _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1284 // const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
1285 // const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
1286 // try ctx.recreateSwapchain(buf_w, buf_h);
1287 // frame_loop.forceArm(); // <-- ADDED
1288 // render_pending = true;
1289 // continue;
1290 // },
1291 // else => return err,
1292 // };
1293
1294 frame_ring.push(frame_timing);
1295
1296 if (sigusr1_received.swap(false, .acq_rel)) {
1297 printFrameStats(computeFrameStats(&frame_ring));
1298 }
1299
1300 clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);
1301
1302 // Commit is already inside ctx.drawCells via queuePresentKHR. The
1303 // wl_surface.commit that backs it happens inside Vulkan WSI. We
1304 // explicitly request the next frame callback here.
1305 try frame_loop.commitRender();
1306 render_pending = false;
1307 }
1308
1309 printFrameStats(computeFrameStats(&frame_ring));
1310 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1311 ```
1312
1313 **Important:** The rendering body (rows_rebuilt loop, atlas upload, instance upload, drawCells, frame_timing updates) between `planRowRefresh(...)` and `frame_ring.push(frame_timing)` is unchanged — copy it verbatim from the pre-refactor version. Only the OUT_OF_DATE switch arm gains `frame_loop.forceArm();`.
1314
1315 - [ ] **Step 4: Build**
1316
1317 Run: `make build`
1318 Expected: clean compile.
1319
1320 - [ ] **Step 5: Run tests**
1321
1322 Run: `make test`
1323 Expected: all existing + new tests pass.
1324
1325 - [ ] **Step 6: Manual smoke test — launch waystty, verify normal operation**
1326
1327 Run: `make run`
1328 Expected: waystty window opens, prompt appears, typing echoes, Ctrl-D exits cleanly.
1329
1330 - [ ] **Step 7: Manual freeze regression test — the reason for this whole change**
1331
1332 Run: `make run`
1333 Then in sway:
1334 1. Start typing in waystty.
1335 2. Switch workspaces (mod+2 or equivalent).
1336 3. Wait 10 seconds.
1337 4. Switch back.
1338
1339 Expected: waystty is responsive on return; no hang, no stale display.
1340
1341 - [ ] **Step 8: Commit**
1342
1343 ```bash
1344 git add src/main.zig src/wayland.zig
1345 git commit -m "$(cat <<'EOF'
1346 Migrate main terminal loop to FrameLoop; fix hidden-workspace freeze
1347
1348 Splits the scale/resize handler into observeResize (non-Vulkan, always
1349 runs) and applyPendingResize/Scale (Vulkan, gated on canRender). All
1350 Vulkan calls — deviceWaitIdle, recreateSwapchain, rebuildFaceForScale,
1351 drawCells — now happen only when the surface is visible. OUT_OF_DATE
1352 path calls forceArm() to retry without a callback.
1353
1354 Fixes: waystty hanging when its window is moved to a hidden sway
1355 workspace.
1356 EOF
1357 )"
1358 ```
1359
1360 ---
1361
1362 ## Task 9: Synthetic hidden-freeze regression test
1363
1364 **Files:**
1365 - Modify: `src/frame_loop.zig`
1366
1367 - [ ] **Step 1: Add regression test**
1368
1369 Append to `src/frame_loop.zig`:
1370
1371 ```zig
1372 test "hidden-freeze regression: pty flood while hidden never blocks" {
1373 var mock = try Mock.init();
1374 defer mock.deinit();
1375 var ts = try TestState.init(std.testing.allocator);
1376 defer ts.deinit();
1377
1378 var loop = FrameLoop.init(mock.ops(), ts.view());
1379 defer loop.deinit();
1380
1381 // Initial render.
1382 try loop.commitRender();
1383 const first_tok = loop.pending_token.?;
1384 loop.onFrameCallbackDone(first_tok);
1385
1386 // Hide the surface: tracker leaves all outputs.
1387 ts.tracker.leaveOutput(1);
1388 loop.onSurfaceHidden();
1389 try std.testing.expect(!loop.canRender());
1390
1391 // Simulate 100 iterations of "pty wrote more data, we want to render".
1392 // Under the gate, canRender is false every iteration and we never call
1393 // commitRender — so no Vulkan work, no blocking.
1394 var i: usize = 0;
1395 while (i < 100) : (i += 1) {
1396 try std.testing.expect(!loop.canRender());
1397 }
1398
1399 // Show again: tracker re-enters an output.
1400 try ts.tracker.enterOutput(1);
1401 loop.onSurfaceShown();
1402
1403 try std.testing.expect(loop.canRender());
1404
1405 // Normal render resumes.
1406 try loop.commitRender();
1407 try std.testing.expect(loop.pending_token != null);
1408 const tok = loop.pending_token.?;
1409 loop.onFrameCallbackDone(tok);
1410 try std.testing.expect(loop.armed);
1411 }
1412 ```
1413
1414 - [ ] **Step 2: Run tests**
1415
1416 Run: `make test`
1417 Expected: regression test passes alongside the existing FrameLoop tests.
1418
1419 - [ ] **Step 3: Commit**
1420
1421 ```bash
1422 git add src/frame_loop.zig
1423 git commit -m "$(cat <<'EOF'
1424 Add synthetic hidden-freeze regression test
1425
1426 Exercises the hide → pty-flood → show sequence through the FrameLoop
1427 state machine. Verifies no canRender() call returns true while hidden,
1428 and that normal pacing resumes on show.
1429 EOF
1430 )"
1431 ```
1432
1433 ---
1434
1435 ## Task 10: Migrate `runTextCoverageCompare` to FrameLoop
1436
1437 **Files:**
1438 - Modify: `src/main.zig`
1439
1440 - [ ] **Step 1: Replace the text-compare main loop body**
1441
1442 In `src/main.zig`, locate `runTextCoverageCompare` (around line 2490). Replace its main loop block (the `while (!window.should_close) { ... }` around lines 2549-2623) with the FrameLoop-based version:
1443
1444 ```zig
1445 var frame_loop = frame_loop_mod.FrameLoop.init(
1446 window.displayOps(conn.display),
1447 window.surfaceStateView(),
1448 );
1449 defer frame_loop.deinit();
1450 window.frame_loop = &frame_loop;
1451 defer window.frame_loop = null;
1452
1453 var last_window_w = window.width;
1454 var last_window_h = window.height;
1455 var last_scale: i32 = geom.buffer_scale;
1456
1457 while (!window.should_close) {
1458 try frame_loop.waitForWork(&.{}, 16);
1459
1460 if (!frame_loop.canRender()) continue;
1461
1462 const current_scale = window.bufferScale();
1463 const scale_changed = current_scale != last_scale;
1464 const size_changed = window.width != last_window_w or window.height != last_window_h;
1465
1466 if (scale_changed or size_changed) {
1467 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1468
1469 if (scale_changed) {
1470 geom = try rebuildFaceForScale(
1471 &face,
1472 &atlas,
1473 font_lookup.path,
1474 font_lookup.index,
1475 config.font_size_px,
1476 current_scale,
1477 );
1478 scene.deinit(alloc);
1479 scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
1480 window.surface.setBufferScale(geom.buffer_scale);
1481 try ctx.uploadAtlas(atlas.pixels);
1482 atlas.dirty = false;
1483 try ctx.uploadInstances(scene.instances.items);
1484 last_scale = current_scale;
1485 }
1486
1487 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
1488 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
1489 try ctx.recreateSwapchain(buf_w, buf_h);
1490 last_window_w = window.width;
1491 last_window_h = window.height;
1492 }
1493
1494 drawTextCoverageCompareFrame(
1495 &ctx,
1496 &scene,
1497 geom.cell_w_px,
1498 geom.cell_h_px,
1499 .{ 0.0, 0.0, 0.0, 1.0 },
1500 ) catch |err| switch (err) {
1501 error.OutOfDateKHR => {
1502 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1503 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
1504 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
1505 try ctx.recreateSwapchain(buf_w, buf_h);
1506 last_window_w = window.width;
1507 last_window_h = window.height;
1508 frame_loop.forceArm();
1509 continue;
1510 },
1511 else => return err,
1512 };
1513
1514 try frame_loop.commitRender();
1515 }
1516
1517 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1518 ```
1519
1520 - [ ] **Step 2: Build and run the mode to verify**
1521
1522 Run: `make build`
1523 Expected: clean compile.
1524
1525 Run: `./zig-out/bin/waystty --text-compare`
1526 Expected: the text-coverage comparison window opens and renders normally.
1527
1528 - [ ] **Step 3: Commit**
1529
1530 ```bash
1531 git add src/main.zig
1532 git commit -m "$(cat <<'EOF'
1533 Migrate runTextCoverageCompare to FrameLoop
1534
1535 Drops the manual 16ms sleep and prepareRead/cancelRead dance in favor
1536 of the shared readiness primitive. Same visual output; now also
1537 freeze-safe under workspace change.
1538 EOF
1539 )"
1540 ```
1541
1542 ---
1543
1544 ## Task 11: Migrate `runDrawSmokeTest` to FrameLoop
1545
1546 **Files:**
1547 - Modify: `src/main.zig`
1548
1549 - [ ] **Step 1: Replace the draw-smoke main loop**
1550
1551 In `src/main.zig`, locate `runDrawSmokeTest` (around line 2628). Replace the `var i: u32 = 0; while (i < 900) : (i += 1) { ... }` block with a FrameLoop-paced version that still runs for ~15 seconds.
1552
1553 Before the loop:
1554
1555 ```zig
1556 var frame_loop = frame_loop_mod.FrameLoop.init(
1557 window.displayOps(conn.display),
1558 window.surfaceStateView(),
1559 );
1560 defer frame_loop.deinit();
1561 window.frame_loop = &frame_loop;
1562 defer window.frame_loop = null;
1563 ```
1564
1565 Then the loop body becomes:
1566
1567 ```zig
1568 const deadline_ns = @as(i128, std.time.nanoTimestamp()) + 15 * std.time.ns_per_s;
1569 while (std.time.nanoTimestamp() < deadline_ns and !window.should_close) {
1570 try frame_loop.waitForWork(&.{}, 16);
1571 if (!frame_loop.canRender()) continue;
1572
1573 const baseline_coverage = renderer.coverageVariantParams(.baseline);
1574 ctx.drawCells(1, .{ cell_w, cell_h }, .{ 0.0, 0.0, 0.0, 1.0 }, baseline_coverage) catch |err| switch (err) {
1575 error.OutOfDateKHR => {
1576 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1577 try ctx.recreateSwapchain(window.width, window.height);
1578 frame_loop.forceArm();
1579 continue;
1580 },
1581 else => return err,
1582 };
1583 try frame_loop.commitRender();
1584 }
1585 ```
1586
1587 - [ ] **Step 2: Build and run**
1588
1589 Run: `make build`
1590 Expected: clean compile.
1591
1592 Run: `./zig-out/bin/waystty --draw-smoke-test`
1593 Expected: renders the 'M' glyph for ~15 seconds, then exits.
1594
1595 - [ ] **Step 3: Commit**
1596
1597 ```bash
1598 git add src/main.zig
1599 git commit -m "$(cat <<'EOF'
1600 Migrate runDrawSmokeTest to FrameLoop
1601
1602 Replaces the fixed 900-iteration + manual event-pump pattern with
1603 FrameLoop pacing. Uses a 15-second wall-clock deadline instead of a
1604 frame count.
1605 EOF
1606 )"
1607 ```
1608
1609 ---
1610
1611 ## Task 12: Add `WAYSTTY_BENCH_UNTHROTTLED` env var for bench mode
1612
1613 **Files:**
1614 - Modify: `src/main.zig`
1615
1616 - [ ] **Step 1: Read env var at bench setup**
1617
1618 In `src/main.zig`, inside `runTerminal`, near where `WAYSTTY_BENCH` is read (around line 201), add:
1619
1620 ```zig
1621 const bench_unthrottled = std.posix.getenv("WAYSTTY_BENCH_UNTHROTTLED") != null
1622 and std.posix.getenv("WAYSTTY_BENCH") != null;
1623 ```
1624
1625 - [ ] **Step 2: Gate FrameLoop.canRender in the main loop**
1626
1627 In the main loop, change the visibility gate line:
1628
1629 ```zig
1630 if (!frame_loop.canRender()) continue; // hidden — no Vulkan at all
1631 ```
1632
1633 to:
1634
1635 ```zig
1636 if (!bench_unthrottled and !frame_loop.canRender()) continue;
1637 ```
1638
1639 And change the `commitRender` call near the end of the loop to:
1640
1641 ```zig
1642 if (!bench_unthrottled) try frame_loop.commitRender();
1643 ```
1644
1645 (When unthrottled, we never gate and never request frame callbacks — effectively the pre-refactor eager loop. Freeze-safety is forfeit, which is documented.)
1646
1647 - [ ] **Step 3: Emit a banner on bench startup**
1648
1649 Near the top of `runTerminal`, after reading `bench_unthrottled`, add:
1650
1651 ```zig
1652 if (std.posix.getenv("WAYSTTY_BENCH") != null) {
1653 if (bench_unthrottled) {
1654 std.debug.print("[bench] mode: UNTHROTTLED (not freeze-safe)\n", .{});
1655 } else {
1656 std.debug.print("[bench] mode: THROTTLED (vsync-paced)\n", .{});
1657 }
1658 }
1659 ```
1660
1661 - [ ] **Step 4: Run bench twice to verify both paths work**
1662
1663 Run: `make bench`
1664 Expected: bench runs, prints `[bench] mode: THROTTLED`, produces frame timing output.
1665
1666 Run: `WAYSTTY_BENCH=1 WAYSTTY_BENCH_UNTHROTTLED=1 ./zig-out/bin/waystty 2>bench.log || true; grep -A 12 "waystty frame timing" bench.log`
1667 Expected: bench runs, prints `[bench] mode: UNTHROTTLED`, produces frame timing output (typically more frames/sec than throttled).
1668
1669 - [ ] **Step 5: Commit**
1670
1671 ```bash
1672 git add src/main.zig
1673 git commit -m "$(cat <<'EOF'
1674 Add WAYSTTY_BENCH_UNTHROTTLED escape hatch
1675
1676 Bypasses FrameLoop gating and callback requests in bench mode for raw
1677 throughput measurement. Explicitly not freeze-safe — documented in the
1678 spec. Emits a banner on startup so bench logs are unambiguous.
1679 EOF
1680 )"
1681 ```
1682
1683 ---
1684
1685 ## Task 13: Add `--hidden-freeze-regression` manual mode
1686
1687 **Files:**
1688 - Modify: `src/main.zig`
1689
1690 - [ ] **Step 1: Register the flag and function**
1691
1692 In `src/main.zig` `pub fn main`, add after the existing mode dispatches (around line 91):
1693
1694 ```zig
1695 if (args.len >= 2 and std.mem.eql(u8, args[1], "--hidden-freeze-regression")) {
1696 return runHiddenFreezeRegression(alloc);
1697 }
1698 ```
1699
1700 - [ ] **Step 2: Implement the mode**
1701
1702 Add at the bottom of `src/main.zig`:
1703
1704 ```zig
1705 fn runHiddenFreezeRegression(alloc: std.mem.Allocator) !void {
1706 const stdout = std.io.getStdOut().writer();
1707 try stdout.writeAll(
1708 \\
1709 \\hidden-freeze regression mode
1710 \\-----------------------------
1711 \\1. This process will spawn waystty and start writing to its pty.
1712 \\2. Move the window to a different workspace.
1713 \\3. Wait 5 seconds.
1714 \\4. Move the window back.
1715 \\5. The window should be responsive and show fresh output.
1716 \\
1717 \\Press enter to start, Ctrl-C to abort.
1718 \\
1719 );
1720 var buf: [16]u8 = undefined;
1721 _ = try std.io.getStdIn().reader().read(&buf);
1722
1723 // Reuse runTerminal — it is the code path we're validating. The regression
1724 // is whether it freezes; manual observation by the operator confirms.
1725 return runTerminal(alloc);
1726 }
1727 ```
1728
1729 - [ ] **Step 3: Build and smoke-test**
1730
1731 Run: `make build && ./zig-out/bin/waystty --hidden-freeze-regression`
1732 Expected: help text prints; on enter, waystty window opens normally. Follow the instructions manually under sway to confirm.
1733
1734 - [ ] **Step 4: Commit**
1735
1736 ```bash
1737 git add src/main.zig
1738 git commit -m "$(cat <<'EOF'
1739 Add --hidden-freeze-regression manual test mode
1740
1741 Prints operator instructions and spawns a normal waystty session. The
1742 freeze fix is validated by moving the window to another workspace and
1743 back; the mode is purely for documentation and repeatable manual QA.
1744 EOF
1745 )"
1746 ```
1747
1748 ---
1749
1750 ## Task 14: Final verification pass
1751
1752 - [ ] **Step 1: Full test suite**
1753
1754 Run: `make test`
1755 Expected: all tests pass (pty, scale_tracker, wayland, main, vt, font, renderer, frame_loop).
1756
1757 - [ ] **Step 2: Clean build**
1758
1759 Run: `make clean && make build`
1760 Expected: clean compile from scratch.
1761
1762 - [ ] **Step 3: Manual multi-workspace verification**
1763
1764 Run: `make run`
1765 Then under sway:
1766 1. Type in waystty.
1767 2. Switch to another workspace (mod+2).
1768 3. Wait 30 seconds.
1769 4. Switch back (mod+1).
1770 5. Confirm: responsive, fresh prompt state.
1771 6. Resize the window.
1772 7. Move to another workspace mid-resize, switch back.
1773 8. Confirm: correct size, no crash.
1774
1775 - [ ] **Step 4: Bench smoke**
1776
1777 Run: `make bench`
1778 Expected: bench runs to completion, throttled-mode banner, timing output printed.
1779
1780 - [ ] **Step 5: If all green, push**
1781
1782 Ask the user: "All steps green. Want me to push to origin?" Wait for explicit yes before pushing.
1783
1784 ---
1785
1786 ## Notes for the implementer
1787
1788 - **Zig idioms.** `catch |err| switch (err) { ... }` is the idiom for handling specific errors. `_ = try foo()` discards non-error results. `std.debug.assert` in Zig is compiled out in ReleaseFast — use it for preconditions, not correctness.
1789 - **zig-wayland bindings.** Method signatures on `*wl.Display`, `*wl.Surface`, `*wl.Callback` are what the scanner generates. If a name in this plan doesn't match (e.g. `setListener` signature), read `zig-cache/.../wayland.zig` (the generated bindings) and adapt.
1790 - **Listeners run synchronously.** Inside `dispatchPending`, every listener call is synchronous on the main thread. No locks needed anywhere in FrameLoop.
1791 - **Commit hygiene.** Each task ends with a commit. Do not squash; each commit should compile and pass tests independently so bisect works.
1792 - **If a task fails to compile.** Do not amend the prior commit. Add the fix as a new commit so history shows the trial.
1793 - **If tests pass but the freeze still reproduces.** Return to Task 8 Step 7 — something in the gate isn't covering a Vulkan call. Use `RUST_BACKTRACE=full` equivalent (`ZIG_DEBUG` or attach gdb `thread apply all bt`) during freeze to find the stuck frame.