a73x

docs/superpowers/plans/2026-04-16-frame-callback-throttling-implementation.md

Ref:   Size: 59.2 KiB

# Frame-Callback Throttling Implementation Plan

> **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.

**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.

**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.

**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.

**Spec:** `docs/superpowers/specs/2026-04-16-frame-callback-throttling-design.md`

---

## File Structure

**New files:**
- `src/frame_loop.zig` — FrameLoop + DisplayOps trait + MockDisplayOps for tests (~250 LOC)

**Modified files:**
- `src/scale_tracker.zig` — add `enteredCount()` (~10 LOC)
- `src/wayland.zig` — add `SurfaceState`, bump wm_base bind v5→v6, handle xdg_toplevel `.suspended`, wire FrameLoop transitions into surfaceListener (~120 LOC delta)
- `src/main.zig` — four loop sites migrated; split resize handling into observeResize/applyPendingResize (~600 LOC net reduction)
- `build.zig` — wire `frame_loop` module and its test step (~30 LOC)

**Unchanged:** `src/renderer.zig`, `src/vt.zig`, `src/pty.zig`, `src/font.zig`, `src/config.zig`, shaders.

---

## Task 1: Add `enteredCount()` to ScaleTracker

**Files:**
- Modify: `src/scale_tracker.zig`

- [ ] **Step 1: Write failing test**

Append to the existing test block in `src/scale_tracker.zig`:

```zig
test "enteredCount reflects the entered-output set" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try std.testing.expectEqual(@as(usize, 0), t.enteredCount());

    try t.addOutput(1);
    try t.addOutput(2);
    try std.testing.expectEqual(@as(usize, 0), t.enteredCount());

    try t.enterOutput(1);
    try std.testing.expectEqual(@as(usize, 1), t.enteredCount());

    try t.enterOutput(2);
    try std.testing.expectEqual(@as(usize, 2), t.enteredCount());

    t.leaveOutput(1);
    try std.testing.expectEqual(@as(usize, 1), t.enteredCount());

    t.removeOutput(2);
    try std.testing.expectEqual(@as(usize, 0), t.enteredCount());
}
```

- [ ] **Step 2: Run to verify failure**

Run: `make test`
Expected: compile error — `enteredCount` not defined on `ScaleTracker`.

- [ ] **Step 3: Implement**

Insert in `src/scale_tracker.zig` after the existing `bufferScale` method (around line 54):

```zig
    pub fn enteredCount(self: *const ScaleTracker) usize {
        return self.entered.count();
    }
```

- [ ] **Step 4: Run to verify pass**

Run: `make test`
Expected: all tests pass, including the new `enteredCount reflects the entered-output set`.

- [ ] **Step 5: Commit**

```bash
git add src/scale_tracker.zig
git commit -m "$(cat <<'EOF'
Add ScaleTracker.enteredCount

Exposes the size of the entered-output set so higher layers can gate
rendering on surface visibility.
EOF
)"
```

---

## Task 2: Add `SurfaceState` struct

**Files:**
- Modify: `src/wayland.zig`

- [ ] **Step 1: Write failing test**

Append to the tests at the bottom of `src/wayland.zig`:

```zig
test "SurfaceState.visible requires configured && !suspended && enteredCount > 0" {
    var tracker = ScaleTracker.init(std.testing.allocator);
    defer tracker.deinit();
    try tracker.addOutput(1);

    var state = SurfaceState{ .tracker = &tracker };

    // Unconfigured → not visible
    try std.testing.expect(!state.visible());

    state.configured = true;
    // No entered outputs yet → not visible
    try std.testing.expect(!state.visible());

    try tracker.enterOutput(1);
    // Configured + entered + not suspended → visible
    try std.testing.expect(state.visible());

    state.suspended = true;
    try std.testing.expect(!state.visible());

    state.suspended = false;
    tracker.leaveOutput(1);
    try std.testing.expect(!state.visible());
}
```

- [ ] **Step 2: Run to verify failure**

Run: `make test`
Expected: compile error — `SurfaceState` undefined.

- [ ] **Step 3: Implement**

Add near the top of `src/wayland.zig` (after imports, before `pub const Connection`):

```zig
pub const SurfaceState = struct {
    configured: bool = false,
    suspended: bool = false,
    tracker: *ScaleTracker,

    pub fn visible(self: *const SurfaceState) bool {
        return self.configured
            and !self.suspended
            and self.tracker.enteredCount() > 0;
    }
};
```

- [ ] **Step 4: Run to verify pass**

Run: `make test`
Expected: all tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/wayland.zig
git commit -m "$(cat <<'EOF'
Add SurfaceState struct for visibility tracking

Single source of truth for whether the surface is currently mapped,
configured, and not suspended. Not yet wired into Window — that happens
in a follow-up.
EOF
)"
```

---

## Task 3: Wire SurfaceState into Window

**Files:**
- Modify: `src/wayland.zig`

- [ ] **Step 1: Add SurfaceState to Window**

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:

```zig
    state: SurfaceState,
```

- [ ] **Step 2: Update Window initialization**

In `createWindow` (around wayland.zig:658), after setting up the window fields but before returning, initialize `state`:

```zig
    window.* = .{
        .alloc = alloc,
        .surface = try compositor.createSurface(),
        .xdg_surface = undefined,
        .xdg_toplevel = undefined,
        .tracker = &self.scale_tracker,
        .outputs = &self.outputs,
        .state = .{ .tracker = &self.scale_tracker },
        // ... preserve other existing fields (width, height, etc.)
    };
```

(Keep all other existing fields. Just add `.state = .{ .tracker = &self.scale_tracker }`.)

- [ ] **Step 3: Update xdgSurfaceListener to set configured**

In `src/wayland.zig`, locate `fn xdgSurfaceListener` (around line 1041) and update:

```zig
fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void {
    switch (event) {
        .configure => |cfg| {
            surface.ackConfigure(cfg.serial);
            window.state.configured = true;
        },
    }
}
```

(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`.)

- [ ] **Step 4: Remove or redirect any remaining `window.configured` references**

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.

In `src/main.zig`, search for any `window.configured` references and update similarly. Run: `grep -n "window\.configured" src/main.zig`.

- [ ] **Step 5: Run tests + build**

Run: `make test && make build`
Expected: all tests pass, main binary compiles.

- [ ] **Step 6: Commit**

```bash
git add src/wayland.zig src/main.zig
git commit -m "$(cat <<'EOF'
Wire SurfaceState into Window

Replaces the bare Window.configured flag with a SurfaceState embedded
on the Window. Visibility queries now go through state.visible(), which
is a no-op change today but will gate rendering once FrameLoop lands.
EOF
)"
```

---

## Task 4: Bump wm_base to v6 and handle `xdg_toplevel.configure.states.suspended`

**Files:**
- Modify: `src/wayland.zig`

- [ ] **Step 1: Write failing test**

Append to the tests at the bottom of `src/wayland.zig`:

```zig
test "SurfaceState.suspended toggles from xdg_toplevel.configure.states" {
    var tracker = ScaleTracker.init(std.testing.allocator);
    defer tracker.deinit();
    try tracker.addOutput(1);
    try tracker.enterOutput(1);

    var state = SurfaceState{ .tracker = &tracker, .configured = true };
    try std.testing.expect(state.visible());

    // Helper function under test — applies xdg_toplevel state array to SurfaceState.
    const states_suspended = [_]u32{@intFromEnum(xdg.Toplevel.State.suspended)};
    applyToplevelStates(&state, std.mem.sliceAsBytes(&states_suspended));
    try std.testing.expect(state.suspended);
    try std.testing.expect(!state.visible());

    const states_none = [_]u32{};
    applyToplevelStates(&state, std.mem.sliceAsBytes(&states_none));
    try std.testing.expect(!state.suspended);
    try std.testing.expect(state.visible());
}
```

- [ ] **Step 2: Run to verify failure**

Run: `make test`
Expected: compile error — `applyToplevelStates` undefined.

- [ ] **Step 3: Bump wm_base bind version**

In `src/wayland.zig` at line 1092, change:

```zig
            } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) {
                conn.globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return;
```

to:

```zig
            } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) {
                conn.globals.wm_base = registry.bind(g.name, xdg.WmBase, 6) catch return;
```

(The scanner already generates v6 bindings per build.zig:31. Only the runtime bind version changes.)

- [ ] **Step 4: Implement `applyToplevelStates`**

Add to `src/wayland.zig` (near `xdgToplevelListener`, around line 1067):

```zig
fn applyToplevelStates(state: *SurfaceState, state_bytes: []const u8) void {
    // xdg_toplevel.configure delivers the state array as a wl_array of u32.
    // Re-interpret to u32 slice and scan for `.suspended`.
    const u32_count = state_bytes.len / @sizeOf(u32);
    const states = std.mem.bytesAsSlice(u32, state_bytes[0 .. u32_count * @sizeOf(u32)]);
    var suspended = false;
    for (states) |raw| {
        if (raw == @intFromEnum(xdg.Toplevel.State.suspended)) {
            suspended = true;
            break;
        }
    }
    state.suspended = suspended;
}
```

- [ ] **Step 5: Wire into xdgToplevelListener**

Update `xdgToplevelListener` (around wayland.zig:1067):

```zig
fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
    switch (event) {
        .configure => |cfg| {
            if (cfg.width > 0) window.width = @intCast(cfg.width);
            if (cfg.height > 0) window.height = @intCast(cfg.height);
            applyToplevelStates(&window.state, std.mem.sliceAsBytes(cfg.states.slice()));
        },
        .close => window.should_close = true,
        .configure_bounds => {},
        .wm_capabilities => {},
    }
}
```

(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.)

- [ ] **Step 6: Run tests**

Run: `make test`
Expected: all tests pass, including `SurfaceState.suspended toggles from xdg_toplevel.configure.states`.

- [ ] **Step 7: Build**

Run: `make build`
Expected: clean compile. Older sway versions simply never send `.suspended` — backwards-compatible.

- [ ] **Step 8: Commit**

```bash
git add src/wayland.zig
git commit -m "$(cat <<'EOF'
Bump xdg_wm_base to v6 and honor toplevel suspended state

The scanner already generates v6 bindings (build.zig:31); this commit
bumps the runtime bind version and hooks xdg_toplevel.configure.states
into SurfaceState.suspended. Compositors that don't send .suspended
(pre-v6 impls) see no behavior change.
EOF
)"
```

---

## Task 5: Create `DisplayOps` trait and real-world shim

**Files:**
- Create: `src/frame_loop.zig`
- Modify: `build.zig`

- [ ] **Step 1: Wire new module into build.zig**

In `build.zig`, after the `scale_tracker_mod` creation (around line 16), add:

```zig
    const frame_loop_mod = b.createModule(.{
        .root_source_file = b.path("src/frame_loop.zig"),
        .target = target,
        .optimize = optimize,
    });
    frame_loop_mod.addImport("wayland", wayland_generated_mod);
    frame_loop_mod.addImport("scale_tracker", scale_tracker_mod);
```

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);`:

```zig
    wayland_mod.addImport("frame_loop", frame_loop_mod);
```

And make `frame_loop` importable by main:

```zig
    exe_mod.addImport("frame_loop", frame_loop_mod);
```

Then add a test step for it. After the `scale_tracker_tests` block (around line 121):

```zig
    // Test frame_loop.zig
    const frame_loop_test_mod = b.createModule(.{
        .root_source_file = b.path("src/frame_loop.zig"),
        .target = target,
        .optimize = optimize,
    });
    frame_loop_test_mod.addImport("wayland", wayland_generated_mod);
    frame_loop_test_mod.addImport("scale_tracker", scale_tracker_mod);
    const frame_loop_tests = b.addTest(.{
        .root_module = frame_loop_test_mod,
    });
    test_step.dependOn(&b.addRunArtifact(frame_loop_tests).step);
```

- [ ] **Step 2: Create initial `src/frame_loop.zig` with the DisplayOps trait**

Create `src/frame_loop.zig`:

```zig
const std = @import("std");
const wl = @import("wayland").client.wl;
const scale_tracker = @import("scale_tracker");

// Mirror of wayland.SurfaceState. Using a structural duplicate here instead of
// importing wayland.zig avoids a circular module dependency (wayland depends on
// frame_loop). The real wayland.SurfaceState embeds one of these by reference.
pub const SurfaceStateView = struct {
    configured_ptr: *const bool,
    suspended_ptr: *const bool,
    tracker: *const scale_tracker.ScaleTracker,

    pub fn visible(self: SurfaceStateView) bool {
        return self.configured_ptr.*
            and !self.suspended_ptr.*
            and self.tracker.enteredCount() > 0;
    }
};

// Opaque handle to a frame callback. Real path holds a *wl.Callback cast here;
// mock holds a usize cast here. Identity is pointer equality.
pub const CallbackToken = *const anyopaque;

pub const DisplayOps = struct {
    ctx: *anyopaque,

    // All fn pointers receive the same ctx so the caller can carry whatever
    // concrete objects it needs (wl.Display, wl.Surface, test mock, etc).
    flushFn: *const fn (*anyopaque) void,
    prepareReadFn: *const fn (*anyopaque) bool,
    readEventsFn: *const fn (*anyopaque) void,
    dispatchPendingFn: *const fn (*anyopaque) void,
    getFdFn: *const fn (*anyopaque) std.posix.fd_t,

    // Requests a wl_surface.frame() and sets its done listener. Returns the
    // token identifying the new callback. FrameLoop compares the token
    // delivered by onFrameCallbackDone against pending_token.
    requestFrameFn: *const fn (*anyopaque, *FrameLoop) anyerror!CallbackToken,

    // Destroys a previously issued frame callback (for hide-path cleanup).
    // Must tolerate being called on a token the compositor has already
    // consumed — real path: wl.Callback.destroy is idempotent on the client.
    destroyCallbackFn: *const fn (*anyopaque, CallbackToken) void,
};

pub const FrameLoop = struct {
    ops: DisplayOps,
    state: SurfaceStateView,

    pending_token: ?CallbackToken = null,
    armed: bool = true,

    pub fn init(ops: DisplayOps, state: SurfaceStateView) FrameLoop {
        return .{ .ops = ops, .state = state };
    }

    pub fn deinit(self: *FrameLoop) void {
        if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
        self.pending_token = null;
    }

    pub fn canRender(self: *const FrameLoop) bool {
        return self.armed and self.state.visible();
    }

    pub fn commitRender(self: *FrameLoop) !void {
        std.debug.assert(self.canRender());
        if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
        self.pending_token = try self.ops.requestFrameFn(self.ops.ctx, self);
        self.armed = false;
    }

    pub fn onFrameCallbackDone(self: *FrameLoop, token: CallbackToken) void {
        if (self.pending_token == null or self.pending_token.? != token) return;
        self.ops.destroyCallbackFn(self.ops.ctx, token);
        self.pending_token = null;
        self.armed = true;
    }

    pub fn onSurfaceHidden(self: *FrameLoop) void {
        if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
        self.pending_token = null;
        // armed unchanged — canRender() is false while hidden regardless.
    }

    pub fn onSurfaceShown(self: *FrameLoop) void {
        self.armed = true;
    }

    pub fn forceArm(self: *FrameLoop) void {
        if (self.pending_token) |t| self.ops.destroyCallbackFn(self.ops.ctx, t);
        self.pending_token = null;
        self.armed = true;
    }

    /// Blocks on wl_fd + extra pollfds with `timeout_ms`, then reads + dispatches
    /// any pending Wayland events. Safe to call in any state.
    pub fn waitForWork(
        self: *FrameLoop,
        extra: []std.posix.pollfd,
        timeout_ms: i32,
    ) !void {
        self.ops.flushFn(self.ops.ctx);

        const wl_fd = self.ops.getFdFn(self.ops.ctx);
        // Build a small on-stack pollfd array: wl_fd + extras.
        // Cap extras at 8 — waystty never polls more than pty+wl.
        var all: [9]std.posix.pollfd = undefined;
        all[0] = .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 };
        std.debug.assert(extra.len <= all.len - 1);
        for (extra, 0..) |fd, i| all[i + 1] = fd;
        const total = 1 + extra.len;

        _ = std.posix.poll(all[0..total], timeout_ms) catch {};

        // Propagate revents back into caller's extra slice.
        for (extra, 0..) |*fd, i| fd.* = all[i + 1];

        if (all[0].revents & std.posix.POLL.IN != 0) {
            if (self.ops.prepareReadFn(self.ops.ctx)) {
                self.ops.readEventsFn(self.ops.ctx);
            }
        }
        self.ops.dispatchPendingFn(self.ops.ctx);
    }
};
```

- [ ] **Step 3: Build to verify module wires up**

Run: `make build`
Expected: clean compile. The file has no tests yet.

- [ ] **Step 4: Commit**

```bash
git add build.zig src/frame_loop.zig
git commit -m "$(cat <<'EOF'
Introduce FrameLoop module and DisplayOps trait

Pure readiness primitive for the wl_surface.frame-callback pacing
pattern. Not yet used by any loop — next commits add a mock DisplayOps
for tests, then migrate the real loops one at a time.
EOF
)"
```

---

## Task 6: Add MockDisplayOps and unit tests for FrameLoop

**Files:**
- Modify: `src/frame_loop.zig`

- [ ] **Step 1: Add MockDisplayOps and failing tests**

Append to `src/frame_loop.zig`:

```zig
// ---------------------------------------------------------------------------
// Test-only mock below this line.
// ---------------------------------------------------------------------------

const Mock = struct {
    next_token: usize = 1,
    frame_requests: usize = 0,
    callbacks_destroyed: usize = 0,
    flushed: usize = 0,
    dispatched: usize = 0,

    // A fake fd that never becomes ready — tests call waitForWork with a
    // 0ms timeout so poll returns immediately.
    pipe_fds: [2]std.posix.fd_t = .{ -1, -1 },

    fn init() !Mock {
        const pipes = try std.posix.pipe();
        return .{ .pipe_fds = pipes };
    }

    fn deinit(self: *Mock) void {
        if (self.pipe_fds[0] >= 0) std.posix.close(self.pipe_fds[0]);
        if (self.pipe_fds[1] >= 0) std.posix.close(self.pipe_fds[1]);
    }

    fn flushThunk(ctx: *anyopaque) void {
        const self: *Mock = @ptrCast(@alignCast(ctx));
        self.flushed += 1;
    }
    fn prepareReadThunk(_: *anyopaque) bool { return false; }
    fn readEventsThunk(_: *anyopaque) void {}
    fn dispatchPendingThunk(ctx: *anyopaque) void {
        const self: *Mock = @ptrCast(@alignCast(ctx));
        self.dispatched += 1;
    }
    fn getFdThunk(ctx: *anyopaque) std.posix.fd_t {
        const self: *Mock = @ptrCast(@alignCast(ctx));
        return self.pipe_fds[0];
    }
    fn requestFrameThunk(ctx: *anyopaque, _: *FrameLoop) anyerror!CallbackToken {
        const self: *Mock = @ptrCast(@alignCast(ctx));
        const tok: CallbackToken = @ptrFromInt(self.next_token);
        self.next_token += 1;
        self.frame_requests += 1;
        return tok;
    }
    fn destroyCallbackThunk(ctx: *anyopaque, _: CallbackToken) void {
        const self: *Mock = @ptrCast(@alignCast(ctx));
        self.callbacks_destroyed += 1;
    }

    fn ops(self: *Mock) DisplayOps {
        return .{
            .ctx = self,
            .flushFn = flushThunk,
            .prepareReadFn = prepareReadThunk,
            .readEventsFn = readEventsThunk,
            .dispatchPendingFn = dispatchPendingThunk,
            .getFdFn = getFdThunk,
            .requestFrameFn = requestFrameThunk,
            .destroyCallbackFn = destroyCallbackThunk,
        };
    }
};

const TestState = struct {
    configured: bool = true,
    suspended: bool = false,
    tracker: scale_tracker.ScaleTracker,

    fn init(alloc: std.mem.Allocator) !TestState {
        var t = scale_tracker.ScaleTracker.init(alloc);
        try t.addOutput(1);
        try t.enterOutput(1);
        return .{ .tracker = t };
    }

    fn deinit(self: *TestState) void {
        self.tracker.deinit();
    }

    fn view(self: *const TestState) SurfaceStateView {
        return .{
            .configured_ptr = &self.configured,
            .suspended_ptr = &self.suspended,
            .tracker = &self.tracker,
        };
    }
};

test "initial state: armed and can render when visible" {
    var mock = try Mock.init();
    defer mock.deinit();
    var ts = try TestState.init(std.testing.allocator);
    defer ts.deinit();

    var loop = FrameLoop.init(mock.ops(), ts.view());
    defer loop.deinit();

    try std.testing.expect(loop.armed);
    try std.testing.expect(loop.canRender());
    try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
}

test "commitRender requests a frame and disarms" {
    var mock = try Mock.init();
    defer mock.deinit();
    var ts = try TestState.init(std.testing.allocator);
    defer ts.deinit();

    var loop = FrameLoop.init(mock.ops(), ts.view());
    defer loop.deinit();

    try loop.commitRender();

    try std.testing.expect(!loop.armed);
    try std.testing.expect(!loop.canRender());
    try std.testing.expect(loop.pending_token != null);
    try std.testing.expectEqual(@as(usize, 1), mock.frame_requests);
}

test "onFrameCallbackDone re-arms when token matches" {
    var mock = try Mock.init();
    defer mock.deinit();
    var ts = try TestState.init(std.testing.allocator);
    defer ts.deinit();

    var loop = FrameLoop.init(mock.ops(), ts.view());
    defer loop.deinit();

    try loop.commitRender();
    const tok = loop.pending_token.?;
    loop.onFrameCallbackDone(tok);

    try std.testing.expect(loop.armed);
    try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
    try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
}

test "onFrameCallbackDone ignores stale token" {
    var mock = try Mock.init();
    defer mock.deinit();
    var ts = try TestState.init(std.testing.allocator);
    defer ts.deinit();

    var loop = FrameLoop.init(mock.ops(), ts.view());
    defer loop.deinit();

    try loop.commitRender();
    const stale_tok: CallbackToken = @ptrFromInt(0xDEAD);
    loop.onFrameCallbackDone(stale_tok);

    try std.testing.expect(!loop.armed);
    try std.testing.expect(loop.pending_token != null);
}

test "onSurfaceHidden destroys pending callback and leaves armed unchanged" {
    var mock = try Mock.init();
    defer mock.deinit();
    var ts = try TestState.init(std.testing.allocator);
    defer ts.deinit();

    var loop = FrameLoop.init(mock.ops(), ts.view());
    defer loop.deinit();

    try loop.commitRender();
    const armed_before = loop.armed;
    loop.onSurfaceHidden();

    try std.testing.expectEqual(armed_before, loop.armed); // false, unchanged
    try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
    try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
}

test "onSurfaceShown force-arms" {
    var mock = try Mock.init();
    defer mock.deinit();
    var ts = try TestState.init(std.testing.allocator);
    defer ts.deinit();

    var loop = FrameLoop.init(mock.ops(), ts.view());
    defer loop.deinit();

    try loop.commitRender();
    try std.testing.expect(!loop.armed);
    loop.onSurfaceShown();
    try std.testing.expect(loop.armed);
}

test "canRender requires both armed and visible" {
    var mock = try Mock.init();
    defer mock.deinit();
    var ts = try TestState.init(std.testing.allocator);
    defer ts.deinit();

    var loop = FrameLoop.init(mock.ops(), ts.view());
    defer loop.deinit();

    try std.testing.expect(loop.canRender()); // armed + visible

    ts.suspended = true;
    try std.testing.expect(!loop.canRender()); // visibility gate

    ts.suspended = false;
    try loop.commitRender();
    try std.testing.expect(!loop.canRender()); // armed gate
}

test "forceArm recovers without a callback (OUT_OF_DATE path)" {
    var mock = try Mock.init();
    defer mock.deinit();
    var ts = try TestState.init(std.testing.allocator);
    defer ts.deinit();

    var loop = FrameLoop.init(mock.ops(), ts.view());
    defer loop.deinit();

    try loop.commitRender();
    try std.testing.expect(!loop.armed);
    loop.forceArm();
    try std.testing.expect(loop.armed);
    try std.testing.expectEqual(@as(?CallbackToken, null), loop.pending_token);
}

test "deinit destroys any pending callback" {
    var mock = try Mock.init();
    defer mock.deinit();
    var ts = try TestState.init(std.testing.allocator);
    defer ts.deinit();

    {
        var loop = FrameLoop.init(mock.ops(), ts.view());
        defer loop.deinit();
        try loop.commitRender();
    }

    try std.testing.expectEqual(@as(usize, 1), mock.callbacks_destroyed);
}
```

- [ ] **Step 2: Run tests**

Run: `make test`
Expected: all 9 new FrameLoop tests pass.

- [ ] **Step 3: Commit**

```bash
git add src/frame_loop.zig
git commit -m "$(cat <<'EOF'
Add MockDisplayOps + FrameLoop unit tests

9 tests covering: initial state, commit disarms, callback done re-arms,
stale-token rejection, hidden cleanup, show force-arm, canRender
gating, forceArm recovery, deinit cleanup.
EOF
)"
```

---

## Task 7: Real-path DisplayOps adapter in wayland.zig

**Files:**
- Modify: `src/wayland.zig`

- [ ] **Step 1: Add adapter at the top of `src/wayland.zig`**

After the existing imports, add:

```zig
const frame_loop_mod = @import("frame_loop");
pub const FrameLoop = frame_loop_mod.FrameLoop;
pub const DisplayOps = frame_loop_mod.DisplayOps;
pub const SurfaceStateView = frame_loop_mod.SurfaceStateView;
pub const CallbackToken = frame_loop_mod.CallbackToken;
```

- [ ] **Step 2: Add the real-path adapter on Window**

Append to the `Window` struct methods (in `src/wayland.zig`, inside the `pub const Window = struct { ... };` block, after the existing methods):

```zig
    /// Build a DisplayOps vtable that drives the real compositor.
    pub fn displayOps(self: *Window, display: *wl.Display) DisplayOps {
        // Carry both display and surface via an owned adapter struct.
        self.display_adapter = .{
            .display = display,
            .surface = self.surface,
            .loop_ref = null,
        };
        return .{
            .ctx = &self.display_adapter,
            .flushFn = DisplayAdapter.flushThunk,
            .prepareReadFn = DisplayAdapter.prepareReadThunk,
            .readEventsFn = DisplayAdapter.readEventsThunk,
            .dispatchPendingFn = DisplayAdapter.dispatchPendingThunk,
            .getFdFn = DisplayAdapter.getFdThunk,
            .requestFrameFn = DisplayAdapter.requestFrameThunk,
            .destroyCallbackFn = DisplayAdapter.destroyCallbackThunk,
        };
    }

    pub fn surfaceStateView(self: *const Window) SurfaceStateView {
        return .{
            .configured_ptr = &self.state.configured,
            .suspended_ptr = &self.state.suspended,
            .tracker = self.tracker,
        };
    }
```

And add a field to `Window`:

```zig
    display_adapter: DisplayAdapter = undefined,
```

- [ ] **Step 3: Implement the DisplayAdapter type**

Add to `src/wayland.zig`, before the `pub const Window = struct` definition:

```zig
pub const DisplayAdapter = struct {
    display: *wl.Display,
    surface: *wl.Surface,
    loop_ref: ?*FrameLoop,

    fn flushThunk(ctx: *anyopaque) void {
        const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
        _ = self.display.flush();
    }
    fn prepareReadThunk(ctx: *anyopaque) bool {
        const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
        return self.display.prepareRead();
    }
    fn readEventsThunk(ctx: *anyopaque) void {
        const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
        _ = self.display.readEvents();
    }
    fn dispatchPendingThunk(ctx: *anyopaque) void {
        const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
        _ = self.display.dispatchPending();
    }
    fn getFdThunk(ctx: *anyopaque) std.posix.fd_t {
        const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
        return self.display.getFd();
    }

    fn requestFrameThunk(ctx: *anyopaque, loop: *FrameLoop) anyerror!CallbackToken {
        const self: *DisplayAdapter = @ptrCast(@alignCast(ctx));
        self.loop_ref = loop;
        const cb = try self.surface.frame();
        cb.setListener(*FrameLoop, frameCallbackListener, loop);
        return @ptrCast(cb);
    }

    fn destroyCallbackThunk(_: *anyopaque, token: CallbackToken) void {
        const cb: *wl.Callback = @constCast(@ptrCast(@alignCast(token)));
        cb.destroy();
    }
};

fn frameCallbackListener(cb: *wl.Callback, event: wl.Callback.Event, loop: *FrameLoop) void {
    switch (event) {
        .done => {
            const tok: CallbackToken = @ptrCast(cb);
            loop.onFrameCallbackDone(tok);
        },
    }
}
```

- [ ] **Step 4: Verify build**

Run: `make build`
Expected: clean compile. No new tests yet — integration is exercised in Task 8+.

- [ ] **Step 5: Commit**

```bash
git add src/wayland.zig
git commit -m "$(cat <<'EOF'
Add real-path DisplayOps adapter on Window

DisplayAdapter thinly wraps wl.Display + wl.Surface and satisfies the
DisplayOps vtable. frameCallbackListener receives wl_callback.done and
forwards it to FrameLoop.onFrameCallbackDone, which performs the
identity check.
EOF
)"
```

---

## Task 8: Migrate main terminal loop to FrameLoop (fixes the freeze)

**Files:**
- Modify: `src/main.zig`

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()`.

- [ ] **Step 1: Add imports and FrameLoop instantiation**

At the top of `src/main.zig`, ensure these imports exist (add missing):

```zig
const wayland_client = @import("wayland-client");
const frame_loop_mod = @import("frame_loop");
```

In `runTerminal`, after the `window` creation block and after `conn.display.roundtrip()` (around main.zig:137), add:

```zig
    var frame_loop = frame_loop_mod.FrameLoop.init(
        window.displayOps(conn.display),
        window.surfaceStateView(),
    );
    defer frame_loop.deinit();
```

- [ ] **Step 2: Wire FrameLoop into surface enter/leave listeners**

In `src/wayland.zig`, extend `surfaceListener` (around line 1050) so it can notify a FrameLoop. Add a FrameLoop pointer field on `Window`:

```zig
    frame_loop: ?*FrameLoop = null,
```

Update `surfaceListener` to call show/hide hooks when visibility transitions:

```zig
fn surfaceListener(_: *wl.Surface, event: wl.Surface.Event, window: *Window) void {
    const was_visible = window.state.visible();
    switch (event) {
        .enter => |e| {
            const wl_out = e.output orelse return;
            window.handleSurfaceEnter(wl_out);
            window.scale_generation += 1;
        },
        .leave => |e| {
            const wl_out = e.output orelse return;
            window.handleSurfaceLeave(wl_out);
            window.scale_generation += 1;
        },
        .preferred_buffer_scale => {},
        .preferred_buffer_transform => {},
    }
    const now_visible = window.state.visible();
    if (window.frame_loop) |loop| {
        if (was_visible and !now_visible) loop.onSurfaceHidden();
        if (!was_visible and now_visible) loop.onSurfaceShown();
    }
}
```

Do the same for `xdgToplevelListener` (so the suspended flag transition fires the hook):

```zig
fn xdgToplevelListener(_: *xdg.Toplevel, event: xdg.Toplevel.Event, window: *Window) void {
    const was_visible = window.state.visible();
    switch (event) {
        .configure => |cfg| {
            if (cfg.width > 0) window.width = @intCast(cfg.width);
            if (cfg.height > 0) window.height = @intCast(cfg.height);
            applyToplevelStates(&window.state, std.mem.sliceAsBytes(cfg.states.slice()));
        },
        .close => window.should_close = true,
        .configure_bounds => {},
        .wm_capabilities => {},
    }
    const now_visible = window.state.visible();
    if (window.frame_loop) |loop| {
        if (was_visible and !now_visible) loop.onSurfaceHidden();
        if (!was_visible and now_visible) loop.onSurfaceShown();
    }
}
```

And for `xdgSurfaceListener` (configured transitions from false→true):

```zig
fn xdgSurfaceListener(surface: *xdg.Surface, event: xdg.Surface.Event, window: *Window) void {
    const was_visible = window.state.visible();
    switch (event) {
        .configure => |cfg| {
            surface.ackConfigure(cfg.serial);
            window.state.configured = true;
        },
    }
    const now_visible = window.state.visible();
    if (window.frame_loop) |loop| {
        if (!was_visible and now_visible) loop.onSurfaceShown();
    }
}
```

Back in `src/main.zig`, right after `frame_loop` init, set:

```zig
    window.frame_loop = &frame_loop;
    defer window.frame_loop = null;
```

- [ ] **Step 3: Split the main loop's scale/resize block**

Locate the main loop in `src/main.zig` (starts around line 246 `while (!window.should_close and p.isChildAlive())`). Identify the two blocks:
- Scale-change block (around line 317-346): `if (current_scale != last_scale) { ... deviceWaitIdle ... recreateSwapchain ... }`
- Size-change block (around line 348-380): `if (window.width != last_window_w or window.height != last_window_h) { ... deviceWaitIdle ... recreateSwapchain ... resize term/pty ... }`

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.

```zig
    var pollfds_extra = [_]std.posix.pollfd{
        .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 },
    };
    var read_buf: [8192]u8 = undefined;
    var key_buf: [32]u8 = undefined;
    var last_window_w = window.width;
    var last_window_h = window.height;
    var last_scale: i32 = geom.buffer_scale;
    var render_pending = true;
    var resize_pending = false;
    var scale_pending = false;

    while (!window.should_close and p.isChildAlive()) {
        const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
        const timeout = computePollTimeoutMs(repeat_timeout_ms, render_pending and frame_loop.canRender());
        try frame_loop.waitForWork(&pollfds_extra, timeout);

        // PTY output
        if (pollfds_extra[0].revents & std.posix.POLL.IN != 0) {
            while (true) {
                const n = p.read(&read_buf) catch |err| switch (err) {
                    error.WouldBlock => break,
                    error.InputOutput => break,
                    else => return err,
                };
                if (n == 0) break;
                term.write(read_buf[0..n]);
                render_pending = true;
            }
        }

        // Pointer events
        const ptr_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale));
        const ptr_cell_h = cell_h / @as(u32, @intCast(geom.buffer_scale));
        const prev_selection = activeSelectionSpan(selection);
        for (pointer.event_queue.items) |ev| {
            handlePointerSelectionEvent(&selection, ev, ptr_cell_w, ptr_cell_h, cols, rows);
        }
        const selection_changed = !std.meta.eql(activeSelectionSpan(selection), prev_selection);
        if (pointer.event_queue.items.len > 0) {
            pointer.event_queue.clearRetainingCapacity();
            render_pending = true;
        }

        // Keyboard events (identical to existing body — paste/copy/encode)
        keyboard.tickRepeat();
        for (keyboard.event_queue.items) |ev| {
            if (ev.action == .release) continue;
            if (isClipboardPasteEvent(ev)) {
                if (clipboard) |cb| {
                    if (try cb.receiveSelectionText(alloc)) |text| {
                        defer alloc.free(text);
                        const encoded = term.encodePaste(text);
                        for (encoded) |chunk| {
                            if (chunk.len == 0) continue;
                            _ = try p.write(chunk);
                        }
                    }
                }
                continue;
            }
            if (isClipboardCopyEvent(ev)) {
                _ = try copySelectionText(alloc, clipboard, term, activeSelectionSpan(selection), ev.serial);
                continue;
            }
            if (ev.utf8_len > 0) {
                _ = try p.write(ev.utf8[0..ev.utf8_len]);
            } else if (try encodeKeyboardEvent(term, ev, &key_buf)) |encoded| {
                _ = try p.write(encoded);
            }
        }
        keyboard.event_queue.clearRetainingCapacity();

        // observeResize — detect changes, record pending flag, no Vulkan.
        const current_scale = window.bufferScale();
        if (current_scale != last_scale) {
            scale_pending = true;
            render_pending = true;
        }
        if (window.width != last_window_w or window.height != last_window_h) {
            resize_pending = true;
            render_pending = true;
        }

        if (!shouldRenderFrame(render_pending, false, false)) continue;
        if (!frame_loop.canRender()) continue;  // hidden — no Vulkan at all

        // applyPendingResize / applyPendingScale — Vulkan work, gated.
        if (scale_pending) {
            _ = try ctx.vkd.deviceWaitIdle(ctx.device);
            geom = try rebuildFaceForScale(
                &face,
                &atlas,
                font_lookup.path,
                font_lookup.index,
                font_size,
                window.bufferScale(),
            );
            cell_w = geom.cell_w_px;
            cell_h = geom.cell_h_px;
            baseline = geom.baseline_px;
            render_cache.invalidateAfterResize();
            term.render_state.dirty = .full;
            window.surface.setBufferScale(geom.buffer_scale);
            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);
            last_scale = geom.buffer_scale;
            scale_pending = false;
        }
        if (resize_pending) {
            const surf_cell_w = cell_w / @as(u32, @intCast(geom.buffer_scale));
            const surf_cell_h = cell_h / @as(u32, @intCast(geom.buffer_scale));
            const new_grid = gridSizeForWindow(window.width, window.height, surf_cell_w, surf_cell_h);
            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            if (new_grid.cols != cols or new_grid.rows != rows) {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                try ctx.recreateSwapchain(buf_w, buf_h);
                try term.resize(new_grid.cols, new_grid.rows);
                try p.resize(new_grid.cols, new_grid.rows);
                cols = new_grid.cols;
                rows = new_grid.rows;
                term.setReportedSize(.{
                    .rows = rows,
                    .columns = cols,
                    .cell_width = cell_w,
                    .cell_height = cell_h,
                });
                selection.committed = if (selection.committed) |span| clampSelectionSpan(span, cols, rows) else null;
                selection.active = if (selection.active) |span| clampSelectionSpan(span, cols, rows) else null;
                selection.anchor = if (selection.anchor) |point| clampGridPoint(point, cols, rows) else null;
                selection.hover = if (selection.hover) |point| clampGridPoint(point, cols, rows) else null;
            } else {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                try ctx.recreateSwapchain(buf_w, buf_h);
            }
            last_window_w = window.width;
            last_window_h = window.height;
            resize_pending = false;
        }

        // === render === (identical to existing body from snapshot through clearConsumedDirtyFlags)
        var frame_timing: FrameTiming = .{};
        const previous_cursor = term.render_state.cursor;
        var section_timer = std.time.Timer.start() catch unreachable;
        try term.snapshot();
        frame_timing.snapshot_us = usFromTimer(&section_timer);

        section_timer = std.time.Timer.start() catch unreachable;
        const default_bg = term.backgroundColor();
        const bg_uv = atlas.cursorUV();
        const term_rows = term.render_state.row_data.items(.cells);
        const dirty_rows = term.render_state.row_data.items(.dirty);
        try render_cache.resizeRows(alloc, term_rows.len);
        const refresh_plan = planRowRefresh(
            if (term.render_state.dirty == .full or selection_changed) .full else .partial,
            dirty_rows,
            .{
                .cursor = .{
                    .old_row = if (previous_cursor.viewport) |cursor| @intCast(cursor.y) else null,
                    .new_row = if (term.render_state.cursor.viewport) |cursor| @intCast(cursor.y) else null,
                    .old_col = if (previous_cursor.viewport) |cursor| @intCast(cursor.x) else null,
                    .new_col = if (term.render_state.cursor.viewport) |cursor| @intCast(cursor.x) else null,
                    .old_visible = previous_cursor.visible,
                    .new_visible = term.render_state.cursor.visible,
                },
            },
        );

        // PRESERVE the existing render body from line ~415 ("var rows_rebuilt")
        // through line ~584 ("frame_timing.gpu_submit_us = ..."). Those lines
        // are unchanged — copy them verbatim from the pre-refactor main loop.
        // The only change is the OUT_OF_DATE handler at the end of drawCells:

        // ctx.drawCells(...) catch |err| switch (err) {
        //     error.OutOfDateKHR => {
        //         _ = try ctx.vkd.deviceWaitIdle(ctx.device);
        //         const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
        //         const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
        //         try ctx.recreateSwapchain(buf_w, buf_h);
        //         frame_loop.forceArm();          // <-- ADDED
        //         render_pending = true;
        //         continue;
        //     },
        //     else => return err,
        // };

        frame_ring.push(frame_timing);

        if (sigusr1_received.swap(false, .acq_rel)) {
            printFrameStats(computeFrameStats(&frame_ring));
        }

        clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan);

        // Commit is already inside ctx.drawCells via queuePresentKHR. The
        // wl_surface.commit that backs it happens inside Vulkan WSI. We
        // explicitly request the next frame callback here.
        try frame_loop.commitRender();
        render_pending = false;
    }

    printFrameStats(computeFrameStats(&frame_ring));
    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
```

**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();`.

- [ ] **Step 4: Build**

Run: `make build`
Expected: clean compile.

- [ ] **Step 5: Run tests**

Run: `make test`
Expected: all existing + new tests pass.

- [ ] **Step 6: Manual smoke test — launch waystty, verify normal operation**

Run: `make run`
Expected: waystty window opens, prompt appears, typing echoes, Ctrl-D exits cleanly.

- [ ] **Step 7: Manual freeze regression test — the reason for this whole change**

Run: `make run`
Then in sway:
1. Start typing in waystty.
2. Switch workspaces (mod+2 or equivalent).
3. Wait 10 seconds.
4. Switch back.

Expected: waystty is responsive on return; no hang, no stale display.

- [ ] **Step 8: Commit**

```bash
git add src/main.zig src/wayland.zig
git commit -m "$(cat <<'EOF'
Migrate main terminal loop to FrameLoop; fix hidden-workspace freeze

Splits the scale/resize handler into observeResize (non-Vulkan, always
runs) and applyPendingResize/Scale (Vulkan, gated on canRender). All
Vulkan calls — deviceWaitIdle, recreateSwapchain, rebuildFaceForScale,
drawCells — now happen only when the surface is visible. OUT_OF_DATE
path calls forceArm() to retry without a callback.

Fixes: waystty hanging when its window is moved to a hidden sway
workspace.
EOF
)"
```

---

## Task 9: Synthetic hidden-freeze regression test

**Files:**
- Modify: `src/frame_loop.zig`

- [ ] **Step 1: Add regression test**

Append to `src/frame_loop.zig`:

```zig
test "hidden-freeze regression: pty flood while hidden never blocks" {
    var mock = try Mock.init();
    defer mock.deinit();
    var ts = try TestState.init(std.testing.allocator);
    defer ts.deinit();

    var loop = FrameLoop.init(mock.ops(), ts.view());
    defer loop.deinit();

    // Initial render.
    try loop.commitRender();
    const first_tok = loop.pending_token.?;
    loop.onFrameCallbackDone(first_tok);

    // Hide the surface: tracker leaves all outputs.
    ts.tracker.leaveOutput(1);
    loop.onSurfaceHidden();
    try std.testing.expect(!loop.canRender());

    // Simulate 100 iterations of "pty wrote more data, we want to render".
    // Under the gate, canRender is false every iteration and we never call
    // commitRender — so no Vulkan work, no blocking.
    var i: usize = 0;
    while (i < 100) : (i += 1) {
        try std.testing.expect(!loop.canRender());
    }

    // Show again: tracker re-enters an output.
    try ts.tracker.enterOutput(1);
    loop.onSurfaceShown();

    try std.testing.expect(loop.canRender());

    // Normal render resumes.
    try loop.commitRender();
    try std.testing.expect(loop.pending_token != null);
    const tok = loop.pending_token.?;
    loop.onFrameCallbackDone(tok);
    try std.testing.expect(loop.armed);
}
```

- [ ] **Step 2: Run tests**

Run: `make test`
Expected: regression test passes alongside the existing FrameLoop tests.

- [ ] **Step 3: Commit**

```bash
git add src/frame_loop.zig
git commit -m "$(cat <<'EOF'
Add synthetic hidden-freeze regression test

Exercises the hide → pty-flood → show sequence through the FrameLoop
state machine. Verifies no canRender() call returns true while hidden,
and that normal pacing resumes on show.
EOF
)"
```

---

## Task 10: Migrate `runTextCoverageCompare` to FrameLoop

**Files:**
- Modify: `src/main.zig`

- [ ] **Step 1: Replace the text-compare main loop body**

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:

```zig
    var frame_loop = frame_loop_mod.FrameLoop.init(
        window.displayOps(conn.display),
        window.surfaceStateView(),
    );
    defer frame_loop.deinit();
    window.frame_loop = &frame_loop;
    defer window.frame_loop = null;

    var last_window_w = window.width;
    var last_window_h = window.height;
    var last_scale: i32 = geom.buffer_scale;

    while (!window.should_close) {
        try frame_loop.waitForWork(&.{}, 16);

        if (!frame_loop.canRender()) continue;

        const current_scale = window.bufferScale();
        const scale_changed = current_scale != last_scale;
        const size_changed = window.width != last_window_w or window.height != last_window_h;

        if (scale_changed or size_changed) {
            _ = try ctx.vkd.deviceWaitIdle(ctx.device);

            if (scale_changed) {
                geom = try rebuildFaceForScale(
                    &face,
                    &atlas,
                    font_lookup.path,
                    font_lookup.index,
                    config.font_size_px,
                    current_scale,
                );
                scene.deinit(alloc);
                scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
                window.surface.setBufferScale(geom.buffer_scale);
                try ctx.uploadAtlas(atlas.pixels);
                atlas.dirty = false;
                try ctx.uploadInstances(scene.instances.items);
                last_scale = current_scale;
            }

            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);
            last_window_w = window.width;
            last_window_h = window.height;
        }

        drawTextCoverageCompareFrame(
            &ctx,
            &scene,
            geom.cell_w_px,
            geom.cell_h_px,
            .{ 0.0, 0.0, 0.0, 1.0 },
        ) catch |err| switch (err) {
            error.OutOfDateKHR => {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
                const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
                try ctx.recreateSwapchain(buf_w, buf_h);
                last_window_w = window.width;
                last_window_h = window.height;
                frame_loop.forceArm();
                continue;
            },
            else => return err,
        };

        try frame_loop.commitRender();
    }

    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
```

- [ ] **Step 2: Build and run the mode to verify**

Run: `make build`
Expected: clean compile.

Run: `./zig-out/bin/waystty --text-compare`
Expected: the text-coverage comparison window opens and renders normally.

- [ ] **Step 3: Commit**

```bash
git add src/main.zig
git commit -m "$(cat <<'EOF'
Migrate runTextCoverageCompare to FrameLoop

Drops the manual 16ms sleep and prepareRead/cancelRead dance in favor
of the shared readiness primitive. Same visual output; now also
freeze-safe under workspace change.
EOF
)"
```

---

## Task 11: Migrate `runDrawSmokeTest` to FrameLoop

**Files:**
- Modify: `src/main.zig`

- [ ] **Step 1: Replace the draw-smoke main loop**

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.

Before the loop:

```zig
    var frame_loop = frame_loop_mod.FrameLoop.init(
        window.displayOps(conn.display),
        window.surfaceStateView(),
    );
    defer frame_loop.deinit();
    window.frame_loop = &frame_loop;
    defer window.frame_loop = null;
```

Then the loop body becomes:

```zig
    const deadline_ns = @as(i128, std.time.nanoTimestamp()) + 15 * std.time.ns_per_s;
    while (std.time.nanoTimestamp() < deadline_ns and !window.should_close) {
        try frame_loop.waitForWork(&.{}, 16);
        if (!frame_loop.canRender()) continue;

        const baseline_coverage = renderer.coverageVariantParams(.baseline);
        ctx.drawCells(1, .{ cell_w, cell_h }, .{ 0.0, 0.0, 0.0, 1.0 }, baseline_coverage) catch |err| switch (err) {
            error.OutOfDateKHR => {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                try ctx.recreateSwapchain(window.width, window.height);
                frame_loop.forceArm();
                continue;
            },
            else => return err,
        };
        try frame_loop.commitRender();
    }
```

- [ ] **Step 2: Build and run**

Run: `make build`
Expected: clean compile.

Run: `./zig-out/bin/waystty --draw-smoke-test`
Expected: renders the 'M' glyph for ~15 seconds, then exits.

- [ ] **Step 3: Commit**

```bash
git add src/main.zig
git commit -m "$(cat <<'EOF'
Migrate runDrawSmokeTest to FrameLoop

Replaces the fixed 900-iteration + manual event-pump pattern with
FrameLoop pacing. Uses a 15-second wall-clock deadline instead of a
frame count.
EOF
)"
```

---

## Task 12: Add `WAYSTTY_BENCH_UNTHROTTLED` env var for bench mode

**Files:**
- Modify: `src/main.zig`

- [ ] **Step 1: Read env var at bench setup**

In `src/main.zig`, inside `runTerminal`, near where `WAYSTTY_BENCH` is read (around line 201), add:

```zig
    const bench_unthrottled = std.posix.getenv("WAYSTTY_BENCH_UNTHROTTLED") != null
        and std.posix.getenv("WAYSTTY_BENCH") != null;
```

- [ ] **Step 2: Gate FrameLoop.canRender in the main loop**

In the main loop, change the visibility gate line:

```zig
        if (!frame_loop.canRender()) continue;  // hidden — no Vulkan at all
```

to:

```zig
        if (!bench_unthrottled and !frame_loop.canRender()) continue;
```

And change the `commitRender` call near the end of the loop to:

```zig
        if (!bench_unthrottled) try frame_loop.commitRender();
```

(When unthrottled, we never gate and never request frame callbacks — effectively the pre-refactor eager loop. Freeze-safety is forfeit, which is documented.)

- [ ] **Step 3: Emit a banner on bench startup**

Near the top of `runTerminal`, after reading `bench_unthrottled`, add:

```zig
    if (std.posix.getenv("WAYSTTY_BENCH") != null) {
        if (bench_unthrottled) {
            std.debug.print("[bench] mode: UNTHROTTLED (not freeze-safe)\n", .{});
        } else {
            std.debug.print("[bench] mode: THROTTLED (vsync-paced)\n", .{});
        }
    }
```

- [ ] **Step 4: Run bench twice to verify both paths work**

Run: `make bench`
Expected: bench runs, prints `[bench] mode: THROTTLED`, produces frame timing output.

Run: `WAYSTTY_BENCH=1 WAYSTTY_BENCH_UNTHROTTLED=1 ./zig-out/bin/waystty 2>bench.log || true; grep -A 12 "waystty frame timing" bench.log`
Expected: bench runs, prints `[bench] mode: UNTHROTTLED`, produces frame timing output (typically more frames/sec than throttled).

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "$(cat <<'EOF'
Add WAYSTTY_BENCH_UNTHROTTLED escape hatch

Bypasses FrameLoop gating and callback requests in bench mode for raw
throughput measurement. Explicitly not freeze-safe — documented in the
spec. Emits a banner on startup so bench logs are unambiguous.
EOF
)"
```

---

## Task 13: Add `--hidden-freeze-regression` manual mode

**Files:**
- Modify: `src/main.zig`

- [ ] **Step 1: Register the flag and function**

In `src/main.zig` `pub fn main`, add after the existing mode dispatches (around line 91):

```zig
    if (args.len >= 2 and std.mem.eql(u8, args[1], "--hidden-freeze-regression")) {
        return runHiddenFreezeRegression(alloc);
    }
```

- [ ] **Step 2: Implement the mode**

Add at the bottom of `src/main.zig`:

```zig
fn runHiddenFreezeRegression(alloc: std.mem.Allocator) !void {
    const stdout = std.io.getStdOut().writer();
    try stdout.writeAll(
        \\
        \\hidden-freeze regression mode
        \\-----------------------------
        \\1. This process will spawn waystty and start writing to its pty.
        \\2. Move the window to a different workspace.
        \\3. Wait 5 seconds.
        \\4. Move the window back.
        \\5. The window should be responsive and show fresh output.
        \\
        \\Press enter to start, Ctrl-C to abort.
        \\
    );
    var buf: [16]u8 = undefined;
    _ = try std.io.getStdIn().reader().read(&buf);

    // Reuse runTerminal — it is the code path we're validating. The regression
    // is whether it freezes; manual observation by the operator confirms.
    return runTerminal(alloc);
}
```

- [ ] **Step 3: Build and smoke-test**

Run: `make build && ./zig-out/bin/waystty --hidden-freeze-regression`
Expected: help text prints; on enter, waystty window opens normally. Follow the instructions manually under sway to confirm.

- [ ] **Step 4: Commit**

```bash
git add src/main.zig
git commit -m "$(cat <<'EOF'
Add --hidden-freeze-regression manual test mode

Prints operator instructions and spawns a normal waystty session. The
freeze fix is validated by moving the window to another workspace and
back; the mode is purely for documentation and repeatable manual QA.
EOF
)"
```

---

## Task 14: Final verification pass

- [ ] **Step 1: Full test suite**

Run: `make test`
Expected: all tests pass (pty, scale_tracker, wayland, main, vt, font, renderer, frame_loop).

- [ ] **Step 2: Clean build**

Run: `make clean && make build`
Expected: clean compile from scratch.

- [ ] **Step 3: Manual multi-workspace verification**

Run: `make run`
Then under sway:
1. Type in waystty.
2. Switch to another workspace (mod+2).
3. Wait 30 seconds.
4. Switch back (mod+1).
5. Confirm: responsive, fresh prompt state.
6. Resize the window.
7. Move to another workspace mid-resize, switch back.
8. Confirm: correct size, no crash.

- [ ] **Step 4: Bench smoke**

Run: `make bench`
Expected: bench runs to completion, throttled-mode banner, timing output printed.

- [ ] **Step 5: If all green, push**

Ask the user: "All steps green. Want me to push to origin?" Wait for explicit yes before pushing.

---

## Notes for the implementer

- **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.
- **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.
- **Listeners run synchronously.** Inside `dispatchPending`, every listener call is synchronous on the main thread. No locks needed anywhere in FrameLoop.
- **Commit hygiene.** Each task ends with a commit. Do not squash; each commit should compile and pass tests independently so bisect works.
- **If a task fails to compile.** Do not amend the prior commit. Add the fix as a new commit so history shows the trial.
- **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.