922aa410
Add input-latency bench implementation plan
a73x 2026-04-18 07:02
Commit message
docs/superpowers/plans/2026-04-18-input-latency-bench-implementation.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1834 @@ | |||
| 1 | # Input-Latency Bench 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:** Implement a closed-loop keystroke-to-display latency benchmark in waystty, measuring cold (idle) and hot (contended-PTY) latency via in-process `KeyEvent` injection, PUA-codepoint sentinels, and `wp_presentation_time` feedback for the display endpoint. | ||
| 6 | |||
| 7 | **Architecture:** A new `WAYSTTY_INPUT_BENCH` mode drives a `BenchDriver` that injects sentinels, scans rendered frames for them, and pairs grid-observations with compositor presentation-feedback to compute latency. A shared grid-lock infrastructure (also applied to the existing output bench) forces a known grid size for reproducibility. | ||
| 8 | |||
| 9 | **Tech Stack:** Zig 0.15+, zig-wayland (ifreund), Vulkan WSI (vulkan-zig), Wayland `wp_presentation_time` protocol, existing `bench_stats` module. | ||
| 10 | |||
| 11 | **Reference spec:** `docs/superpowers/specs/2026-04-18-input-latency-bench-design.md` | ||
| 12 | |||
| 13 | --- | ||
| 14 | |||
| 15 | ## Phase 1 — Shared Bench Infrastructure (grid-lock retrofit) | ||
| 16 | |||
| 17 | These tasks apply to *both* existing `WAYSTTY_BENCH` and the new `WAYSTTY_INPUT_BENCH`. They harden reproducibility of what's already there before adding new bench modes. | ||
| 18 | |||
| 19 | ### Task 1.1: Bench-mode grid-size env vars | ||
| 20 | |||
| 21 | **Files:** | ||
| 22 | - Modify: `src/main.zig:196` (initial_grid constant) | ||
| 23 | |||
| 24 | - [ ] **Step 1: Add env parsing for bench grid size** | ||
| 25 | |||
| 26 | Replace the `initial_grid` constant and add a helper above `main()`: | ||
| 27 | |||
| 28 | ```zig | ||
| 29 | fn benchGridSize() GridSize { | ||
| 30 | const cols_str = std.posix.getenv("WAYSTTY_BENCH_COLS") orelse "80"; | ||
| 31 | const rows_str = std.posix.getenv("WAYSTTY_BENCH_ROWS") orelse "24"; | ||
| 32 | const cols = std.fmt.parseInt(u16, cols_str, 10) catch 80; | ||
| 33 | const rows = std.fmt.parseInt(u16, rows_str, 10) catch 24; | ||
| 34 | return .{ .cols = cols, .rows = rows }; | ||
| 35 | } | ||
| 36 | |||
| 37 | fn benchModeActive() bool { | ||
| 38 | return std.posix.getenv("WAYSTTY_BENCH") != null | ||
| 39 | or std.posix.getenv("WAYSTTY_INPUT_BENCH") != null; | ||
| 40 | } | ||
| 41 | ``` | ||
| 42 | |||
| 43 | Change the `initial_grid` site in `main`: | ||
| 44 | |||
| 45 | ```zig | ||
| 46 | // === grid size === | ||
| 47 | const initial_grid: GridSize = if (benchModeActive()) | ||
| 48 | benchGridSize() | ||
| 49 | else | ||
| 50 | .{ .cols = 80, .rows = 24 }; | ||
| 51 | var cols: u16 = initial_grid.cols; | ||
| 52 | var rows: u16 = initial_grid.rows; | ||
| 53 | ``` | ||
| 54 | |||
| 55 | - [ ] **Step 2: Verify build** | ||
| 56 | |||
| 57 | Run: `zig build` | ||
| 58 | Expected: PASS | ||
| 59 | |||
| 60 | - [ ] **Step 3: Commit** | ||
| 61 | |||
| 62 | ```bash | ||
| 63 | git add src/main.zig | ||
| 64 | git commit -m "bench: parse WAYSTTY_BENCH_COLS/ROWS for configurable grid" | ||
| 65 | ``` | ||
| 66 | |||
| 67 | ### Task 1.2: `xdg_toplevel` min/max size hints | ||
| 68 | |||
| 69 | **Files:** | ||
| 70 | - Modify: `src/main.zig` around the `xdg_toplevel` setup (after window creation, before the first roundtrip at `src/main.zig:213`) | ||
| 71 | |||
| 72 | - [ ] **Step 1: Expose size hints on Window in `src/wayland.zig`** | ||
| 73 | |||
| 74 | Add a method on `Window` near the existing `setTitle`: | ||
| 75 | |||
| 76 | ```zig | ||
| 77 | pub fn setSizeHints(self: *Window, w: u32, h: u32) void { | ||
| 78 | const iw = @as(i32, @intCast(w)); | ||
| 79 | const ih = @as(i32, @intCast(h)); | ||
| 80 | self.xdg_toplevel.setMinSize(iw, ih); | ||
| 81 | self.xdg_toplevel.setMaxSize(iw, ih); | ||
| 82 | } | ||
| 83 | ``` | ||
| 84 | |||
| 85 | - [ ] **Step 2: Call from main when bench is active** | ||
| 86 | |||
| 87 | After `window.height = initial_h;` (around `src/main.zig:211`) and before the roundtrip: | ||
| 88 | |||
| 89 | ```zig | ||
| 90 | if (benchModeActive()) { | ||
| 91 | window.setSizeHints(initial_w, initial_h); | ||
| 92 | } | ||
| 93 | ``` | ||
| 94 | |||
| 95 | - [ ] **Step 3: Build and smoke-test** | ||
| 96 | |||
| 97 | Run: `zig build && WAYSTTY_BENCH=1 WAYSTTY_BENCH_ROWS=24 WAYSTTY_BENCH_COLS=80 ./zig-out/bin/waystty 2>/tmp/smoke.log; head -40 /tmp/smoke.log` | ||
| 98 | Expected: launches and exits cleanly; in a floating window on sway, geometry is respected. | ||
| 99 | |||
| 100 | - [ ] **Step 4: Commit** | ||
| 101 | |||
| 102 | ```bash | ||
| 103 | git add src/main.zig src/wayland.zig | ||
| 104 | git commit -m "bench: advertise xdg_toplevel min/max size hints in bench mode" | ||
| 105 | ``` | ||
| 106 | |||
| 107 | ### Task 1.3: Abort on compositor resize in bench mode | ||
| 108 | |||
| 109 | **Files:** | ||
| 110 | - Modify: `src/main.zig:409` (resize observer) | ||
| 111 | |||
| 112 | - [ ] **Step 1: Add abort on size mismatch in the resize observer** | ||
| 113 | |||
| 114 | Replace the block at `src/main.zig:409`: | ||
| 115 | |||
| 116 | ```zig | ||
| 117 | if (window.width != last_window_w or window.height != last_window_h) { | ||
| 118 | if (benchModeActive()) { | ||
| 119 | std.debug.print( | ||
| 120 | "\nwaystty bench: compositor sized window to {}x{}, expected {}x{} ({}x{} grid). " ++ | ||
| 121 | "Run in a floating window or non-tiling compositor for reproducible benchmarks.\n", | ||
| 122 | .{ window.width, window.height, initial_w, initial_h, cols, rows }, | ||
| 123 | ); | ||
| 124 | std.process.exit(2); | ||
| 125 | } | ||
| 126 | resize_pending = true; | ||
| 127 | render_pending = true; | ||
| 128 | } | ||
| 129 | ``` | ||
| 130 | |||
| 131 | - [ ] **Step 2: Build** | ||
| 132 | |||
| 133 | Run: `zig build` | ||
| 134 | Expected: PASS | ||
| 135 | |||
| 136 | - [ ] **Step 3: Manual sanity — tiling compositor abort** | ||
| 137 | |||
| 138 | On sway (tiling mode), run: | ||
| 139 | `WAYSTTY_BENCH=1 ./zig-out/bin/waystty 2>/tmp/bench-abort.log` | ||
| 140 | Expected: exits with code 2 and diagnostic message. (In floating mode, no abort.) | ||
| 141 | |||
| 142 | - [ ] **Step 4: Commit** | ||
| 143 | |||
| 144 | ```bash | ||
| 145 | git add src/main.zig | ||
| 146 | git commit -m "bench: abort with diagnostic if compositor resizes during bench" | ||
| 147 | ``` | ||
| 148 | |||
| 149 | ### Task 1.4: Print grid size in bench stats output | ||
| 150 | |||
| 151 | **Files:** | ||
| 152 | - Modify: `src/bench_stats.zig:152-162` (`printFrameStats`) | ||
| 153 | |||
| 154 | - [ ] **Step 1: Extend signature to take grid dims** | ||
| 155 | |||
| 156 | Replace `printFrameStats`: | ||
| 157 | |||
| 158 | ```zig | ||
| 159 | pub fn printFrameStats(stats: FrameTimingStats, cols: u16, rows: u16) void { | ||
| 160 | const row_fmt = "{s:<20}{d:>6}{d:>6}{d:>6}{d:>6}\n"; | ||
| 161 | std.debug.print("\n=== waystty frame timing ({d} frames, {d}x{d} grid) ===\n", .{ stats.frame_count, cols, rows }); | ||
| 162 | std.debug.print("{s:<20}{s:>6}{s:>6}{s:>6}{s:>6} (us)\n", .{ "section", "min", "avg", "p99", "max" }); | ||
| 163 | std.debug.print(row_fmt, .{ "snapshot", stats.snapshot.min, stats.snapshot.avg, stats.snapshot.p99, stats.snapshot.max }); | ||
| 164 | std.debug.print(row_fmt, .{ "row_rebuild", stats.row_rebuild.min, stats.row_rebuild.avg, stats.row_rebuild.p99, stats.row_rebuild.max }); | ||
| 165 | std.debug.print(row_fmt, .{ "atlas_upload", stats.atlas_upload.min, stats.atlas_upload.avg, stats.atlas_upload.p99, stats.atlas_upload.max }); | ||
| 166 | std.debug.print(row_fmt, .{ "instance_upload", stats.instance_upload.min, stats.instance_upload.avg, stats.instance_upload.p99, stats.instance_upload.max }); | ||
| 167 | std.debug.print(row_fmt, .{ "gpu_submit", stats.gpu_submit.min, stats.gpu_submit.avg, stats.gpu_submit.p99, stats.gpu_submit.max }); | ||
| 168 | std.debug.print("----------------------------------------------------\n", .{}); | ||
| 169 | std.debug.print(row_fmt, .{ "total", stats.total.min, stats.total.avg, stats.total.p99, stats.total.max }); | ||
| 170 | } | ||
| 171 | ``` | ||
| 172 | |||
| 173 | - [ ] **Step 2: Update all call sites in `src/main.zig`** | ||
| 174 | |||
| 175 | Run: `grep -n "printFrameStats" src/main.zig` | ||
| 176 | For each call, pass `cols, rows` as additional args. E.g. `printFrameStats(computeFrameStats(&frame_ring), cols, rows);` | ||
| 177 | |||
| 178 | - [ ] **Step 3: Build and test** | ||
| 179 | |||
| 180 | Run: `zig build && zig build test` | ||
| 181 | Expected: PASS. | ||
| 182 | |||
| 183 | - [ ] **Step 4: Commit** | ||
| 184 | |||
| 185 | ```bash | ||
| 186 | git add src/main.zig src/bench_stats.zig | ||
| 187 | git commit -m "bench: include grid size in stats header" | ||
| 188 | ``` | ||
| 189 | |||
| 190 | --- | ||
| 191 | |||
| 192 | ## Phase 2 — Frame-counter plumbing | ||
| 193 | |||
| 194 | ### Task 2.1: Add `frame_counter` field to `FrameTiming` | ||
| 195 | |||
| 196 | **Files:** | ||
| 197 | - Modify: `src/bench_stats.zig:3-24` (`FrameTiming` struct) | ||
| 198 | |||
| 199 | - [ ] **Step 1: Extend the struct** | ||
| 200 | |||
| 201 | Add `frame_counter: u64 = 0,` as a field on `FrameTiming` (keep `.total()` unchanged — counter is metadata, not timing): | ||
| 202 | |||
| 203 | ```zig | ||
| 204 | pub const FrameTiming = struct { | ||
| 205 | frame_counter: u64 = 0, | ||
| 206 | snapshot_us: u32 = 0, | ||
| 207 | row_rebuild_us: u32 = 0, | ||
| 208 | atlas_upload_us: u32 = 0, | ||
| 209 | instance_upload_us: u32 = 0, | ||
| 210 | gpu_submit_us: u32 = 0, | ||
| 211 | wait_fences_us: u32 = 0, | ||
| 212 | acquire_us: u32 = 0, | ||
| 213 | record_us: u32 = 0, | ||
| 214 | submit_us: u32 = 0, | ||
| 215 | present_us: u32 = 0, | ||
| 216 | |||
| 217 | pub fn total(self: FrameTiming) u32 { | ||
| 218 | return self.snapshot_us + | ||
| 219 | self.row_rebuild_us + | ||
| 220 | self.atlas_upload_us + | ||
| 221 | self.instance_upload_us + | ||
| 222 | self.gpu_submit_us; | ||
| 223 | } | ||
| 224 | }; | ||
| 225 | ``` | ||
| 226 | |||
| 227 | - [ ] **Step 2: Update CSV writer to include frame_counter column** | ||
| 228 | |||
| 229 | In `writeFrameCsv` (around `src/bench_stats.zig:124`), change the header and row: | ||
| 230 | |||
| 231 | ```zig | ||
| 232 | _ = try file.write("frame_counter,frame_idx,snapshot_us,row_rebuild_us,atlas_upload_us,instance_upload_us,gpu_submit_us,wait_fences_us,acquire_us,record_us,submit_us,present_us,total_us\n"); | ||
| 233 | for (entries, 0..) |e, i| { | ||
| 234 | const line = try std.fmt.bufPrint(&buf, "{d},{d},{d},{d},{d},{d},{d},{d},{d},{d},{d},{d},{d}\n", .{ | ||
| 235 | e.frame_counter, | ||
| 236 | i, | ||
| 237 | e.snapshot_us, | ||
| 238 | e.row_rebuild_us, | ||
| 239 | e.atlas_upload_us, | ||
| 240 | e.instance_upload_us, | ||
| 241 | e.gpu_submit_us, | ||
| 242 | e.wait_fences_us, | ||
| 243 | e.acquire_us, | ||
| 244 | e.record_us, | ||
| 245 | e.submit_us, | ||
| 246 | e.present_us, | ||
| 247 | e.total(), | ||
| 248 | }); | ||
| 249 | _ = try file.write(line); | ||
| 250 | } | ||
| 251 | ``` | ||
| 252 | |||
| 253 | - [ ] **Step 3: Increment in main loop** | ||
| 254 | |||
| 255 | In `src/main.zig`, add near the other `var` declarations before the main loop (around `src/main.zig:339`): | ||
| 256 | |||
| 257 | ```zig | ||
| 258 | var frame_counter: u64 = 0; | ||
| 259 | ``` | ||
| 260 | |||
| 261 | At the end of each rendered frame (where the ring push happens — grep `frame_ring.push` to find it), set the counter on the timing struct before pushing, then increment: | ||
| 262 | |||
| 263 | ```zig | ||
| 264 | timing.frame_counter = frame_counter; | ||
| 265 | frame_ring.push(timing); | ||
| 266 | frame_counter +%= 1; | ||
| 267 | ``` | ||
| 268 | |||
| 269 | (Use the exact local name of the timing variable at that site; adjust if named differently.) | ||
| 270 | |||
| 271 | - [ ] **Step 4: Build and test** | ||
| 272 | |||
| 273 | Run: `zig build && zig build test` | ||
| 274 | Expected: PASS. Existing tests continue to pass (they don't touch `frame_counter`). | ||
| 275 | |||
| 276 | - [ ] **Step 5: Commit** | ||
| 277 | |||
| 278 | ```bash | ||
| 279 | git add src/bench_stats.zig src/main.zig | ||
| 280 | git commit -m "bench: add frame_counter to FrameTiming for sample correlation" | ||
| 281 | ``` | ||
| 282 | |||
| 283 | ### Task 2.2: Test that `frame_counter` round-trips through the ring | ||
| 284 | |||
| 285 | **Files:** | ||
| 286 | - Modify: `src/bench_stats.zig` (add new test) | ||
| 287 | |||
| 288 | - [ ] **Step 1: Add test** | ||
| 289 | |||
| 290 | Append to the test block: | ||
| 291 | |||
| 292 | ```zig | ||
| 293 | test "FrameTimingRing preserves frame_counter through wrap" { | ||
| 294 | var ring = FrameTimingRing{}; | ||
| 295 | for (0..FrameTimingRing.capacity + 5) |i| { | ||
| 296 | ring.push(.{ .frame_counter = i, .snapshot_us = @intCast(i) }); | ||
| 297 | } | ||
| 298 | var buf: [FrameTimingRing.capacity]FrameTiming = undefined; | ||
| 299 | const ordered = ring.orderedSlice(&buf); | ||
| 300 | try std.testing.expectEqual(@as(u64, 5), ordered[0].frame_counter); | ||
| 301 | try std.testing.expectEqual(@as(u64, FrameTimingRing.capacity + 4), ordered[ordered.len - 1].frame_counter); | ||
| 302 | } | ||
| 303 | ``` | ||
| 304 | |||
| 305 | - [ ] **Step 2: Run test** | ||
| 306 | |||
| 307 | Run: `zig build test 2>&1 | grep -E "PASS|FAIL|error"` | ||
| 308 | Expected: test PASSes. | ||
| 309 | |||
| 310 | - [ ] **Step 3: Commit** | ||
| 311 | |||
| 312 | ```bash | ||
| 313 | git add src/bench_stats.zig | ||
| 314 | git commit -m "bench: test frame_counter preservation across ring wrap" | ||
| 315 | ``` | ||
| 316 | |||
| 317 | --- | ||
| 318 | |||
| 319 | ## Phase 3 — `wp_presentation_time` protocol binding | ||
| 320 | |||
| 321 | ### Task 3.1: Add protocol XML to the Wayland scanner | ||
| 322 | |||
| 323 | **Files:** | ||
| 324 | - Modify: `build.zig:35-40` | ||
| 325 | |||
| 326 | - [ ] **Step 1: Register the protocol** | ||
| 327 | |||
| 328 | After `scanner.addSystemProtocol("stable/xdg-shell/xdg-shell.xml");`: | ||
| 329 | |||
| 330 | ```zig | ||
| 331 | scanner.addSystemProtocol("stable/presentation-time/presentation-time.xml"); | ||
| 332 | ``` | ||
| 333 | |||
| 334 | And after `scanner.generate("xdg_wm_base", 6);`: | ||
| 335 | |||
| 336 | ```zig | ||
| 337 | scanner.generate("wp_presentation", 1); | ||
| 338 | ``` | ||
| 339 | |||
| 340 | - [ ] **Step 2: Build** | ||
| 341 | |||
| 342 | Run: `zig build` | ||
| 343 | Expected: PASS. (Requires `wayland-protocols` system package — if missing, install it via the distro's wayland-protocols dev package.) | ||
| 344 | |||
| 345 | - [ ] **Step 3: Commit** | ||
| 346 | |||
| 347 | ```bash | ||
| 348 | git add build.zig | ||
| 349 | git commit -m "bench: register wp_presentation_time protocol in build.zig" | ||
| 350 | ``` | ||
| 351 | |||
| 352 | ### Task 3.2: Bind `wp_presentation` global in the Wayland layer | ||
| 353 | |||
| 354 | **Files:** | ||
| 355 | - Modify: `src/wayland.zig` — find the `Globals` struct and the registry listener | ||
| 356 | |||
| 357 | - [ ] **Step 1: Find the Globals struct** | ||
| 358 | |||
| 359 | Run: `grep -n "struct.*Globals\|pub const Globals\|globals:\|seat: ?\|compositor: ?" src/wayland.zig | head -20` | ||
| 360 | |||
| 361 | Locate where other globals like `seat`, `compositor`, `data_device_manager` are declared. Add a new field: | ||
| 362 | |||
| 363 | ```zig | ||
| 364 | wp_presentation: ?*wp.Presentation = null, | ||
| 365 | ``` | ||
| 366 | |||
| 367 | (Adjust the Wayland protocol import — the generated module exposes `wp` as a namespace; follow the pattern already used for `xdg`/`wl`.) | ||
| 368 | |||
| 369 | - [ ] **Step 2: Handle the global in the registry listener** | ||
| 370 | |||
| 371 | Find the `registryListener` (or similarly named) that dispatches `registry.global` events. In the switch on interface name, add a branch for `wp_presentation`: | ||
| 372 | |||
| 373 | ```zig | ||
| 374 | } else if (std.mem.eql(u8, interface, "wp_presentation")) { | ||
| 375 | globals.wp_presentation = registry.bind(name, wp.Presentation, 1) catch null; | ||
| 376 | } | ||
| 377 | ``` | ||
| 378 | |||
| 379 | (Follow the exact pattern of neighboring `std.mem.eql(u8, interface, "wl_seat")` branches.) | ||
| 380 | |||
| 381 | - [ ] **Step 3: Import the generated namespace at the top of `src/wayland.zig`** | ||
| 382 | |||
| 383 | Find the existing `const wl = @import("wayland").client.wl;` line and add a parallel: | ||
| 384 | |||
| 385 | ```zig | ||
| 386 | const wp = @import("wayland").client.wp; | ||
| 387 | ``` | ||
| 388 | |||
| 389 | (If the generated module uses a different namespace (e.g. `wp_presentation` rather than `wp`), use whatever the scanner emits — check `zig-cache`'s generated wayland.zig.) | ||
| 390 | |||
| 391 | - [ ] **Step 4: Build** | ||
| 392 | |||
| 393 | Run: `zig build` | ||
| 394 | Expected: PASS. | ||
| 395 | |||
| 396 | - [ ] **Step 5: Commit** | ||
| 397 | |||
| 398 | ```bash | ||
| 399 | git add src/wayland.zig | ||
| 400 | git commit -m "bench: bind wp_presentation global" | ||
| 401 | ``` | ||
| 402 | |||
| 403 | ### Task 3.3: Wrap `wp_presentation_feedback` with a Zig-friendly callback | ||
| 404 | |||
| 405 | **Files:** | ||
| 406 | - Modify: `src/wayland.zig` | ||
| 407 | |||
| 408 | - [ ] **Step 1: Add a `PresentationFeedback` wrapper type** | ||
| 409 | |||
| 410 | Near the existing Window / Keyboard types, add: | ||
| 411 | |||
| 412 | ```zig | ||
| 413 | pub const PresentationFeedback = struct { | ||
| 414 | pub const Event = union(enum) { | ||
| 415 | presented: struct { tv_sec: u64, tv_nsec: u32, refresh: u32, flags: u32 }, | ||
| 416 | discarded: void, | ||
| 417 | }; | ||
| 418 | |||
| 419 | feedback: *wp.PresentationFeedback, | ||
| 420 | user_data: ?*anyopaque = null, | ||
| 421 | callback: ?*const fn (user_data: ?*anyopaque, ev: Event) void = null, | ||
| 422 | |||
| 423 | pub fn init( | ||
| 424 | presentation: *wp.Presentation, | ||
| 425 | surface: *wl.Surface, | ||
| 426 | user_data: ?*anyopaque, | ||
| 427 | callback: *const fn (?*anyopaque, Event) void, | ||
| 428 | ) !*PresentationFeedback { | ||
| 429 | const alloc = std.heap.c_allocator; // arena-free, lives until presented/discarded | ||
| 430 | const self = try alloc.create(PresentationFeedback); | ||
| 431 | self.* = .{ | ||
| 432 | .feedback = try presentation.feedback(surface), | ||
| 433 | .user_data = user_data, | ||
| 434 | .callback = callback, | ||
| 435 | }; | ||
| 436 | self.feedback.setListener(*PresentationFeedback, feedbackListener, self); | ||
| 437 | return self; | ||
| 438 | } | ||
| 439 | |||
| 440 | fn feedbackListener( | ||
| 441 | _: *wp.PresentationFeedback, | ||
| 442 | event: wp.PresentationFeedback.Event, | ||
| 443 | self: *PresentationFeedback, | ||
| 444 | ) void { | ||
| 445 | switch (event) { | ||
| 446 | .presented => |p| { | ||
| 447 | const tv_sec = (@as(u64, p.tv_sec_hi) << 32) | p.tv_sec_lo; | ||
| 448 | if (self.callback) |cb| { | ||
| 449 | cb(self.user_data, .{ .presented = .{ | ||
| 450 | .tv_sec = tv_sec, | ||
| 451 | .tv_nsec = p.tv_nsec, | ||
| 452 | .refresh = p.refresh, | ||
| 453 | .flags = @bitCast(p.flags), | ||
| 454 | } }); | ||
| 455 | } | ||
| 456 | self.destroy(); | ||
| 457 | }, | ||
| 458 | .discarded => { | ||
| 459 | if (self.callback) |cb| cb(self.user_data, .discarded); | ||
| 460 | self.destroy(); | ||
| 461 | }, | ||
| 462 | else => {}, // sync_output events — ignore | ||
| 463 | } | ||
| 464 | } | ||
| 465 | |||
| 466 | fn destroy(self: *PresentationFeedback) void { | ||
| 467 | self.feedback.destroy(); | ||
| 468 | std.heap.c_allocator.destroy(self); | ||
| 469 | } | ||
| 470 | }; | ||
| 471 | ``` | ||
| 472 | |||
| 473 | (Allocator choice: `c_allocator` because lifetime is tied to async Wayland events, not main-loop ownership. If the project already has a conventional allocator for this, use it.) | ||
| 474 | |||
| 475 | - [ ] **Step 2: Build** | ||
| 476 | |||
| 477 | Run: `zig build` | ||
| 478 | Expected: PASS. Fix any compilation errors (wp event names differ slightly in generated bindings — check `zig build --verbose` for exact field names). | ||
| 479 | |||
| 480 | - [ ] **Step 3: Commit** | ||
| 481 | |||
| 482 | ```bash | ||
| 483 | git add src/wayland.zig | ||
| 484 | git commit -m "bench: add PresentationFeedback wrapper with typed callback" | ||
| 485 | ``` | ||
| 486 | |||
| 487 | ### Task 3.4: Smoke-test `wp_presentation` under sway | ||
| 488 | |||
| 489 | **Files:** | ||
| 490 | - Create: `src/tools/presentation_smoke.zig` (new, small standalone program) | ||
| 491 | |||
| 492 | - [ ] **Step 1: Write a minimal smoke test** | ||
| 493 | |||
| 494 | ```zig | ||
| 495 | const std = @import("std"); | ||
| 496 | const wayland_client = @import("wayland-client"); | ||
| 497 | |||
| 498 | pub fn main() !void { | ||
| 499 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 500 | defer _ = gpa.deinit(); | ||
| 501 | const alloc = gpa.allocator(); | ||
| 502 | |||
| 503 | const conn = try wayland_client.Connection.init(alloc); | ||
| 504 | defer conn.deinit(); | ||
| 505 | |||
| 506 | if (conn.globals.wp_presentation == null) { | ||
| 507 | std.debug.print("FAIL: wp_presentation global not advertised by compositor\n", .{}); | ||
| 508 | std.process.exit(1); | ||
| 509 | } | ||
| 510 | std.debug.print("OK: wp_presentation bound\n", .{}); | ||
| 511 | } | ||
| 512 | ``` | ||
| 513 | |||
| 514 | - [ ] **Step 2: Add build step in `build.zig`** | ||
| 515 | |||
| 516 | After other tools (grep for `bench_baseline` to find the pattern), add: | ||
| 517 | |||
| 518 | ```zig | ||
| 519 | const presentation_smoke_exe = b.addExecutable(.{ | ||
| 520 | .name = "presentation-smoke", | ||
| 521 | .root_source_file = b.path("src/tools/presentation_smoke.zig"), | ||
| 522 | .target = target, | ||
| 523 | .optimize = optimize, | ||
| 524 | }); | ||
| 525 | presentation_smoke_exe.root_module.addImport("wayland-client", wayland_mod); | ||
| 526 | const run_presentation_smoke = b.addRunArtifact(presentation_smoke_exe); | ||
| 527 | const smoke_step = b.step("presentation-smoke", "Verify wp_presentation binding"); | ||
| 528 | smoke_step.dependOn(&run_presentation_smoke.step); | ||
| 529 | ``` | ||
| 530 | |||
| 531 | - [ ] **Step 3: Run** | ||
| 532 | |||
| 533 | Run: `zig build presentation-smoke` | ||
| 534 | Expected: prints `OK: wp_presentation bound`. On compositors without the protocol, FAILs with a clear message. | ||
| 535 | |||
| 536 | - [ ] **Step 4: Commit** | ||
| 537 | |||
| 538 | ```bash | ||
| 539 | git add src/tools/presentation_smoke.zig build.zig | ||
| 540 | git commit -m "bench: add presentation-smoke tool for wp_presentation binding check" | ||
| 541 | ``` | ||
| 542 | |||
| 543 | ### Task 3.5: Request feedback per-frame in the renderer | ||
| 544 | |||
| 545 | **Files:** | ||
| 546 | - Modify: `src/renderer.zig` (around the swapchain `queuePresentKHR` call) and `src/main.zig` | ||
| 547 | |||
| 548 | - [ ] **Step 1: Find the present call** | ||
| 549 | |||
| 550 | Run: `grep -n "queuePresentKHR\|present_info\|queue_present" src/renderer.zig` | ||
| 551 | Expected: one location where `vkQueuePresentKHR` is invoked. | ||
| 552 | |||
| 553 | - [ ] **Step 2: Expose a pre-present hook** | ||
| 554 | |||
| 555 | Add a function pointer field on the renderer context (or equivalent) that's called just before `queuePresentKHR`: | ||
| 556 | |||
| 557 | ```zig | ||
| 558 | // In the Context struct definition | ||
| 559 | pre_present_hook: ?*const fn (ctx: ?*anyopaque) void = null, | ||
| 560 | pre_present_ctx: ?*anyopaque = null, | ||
| 561 | ``` | ||
| 562 | |||
| 563 | Immediately before the `queuePresentKHR` call site: | ||
| 564 | |||
| 565 | ```zig | ||
| 566 | if (ctx.pre_present_hook) |h| h(ctx.pre_present_ctx); | ||
| 567 | ``` | ||
| 568 | |||
| 569 | - [ ] **Step 3: Wire the hook from main** | ||
| 570 | |||
| 571 | In `src/main.zig`, after the bench driver is initialized (Task 4.1+), set: | ||
| 572 | |||
| 573 | ```zig | ||
| 574 | ctx.pre_present_hook = &benchPrePresentHook; | ||
| 575 | ctx.pre_present_ctx = &bench_driver; | ||
| 576 | ``` | ||
| 577 | |||
| 578 | For now, leave the hook body as a placeholder fn that does nothing — actual feedback-request logic lands in Phase 6. Define: | ||
| 579 | |||
| 580 | ```zig | ||
| 581 | fn benchPrePresentHook(opaque_ctx: ?*anyopaque) void { | ||
| 582 | _ = opaque_ctx; | ||
| 583 | // Populated in Task 6.3 | ||
| 584 | } | ||
| 585 | ``` | ||
| 586 | |||
| 587 | - [ ] **Step 4: Build** | ||
| 588 | |||
| 589 | Run: `zig build` | ||
| 590 | Expected: PASS. | ||
| 591 | |||
| 592 | - [ ] **Step 5: Commit** | ||
| 593 | |||
| 594 | ```bash | ||
| 595 | git add src/renderer.zig src/main.zig | ||
| 596 | git commit -m "bench: add pre-present hook in renderer for wp_presentation_feedback" | ||
| 597 | ``` | ||
| 598 | |||
| 599 | --- | ||
| 600 | |||
| 601 | ## Phase 4 — BenchDriver skeleton + PTY plumbing | ||
| 602 | |||
| 603 | ### Task 4.1: Env parsing + `Scenario` enum | ||
| 604 | |||
| 605 | **Files:** | ||
| 606 | - Create: `src/bench_input.zig` | ||
| 607 | - Modify: `build.zig` (register module) | ||
| 608 | - Modify: `src/main.zig` | ||
| 609 | |||
| 610 | - [ ] **Step 1: Create `src/bench_input.zig` scaffold** | ||
| 611 | |||
| 612 | ```zig | ||
| 613 | const std = @import("std"); | ||
| 614 | |||
| 615 | pub const Scenario = enum { | ||
| 616 | cold, | ||
| 617 | hot, | ||
| 618 | both, | ||
| 619 | |||
| 620 | pub fn parse(s: []const u8) ?Scenario { | ||
| 621 | if (std.mem.eql(u8, s, "cold")) return .cold; | ||
| 622 | if (std.mem.eql(u8, s, "hot")) return .hot; | ||
| 623 | if (std.mem.eql(u8, s, "both")) return .both; | ||
| 624 | if (std.mem.eql(u8, s, "1")) return .both; // default when set to any truthy | ||
| 625 | return null; | ||
| 626 | } | ||
| 627 | }; | ||
| 628 | |||
| 629 | pub const Config = struct { | ||
| 630 | scenario: Scenario, | ||
| 631 | samples_per_scenario: u32 = 500, | ||
| 632 | max_frames_per_sample: u32 = 60, | ||
| 633 | cols: u16 = 80, | ||
| 634 | rows: u16 = 24, | ||
| 635 | }; | ||
| 636 | |||
| 637 | pub fn readConfigFromEnv() ?Config { | ||
| 638 | const val = std.posix.getenv("WAYSTTY_INPUT_BENCH") orelse return null; | ||
| 639 | const sc = Scenario.parse(val) orelse { | ||
| 640 | std.debug.print("WAYSTTY_INPUT_BENCH: invalid scenario '{s}', expected cold|hot|both\n", .{val}); | ||
| 641 | std.process.exit(2); | ||
| 642 | }; | ||
| 643 | return .{ | ||
| 644 | .scenario = sc, | ||
| 645 | .cols = if (std.posix.getenv("WAYSTTY_BENCH_COLS")) |s| | ||
| 646 | (std.fmt.parseInt(u16, s, 10) catch 80) | ||
| 647 | else 80, | ||
| 648 | .rows = if (std.posix.getenv("WAYSTTY_BENCH_ROWS")) |s| | ||
| 649 | (std.fmt.parseInt(u16, s, 10) catch 24) | ||
| 650 | else 24, | ||
| 651 | }; | ||
| 652 | } | ||
| 653 | |||
| 654 | test "Scenario.parse" { | ||
| 655 | try std.testing.expectEqual(@as(?Scenario, .cold), Scenario.parse("cold")); | ||
| 656 | try std.testing.expectEqual(@as(?Scenario, .hot), Scenario.parse("hot")); | ||
| 657 | try std.testing.expectEqual(@as(?Scenario, .both), Scenario.parse("both")); | ||
| 658 | try std.testing.expectEqual(@as(?Scenario, null), Scenario.parse("nope")); | ||
| 659 | } | ||
| 660 | ``` | ||
| 661 | |||
| 662 | - [ ] **Step 2: Register module in `build.zig`** | ||
| 663 | |||
| 664 | After other module declarations (grep `bench_stats_mod` pattern), add: | ||
| 665 | |||
| 666 | ```zig | ||
| 667 | const bench_input_mod = b.createModule(.{ | ||
| 668 | .root_source_file = b.path("src/bench_input.zig"), | ||
| 669 | .target = target, | ||
| 670 | .optimize = optimize, | ||
| 671 | }); | ||
| 672 | ``` | ||
| 673 | |||
| 674 | And at the waystty executable's `addImport` block, add: | ||
| 675 | |||
| 676 | ```zig | ||
| 677 | exe.root_module.addImport("bench_input", bench_input_mod); | ||
| 678 | ``` | ||
| 679 | |||
| 680 | Also add a test step for it: | ||
| 681 | |||
| 682 | ```zig | ||
| 683 | const bench_input_tests = b.addTest(.{ .root_module = bench_input_mod }); | ||
| 684 | const run_bench_input_tests = b.addRunArtifact(bench_input_tests); | ||
| 685 | const test_step = b.step("test", "Run tests"); // if a test step already exists, just add the dep | ||
| 686 | test_step.dependOn(&run_bench_input_tests.step); | ||
| 687 | ``` | ||
| 688 | |||
| 689 | (If there's already a `test` step — check with `grep "b.step(\"test\"" build.zig` — add `test_step.dependOn(&run_bench_input_tests.step);` to the existing one.) | ||
| 690 | |||
| 691 | - [ ] **Step 3: Run tests** | ||
| 692 | |||
| 693 | Run: `zig build test 2>&1 | tail -20` | ||
| 694 | Expected: `Scenario.parse` passes. | ||
| 695 | |||
| 696 | - [ ] **Step 4: Commit** | ||
| 697 | |||
| 698 | ```bash | ||
| 699 | git add src/bench_input.zig build.zig | ||
| 700 | git commit -m "bench: create bench_input module with Scenario enum + Config" | ||
| 701 | ``` | ||
| 702 | |||
| 703 | ### Task 4.2: PTY termios ECHO verification helper | ||
| 704 | |||
| 705 | **Files:** | ||
| 706 | - Modify: `src/pty.zig` | ||
| 707 | |||
| 708 | - [ ] **Step 1: Find PTY spawn** | ||
| 709 | |||
| 710 | Run: `grep -n "pub fn spawn\|tcsetattr\|termios\|ECHO" src/pty.zig | head` | ||
| 711 | |||
| 712 | - [ ] **Step 2: Add a helper** | ||
| 713 | |||
| 714 | Near the existing `spawn` method: | ||
| 715 | |||
| 716 | ```zig | ||
| 717 | pub fn ensureEcho(slave_fd: std.posix.fd_t) !void { | ||
| 718 | var tio: std.posix.termios = undefined; | ||
| 719 | try std.posix.tcgetattr(slave_fd, &tio); | ||
| 720 | if ((tio.lflag & std.posix.system.linux.ECHO) == 0) { | ||
| 721 | tio.lflag |= std.posix.system.linux.ECHO; | ||
| 722 | try std.posix.tcsetattr(slave_fd, .NOW, tio); | ||
| 723 | } | ||
| 724 | } | ||
| 725 | ``` | ||
| 726 | |||
| 727 | (Adjust namespaces if Zig stdlib differs slightly — find by grepping `tcgetattr` in the stdlib.) | ||
| 728 | |||
| 729 | - [ ] **Step 3: Build** | ||
| 730 | |||
| 731 | Run: `zig build` | ||
| 732 | Expected: PASS. | ||
| 733 | |||
| 734 | - [ ] **Step 4: Commit** | ||
| 735 | |||
| 736 | ```bash | ||
| 737 | git add src/pty.zig | ||
| 738 | git commit -m "bench: add Pty.ensureEcho helper" | ||
| 739 | ``` | ||
| 740 | |||
| 741 | ### Task 4.3: Cold PTY spawn (`cat > /dev/null`) | ||
| 742 | |||
| 743 | **Files:** | ||
| 744 | - Modify: `src/main.zig` (spawn block around lines 276-304) | ||
| 745 | |||
| 746 | - [ ] **Step 1: Extend the shell-selection logic** | ||
| 747 | |||
| 748 | Replace the block from `const is_bench = ...` through `defer p.deinit();` (roughly `src/main.zig:276-305`): | ||
| 749 | |||
| 750 | ```zig | ||
| 751 | const bench_input_cfg = @import("bench_input").readConfigFromEnv(); | ||
| 752 | const is_output_bench = std.posix.getenv("WAYSTTY_BENCH") != null; | ||
| 753 | const bench_unthrottled = is_output_bench and std.posix.getenv("WAYSTTY_BENCH_UNTHROTTLED") != null; | ||
| 754 | |||
| 755 | // Shell + args choice | ||
| 756 | const ShellPlan = struct { | ||
| 757 | shell: [:0]const u8, | ||
| 758 | args: ?[]const [:0]const u8, | ||
| 759 | }; | ||
| 760 | const shell_plan: ShellPlan = if (bench_input_cfg) |cfg| blk: { | ||
| 761 | const sh_args: []const [:0]const u8 = switch (cfg.scenario) { | ||
| 762 | .cold, .both => &.{ "-c", "cat > /dev/null" }, | ||
| 763 | .hot => &.{ "-c", "yes \"$(printf 'x%.0s' {1..500})\" | pv -qL 24K" }, | ||
| 764 | }; | ||
| 765 | break :blk .{ .shell = try alloc.dupeZ(u8, "/bin/sh"), .args = sh_args }; | ||
| 766 | } else if (is_output_bench) blk: { | ||
| 767 | break :blk .{ .shell = try alloc.dupeZ(u8, "/bin/sh"), .args = null }; | ||
| 768 | } else blk: { | ||
| 769 | const shell_env = std.posix.getenv("SHELL") orelse "/bin/sh"; | ||
| 770 | break :blk .{ .shell = try alloc.dupeZ(u8, shell_env), .args = null }; | ||
| 771 | }; | ||
| 772 | defer alloc.free(shell_plan.shell); | ||
| 773 | |||
| 774 | const bench_script: ?[:0]const u8 = if (is_output_bench) | ||
| 775 | @embedFile("bench_workload") | ||
| 776 | else | ||
| 777 | null; | ||
| 778 | |||
| 779 | if (is_output_bench) { | ||
| 780 | if (bench_unthrottled) { | ||
| 781 | std.debug.print("[bench] mode: UNTHROTTLED (not freeze-safe)\n", .{}); | ||
| 782 | } else { | ||
| 783 | std.debug.print("[bench] mode: THROTTLED (vsync-paced)\n", .{}); | ||
| 784 | } | ||
| 785 | } | ||
| 786 | if (bench_input_cfg) |cfg| { | ||
| 787 | std.debug.print("[input-bench] scenario: {s}, grid: {d}x{d}\n", .{ @tagName(cfg.scenario), cfg.cols, cfg.rows }); | ||
| 788 | } | ||
| 789 | |||
| 790 | const pty_args = if (shell_plan.args) |a| a else if (bench_script) |script| &[_][:0]const u8{ "-c", script } else null; | ||
| 791 | |||
| 792 | var p = try pty.Pty.spawn(.{ | ||
| 793 | .cols = cols, | ||
| 794 | .rows = rows, | ||
| 795 | .shell = shell_plan.shell, | ||
| 796 | .shell_args = pty_args, | ||
| 797 | }); | ||
| 798 | defer p.deinit(); | ||
| 799 | try pty.Pty.ensureEcho(p.slave_fd); // if slave_fd isn't public, expose it; otherwise do inside Pty.spawn | ||
| 800 | term.setWritePtyCallback(&p, &writePtyFromTerminal); | ||
| 801 | ``` | ||
| 802 | |||
| 803 | (If `p.slave_fd` is not exposed, modify `src/pty.zig` to expose it, or call `ensureEcho` from inside `Pty.spawn`.) | ||
| 804 | |||
| 805 | - [ ] **Step 2: Build and smoke-test cold** | ||
| 806 | |||
| 807 | Run: `zig build && WAYSTTY_INPUT_BENCH=cold WAYSTTY_BENCH_COLS=80 WAYSTTY_BENCH_ROWS=24 ./zig-out/bin/waystty 2>/tmp/bench-cold.log &` | ||
| 808 | |||
| 809 | Let it run ~2s, then kill. Inspect `/tmp/bench-cold.log` — should show the `[input-bench] scenario: cold` line. | ||
| 810 | |||
| 811 | - [ ] **Step 3: Commit** | ||
| 812 | |||
| 813 | ```bash | ||
| 814 | git add src/main.zig src/pty.zig | ||
| 815 | git commit -m "bench: spawn bench-specific PTY children for cold/hot scenarios" | ||
| 816 | ``` | ||
| 817 | |||
| 818 | ### Task 4.4: `pv` availability check for hot mode | ||
| 819 | |||
| 820 | **Files:** | ||
| 821 | - Modify: `src/main.zig` (inside the hot-scenario branch) | ||
| 822 | |||
| 823 | - [ ] **Step 1: Add a pre-spawn check** | ||
| 824 | |||
| 825 | Before the hot branch resolves the args, add: | ||
| 826 | |||
| 827 | ```zig | ||
| 828 | fn assertPvAvailable(alloc: std.mem.Allocator) void { | ||
| 829 | const res = std.process.Child.run(.{ | ||
| 830 | .allocator = alloc, | ||
| 831 | .argv = &.{ "sh", "-c", "command -v pv" }, | ||
| 832 | }) catch { | ||
| 833 | std.debug.print("waystty input-bench hot: `pv` not found. Install with your package manager (e.g. `pacman -S pv`).\n", .{}); | ||
| 834 | std.process.exit(2); | ||
| 835 | }; | ||
| 836 | alloc.free(res.stdout); | ||
| 837 | alloc.free(res.stderr); | ||
| 838 | if (res.term != .Exited or res.term.Exited != 0) { | ||
| 839 | std.debug.print("waystty input-bench hot: `pv` not found. Install with your package manager (e.g. `pacman -S pv`).\n", .{}); | ||
| 840 | std.process.exit(2); | ||
| 841 | } | ||
| 842 | } | ||
| 843 | ``` | ||
| 844 | |||
| 845 | Call it in the hot arm: | ||
| 846 | |||
| 847 | ```zig | ||
| 848 | .hot => blk: { | ||
| 849 | assertPvAvailable(alloc); | ||
| 850 | break :blk &.{ "-c", "yes \"$(printf 'x%.0s' {1..500})\" | pv -qL 24K" }; | ||
| 851 | }, | ||
| 852 | ``` | ||
| 853 | |||
| 854 | - [ ] **Step 2: Manual test without pv** | ||
| 855 | |||
| 856 | Run: `PATH=/usr/bin:/bin WAYSTTY_INPUT_BENCH=hot WAYSTTY_BENCH_COLS=80 ./zig-out/bin/waystty 2>/tmp/bench-hot.log || echo "exit $?"` | ||
| 857 | Expected: If `pv` is present, runs; otherwise exits with 2 and the diagnostic. | ||
| 858 | |||
| 859 | - [ ] **Step 3: Commit** | ||
| 860 | |||
| 861 | ```bash | ||
| 862 | git add src/main.zig | ||
| 863 | git commit -m "bench: fail loudly if pv is missing for hot scenario" | ||
| 864 | ``` | ||
| 865 | |||
| 866 | ### Task 4.5: Child teardown (SIGTERM → 100ms → SIGKILL → waitpid) | ||
| 867 | |||
| 868 | **Files:** | ||
| 869 | - Modify: `src/pty.zig` | ||
| 870 | |||
| 871 | - [ ] **Step 1: Add a `gracefulTeardown` method on Pty** | ||
| 872 | |||
| 873 | ```zig | ||
| 874 | pub fn gracefulTeardown(self: *Pty) void { | ||
| 875 | if (self.pid <= 0) return; | ||
| 876 | _ = std.posix.kill(self.pid, std.posix.SIG.TERM) catch {}; | ||
| 877 | // poll waitpid up to 100ms | ||
| 878 | var elapsed_ms: u32 = 0; | ||
| 879 | while (elapsed_ms < 100) : (elapsed_ms += 10) { | ||
| 880 | const res = std.posix.waitpid(self.pid, std.posix.W.NOHANG); | ||
| 881 | if (res.pid != 0) return; | ||
| 882 | std.Thread.sleep(10 * std.time.ns_per_ms); | ||
| 883 | } | ||
| 884 | _ = std.posix.kill(self.pid, std.posix.SIG.KILL) catch {}; | ||
| 885 | _ = std.posix.waitpid(self.pid, 0); | ||
| 886 | } | ||
| 887 | ``` | ||
| 888 | |||
| 889 | (Cross-check exact `waitpid` / `WNOHANG` / `SIG.TERM` spellings against Zig stdlib.) | ||
| 890 | |||
| 891 | - [ ] **Step 2: Call from deinit or bench scenario switch** | ||
| 892 | |||
| 893 | Make `Pty.deinit` call `gracefulTeardown` before closing fds if the child is still running. For `both` scenario switching, expose a public method to call explicitly. | ||
| 894 | |||
| 895 | - [ ] **Step 3: Build** | ||
| 896 | |||
| 897 | Run: `zig build` | ||
| 898 | Expected: PASS. | ||
| 899 | |||
| 900 | - [ ] **Step 4: Commit** | ||
| 901 | |||
| 902 | ```bash | ||
| 903 | git add src/pty.zig | ||
| 904 | git commit -m "bench: gracefulTeardown for PTY children (SIGTERM→grace→SIGKILL)" | ||
| 905 | ``` | ||
| 906 | |||
| 907 | ### Task 4.6: Suppress `.key` events in bench mode | ||
| 908 | |||
| 909 | **Files:** | ||
| 910 | - Modify: `src/main.zig:372-399` (keyboard event loop) | ||
| 911 | |||
| 912 | - [ ] **Step 1: Gate the `.key` processing** | ||
| 913 | |||
| 914 | Replace the loop at `src/main.zig:374-398`: | ||
| 915 | |||
| 916 | ```zig | ||
| 917 | for (keyboard.event_queue.items) |ev| { | ||
| 918 | if (ev.action == .release) continue; | ||
| 919 | if (bench_input_cfg != null) { | ||
| 920 | // Bench mode: drop real keyboard .key events so ambient typing | ||
| 921 | // can't perturb measurements. Modifiers/enter/leave/repeat state | ||
| 922 | // on the Keyboard struct still update via the listener callbacks. | ||
| 923 | continue; | ||
| 924 | } | ||
| 925 | // ... existing clipboard/paste/encode path | ||
| 926 | } | ||
| 927 | ``` | ||
| 928 | |||
| 929 | (Rewrap the remaining body unchanged under the `else` / after the continue.) | ||
| 930 | |||
| 931 | - [ ] **Step 2: Build and smoke-test** | ||
| 932 | |||
| 933 | Run: `zig build && WAYSTTY_INPUT_BENCH=cold ./zig-out/bin/waystty 2>/tmp/sm.log &` | ||
| 934 | |||
| 935 | Type in the window (focus must be on it); verify no characters appear. Kill. | ||
| 936 | |||
| 937 | - [ ] **Step 3: Commit** | ||
| 938 | |||
| 939 | ```bash | ||
| 940 | git add src/main.zig | ||
| 941 | git commit -m "bench: drop real keyboard .key events in input-bench mode" | ||
| 942 | ``` | ||
| 943 | |||
| 944 | --- | ||
| 945 | |||
| 946 | ## Phase 5 — Sentinel allocator + injection | ||
| 947 | |||
| 948 | ### Task 5.1: PUA sentinel allocator | ||
| 949 | |||
| 950 | **Files:** | ||
| 951 | - Modify: `src/bench_input.zig` | ||
| 952 | |||
| 953 | - [ ] **Step 1: Add SentinelAlloc** | ||
| 954 | |||
| 955 | Append to `src/bench_input.zig`: | ||
| 956 | |||
| 957 | ```zig | ||
| 958 | pub const SentinelAlloc = struct { | ||
| 959 | const PUA_START: u21 = 0xE000; | ||
| 960 | const PUA_COUNT: u32 = 4096; | ||
| 961 | |||
| 962 | next: u32 = 0, | ||
| 963 | |||
| 964 | pub fn take(self: *SentinelAlloc) u21 { | ||
| 965 | const idx = self.next % PUA_COUNT; | ||
| 966 | self.next +%= 1; | ||
| 967 | return PUA_START + @as(u21, @intCast(idx)); | ||
| 968 | } | ||
| 969 | }; | ||
| 970 | |||
| 971 | /// Encode a codepoint as UTF-8 into `buf`. Returns the length written. | ||
| 972 | pub fn encodeCodepoint(cp: u21, buf: *[4]u8) u3 { | ||
| 973 | const n = std.unicode.utf8Encode(cp, buf) catch unreachable; | ||
| 974 | return @intCast(n); | ||
| 975 | } | ||
| 976 | |||
| 977 | test "SentinelAlloc rotates through 4096 PUA codepoints" { | ||
| 978 | var a: SentinelAlloc = .{}; | ||
| 979 | const first = a.take(); | ||
| 980 | try std.testing.expectEqual(@as(u21, 0xE000), first); | ||
| 981 | for (1..4096) |_| _ = a.take(); | ||
| 982 | // Next should wrap to 0xE000 again | ||
| 983 | try std.testing.expectEqual(@as(u21, 0xE000), a.take()); | ||
| 984 | } | ||
| 985 | |||
| 986 | test "encodeCodepoint produces valid 3-byte UTF-8 for PUA" { | ||
| 987 | var buf: [4]u8 = undefined; | ||
| 988 | const n = encodeCodepoint(0xE000, &buf); | ||
| 989 | try std.testing.expectEqual(@as(u3, 3), n); | ||
| 990 | try std.testing.expectEqual(@as(u8, 0xEE), buf[0]); | ||
| 991 | try std.testing.expectEqual(@as(u8, 0x80), buf[1]); | ||
| 992 | try std.testing.expectEqual(@as(u8, 0x80), buf[2]); | ||
| 993 | } | ||
| 994 | ``` | ||
| 995 | |||
| 996 | - [ ] **Step 2: Run tests** | ||
| 997 | |||
| 998 | Run: `zig build test 2>&1 | tail` | ||
| 999 | Expected: both tests PASS. | ||
| 1000 | |||
| 1001 | - [ ] **Step 3: Commit** | ||
| 1002 | |||
| 1003 | ```bash | ||
| 1004 | git add src/bench_input.zig | ||
| 1005 | git commit -m "bench: SentinelAlloc and encodeCodepoint helpers" | ||
| 1006 | ``` | ||
| 1007 | |||
| 1008 | ### Task 5.2: Fabricate `KeyEvent` injector | ||
| 1009 | |||
| 1010 | **Files:** | ||
| 1011 | - Modify: `src/bench_input.zig` | ||
| 1012 | - Review: `src/wayland.zig` (Keyboard.KeyEvent type) | ||
| 1013 | |||
| 1014 | - [ ] **Step 1: Inspect KeyEvent type** | ||
| 1015 | |||
| 1016 | Run: `grep -n "pub const KeyEvent\|KeyEvent = struct\|action:.*\\.\\(press\\|release\\)\|utf8:" src/wayland.zig | head -10` | ||
| 1017 | |||
| 1018 | Note the exact field set — likely something like: | ||
| 1019 | ```zig | ||
| 1020 | pub const KeyEvent = struct { | ||
| 1021 | action: enum { press, release }, | ||
| 1022 | keysym: u32, | ||
| 1023 | serial: u32, | ||
| 1024 | utf8: [16]u8, | ||
| 1025 | utf8_len: u8, | ||
| 1026 | // ... possibly mods | ||
| 1027 | }; | ||
| 1028 | ``` | ||
| 1029 | |||
| 1030 | - [ ] **Step 2: Add `injectSentinel` in bench_input.zig** | ||
| 1031 | |||
| 1032 | ```zig | ||
| 1033 | const wayland_client = @import("wayland-client"); | ||
| 1034 | |||
| 1035 | pub fn injectSentinel( | ||
| 1036 | keyboard: *wayland_client.Keyboard, | ||
| 1037 | sentinel_cp: u21, | ||
| 1038 | ) !void { | ||
| 1039 | var utf8: [16]u8 = @splat(0); | ||
| 1040 | var enc: [4]u8 = undefined; | ||
| 1041 | const n = encodeCodepoint(sentinel_cp, &enc); | ||
| 1042 | @memcpy(utf8[0..n], enc[0..n]); | ||
| 1043 | |||
| 1044 | const ev = wayland_client.Keyboard.KeyEvent{ | ||
| 1045 | .action = .press, | ||
| 1046 | .keysym = 0, | ||
| 1047 | .serial = 0, | ||
| 1048 | .utf8 = utf8, | ||
| 1049 | .utf8_len = n, | ||
| 1050 | }; | ||
| 1051 | try keyboard.event_queue.append(ev); | ||
| 1052 | } | ||
| 1053 | ``` | ||
| 1054 | |||
| 1055 | (Adjust field names to the exact struct — fill with zeros for any required fields not shown above.) | ||
| 1056 | |||
| 1057 | - [ ] **Step 3: Build** | ||
| 1058 | |||
| 1059 | Run: `zig build` | ||
| 1060 | Expected: PASS. | ||
| 1061 | |||
| 1062 | - [ ] **Step 4: Commit** | ||
| 1063 | |||
| 1064 | ```bash | ||
| 1065 | git add src/bench_input.zig | ||
| 1066 | git commit -m "bench: injectSentinel pushes fabricated KeyEvent onto queue" | ||
| 1067 | ``` | ||
| 1068 | |||
| 1069 | --- | ||
| 1070 | |||
| 1071 | ## Phase 6 — Pair-on-arrival matching | ||
| 1072 | |||
| 1073 | ### Task 6.1: `Sample` and `BenchDriver` skeletons | ||
| 1074 | |||
| 1075 | **Files:** | ||
| 1076 | - Modify: `src/bench_input.zig` | ||
| 1077 | |||
| 1078 | - [ ] **Step 1: Add Sample + state** | ||
| 1079 | |||
| 1080 | ```zig | ||
| 1081 | pub const Sample = struct { | ||
| 1082 | sentinel: u21, | ||
| 1083 | t_inject_ns: u64, | ||
| 1084 | injected_frame: u64, | ||
| 1085 | grid_seen_frame: ?u64 = null, | ||
| 1086 | presented_ns: ?u64 = null, | ||
| 1087 | timed_out: bool = false, | ||
| 1088 | |||
| 1089 | pub fn complete(self: Sample) bool { | ||
| 1090 | return self.timed_out or (self.grid_seen_frame != null and self.presented_ns != null); | ||
| 1091 | } | ||
| 1092 | |||
| 1093 | pub fn latencyNs(self: Sample) ?u64 { | ||
| 1094 | const p = self.presented_ns orelse return null; | ||
| 1095 | return p - self.t_inject_ns; | ||
| 1096 | } | ||
| 1097 | }; | ||
| 1098 | |||
| 1099 | pub const SampleBuffer = struct { | ||
| 1100 | const cap = 2000; // 2 scenarios × 500 samples + headroom | ||
| 1101 | items: [cap]Sample = undefined, | ||
| 1102 | count: usize = 0, | ||
| 1103 | |||
| 1104 | pub fn push(self: *SampleBuffer, s: Sample) void { | ||
| 1105 | if (self.count < cap) { | ||
| 1106 | self.items[self.count] = s; | ||
| 1107 | self.count += 1; | ||
| 1108 | } | ||
| 1109 | } | ||
| 1110 | }; | ||
| 1111 | ``` | ||
| 1112 | |||
| 1113 | - [ ] **Step 2: Build + test** | ||
| 1114 | |||
| 1115 | Run: `zig build test 2>&1 | tail -5` | ||
| 1116 | Expected: no new test failures. | ||
| 1117 | |||
| 1118 | - [ ] **Step 3: Commit** | ||
| 1119 | |||
| 1120 | ```bash | ||
| 1121 | git add src/bench_input.zig | ||
| 1122 | git commit -m "bench: Sample and SampleBuffer data types" | ||
| 1123 | ``` | ||
| 1124 | |||
| 1125 | ### Task 6.2: BenchDriver struct + tick entry points | ||
| 1126 | |||
| 1127 | **Files:** | ||
| 1128 | - Modify: `src/bench_input.zig` | ||
| 1129 | |||
| 1130 | - [ ] **Step 1: Add driver** | ||
| 1131 | |||
| 1132 | ```zig | ||
| 1133 | pub const Phase = enum { idle, running, done }; | ||
| 1134 | |||
| 1135 | pub const BenchDriver = struct { | ||
| 1136 | cfg: Config, | ||
| 1137 | alloc: std.mem.Allocator, | ||
| 1138 | sentinels: SentinelAlloc = .{}, | ||
| 1139 | in_flight: ?Sample = null, | ||
| 1140 | samples: SampleBuffer = .{}, | ||
| 1141 | pending_feedback: std.AutoArrayHashMapUnmanaged(u64, u64) = .{}, // frame_counter -> presented_ns | ||
| 1142 | current_phase: Phase = .idle, | ||
| 1143 | current_scenario: Scenario = .cold, | ||
| 1144 | scenario_sample_count: u32 = 0, | ||
| 1145 | early_timeouts: u32 = 0, | ||
| 1146 | early_samples: u32 = 0, | ||
| 1147 | |||
| 1148 | pub fn init(alloc: std.mem.Allocator, cfg: Config) BenchDriver { | ||
| 1149 | return .{ | ||
| 1150 | .cfg = cfg, | ||
| 1151 | .alloc = alloc, | ||
| 1152 | .current_scenario = switch (cfg.scenario) { | ||
| 1153 | .cold, .both => .cold, | ||
| 1154 | .hot => .hot, | ||
| 1155 | }, | ||
| 1156 | }; | ||
| 1157 | } | ||
| 1158 | |||
| 1159 | pub fn deinit(self: *BenchDriver) void { | ||
| 1160 | self.pending_feedback.deinit(self.alloc); | ||
| 1161 | } | ||
| 1162 | |||
| 1163 | /// Called before processing keyboard events; decides whether to inject. | ||
| 1164 | pub fn preTick( | ||
| 1165 | self: *BenchDriver, | ||
| 1166 | keyboard: *wayland_client.Keyboard, | ||
| 1167 | frame_counter: u64, | ||
| 1168 | ) !void { | ||
| 1169 | if (self.current_phase != .running) return; | ||
| 1170 | if (self.in_flight != null) return; | ||
| 1171 | |||
| 1172 | const sentinel = self.sentinels.take(); | ||
| 1173 | try injectSentinel(keyboard, sentinel); | ||
| 1174 | self.in_flight = .{ | ||
| 1175 | .sentinel = sentinel, | ||
| 1176 | .t_inject_ns = @intCast(std.time.Instant.now().timestamp), | ||
| 1177 | .injected_frame = frame_counter, | ||
| 1178 | }; | ||
| 1179 | } | ||
| 1180 | |||
| 1181 | /// Called after term.snapshot — scan the grid for the active sentinel. | ||
| 1182 | pub fn postFrameGridScan( | ||
| 1183 | self: *BenchDriver, | ||
| 1184 | frame_counter: u64, | ||
| 1185 | grid_contains_sentinel: bool, | ||
| 1186 | ) void { | ||
| 1187 | if (self.in_flight == null) return; | ||
| 1188 | var s = &self.in_flight.?; | ||
| 1189 | if (s.grid_seen_frame != null) return; | ||
| 1190 | if (grid_contains_sentinel) { | ||
| 1191 | s.grid_seen_frame = frame_counter; | ||
| 1192 | } else if (frame_counter - s.injected_frame >= self.cfg.max_frames_per_sample) { | ||
| 1193 | s.timed_out = true; | ||
| 1194 | self.finalizeSample(); | ||
| 1195 | } | ||
| 1196 | } | ||
| 1197 | |||
| 1198 | /// Called from the presentation feedback callback. | ||
| 1199 | pub fn recordPresented(self: *BenchDriver, frame_counter: u64, presented_ns: u64) void { | ||
| 1200 | _ = self.pending_feedback.put(self.alloc, frame_counter, presented_ns) catch return; | ||
| 1201 | self.tryFinalize(); | ||
| 1202 | } | ||
| 1203 | |||
| 1204 | /// Called on presentation-feedback discarded event (no-op; we simply keep waiting). | ||
| 1205 | pub fn recordDiscarded(self: *BenchDriver, frame_counter: u64) void { | ||
| 1206 | _ = self; | ||
| 1207 | _ = frame_counter; | ||
| 1208 | } | ||
| 1209 | |||
| 1210 | fn tryFinalize(self: *BenchDriver) void { | ||
| 1211 | if (self.in_flight == null) return; | ||
| 1212 | const s = self.in_flight.?; | ||
| 1213 | const gsf = s.grid_seen_frame orelse return; | ||
| 1214 | const p = self.pending_feedback.get(gsf) orelse return; | ||
| 1215 | self.in_flight.?.presented_ns = p; | ||
| 1216 | self.finalizeSample(); | ||
| 1217 | } | ||
| 1218 | |||
| 1219 | fn finalizeSample(self: *BenchDriver) void { | ||
| 1220 | const sample = self.in_flight.?; | ||
| 1221 | self.in_flight = null; | ||
| 1222 | self.samples.push(sample); | ||
| 1223 | self.scenario_sample_count += 1; | ||
| 1224 | |||
| 1225 | // WSI fallback detection: if >10% of first 50 time out, abort. | ||
| 1226 | if (self.early_samples < 50) { | ||
| 1227 | self.early_samples += 1; | ||
| 1228 | if (sample.timed_out) self.early_timeouts += 1; | ||
| 1229 | if (self.early_samples == 50 and self.early_timeouts > 5) { | ||
| 1230 | std.debug.print( | ||
| 1231 | "waystty input-bench: {d}/50 early samples timed out. " ++ | ||
| 1232 | "Likely wp_presentation.feedback commit race with Mesa WSI. " ++ | ||
| 1233 | "Investigate VK_KHR_present_wait as an alternative.\n", | ||
| 1234 | .{self.early_timeouts}, | ||
| 1235 | ); | ||
| 1236 | std.process.exit(3); | ||
| 1237 | } | ||
| 1238 | } | ||
| 1239 | |||
| 1240 | if (self.scenario_sample_count >= self.cfg.samples_per_scenario) { | ||
| 1241 | self.advanceScenario(); | ||
| 1242 | } | ||
| 1243 | } | ||
| 1244 | |||
| 1245 | fn advanceScenario(self: *BenchDriver) void { | ||
| 1246 | if (self.cfg.scenario == .both and self.current_scenario == .cold) { | ||
| 1247 | self.current_scenario = .hot; | ||
| 1248 | self.scenario_sample_count = 0; | ||
| 1249 | // main.zig's scenario sequencer will respawn the child | ||
| 1250 | self.current_phase = .idle; // pauses until sequencer re-arms | ||
| 1251 | } else { | ||
| 1252 | self.current_phase = .done; | ||
| 1253 | } | ||
| 1254 | } | ||
| 1255 | |||
| 1256 | pub fn start(self: *BenchDriver) void { | ||
| 1257 | self.current_phase = .running; | ||
| 1258 | } | ||
| 1259 | |||
| 1260 | pub fn finished(self: *const BenchDriver) bool { | ||
| 1261 | return self.current_phase == .done; | ||
| 1262 | } | ||
| 1263 | }; | ||
| 1264 | ``` | ||
| 1265 | |||
| 1266 | - [ ] **Step 2: Build** | ||
| 1267 | |||
| 1268 | Run: `zig build` | ||
| 1269 | Expected: PASS. Fix minor syntax issues (e.g., `std.time.Instant` API shape). | ||
| 1270 | |||
| 1271 | - [ ] **Step 3: Commit** | ||
| 1272 | |||
| 1273 | ```bash | ||
| 1274 | git add src/bench_input.zig | ||
| 1275 | git commit -m "bench: BenchDriver skeleton with pre/post-tick entry points" | ||
| 1276 | ``` | ||
| 1277 | |||
| 1278 | ### Task 6.3: Populate pre-present hook to request feedback | ||
| 1279 | |||
| 1280 | **Files:** | ||
| 1281 | - Modify: `src/main.zig` | ||
| 1282 | |||
| 1283 | - [ ] **Step 1: Replace the placeholder `benchPrePresentHook`** | ||
| 1284 | |||
| 1285 | ```zig | ||
| 1286 | fn benchPrePresentHook(opaque_ctx: ?*anyopaque) void { | ||
| 1287 | const driver: *bench_input.BenchDriver = @ptrCast(@alignCast(opaque_ctx orelse return)); | ||
| 1288 | if (driver.current_phase != .running) return; | ||
| 1289 | |||
| 1290 | // Request feedback on the upcoming commit, associate with the *next* frame_counter | ||
| 1291 | // (the one about to be rendered). | ||
| 1292 | const fc = driver.next_expected_frame orelse return; | ||
| 1293 | const feedback_ctx = blk: { | ||
| 1294 | const ctx = alloc_g.create(FeedbackCtx) catch return; | ||
| 1295 | ctx.* = .{ .driver = driver, .frame_counter = fc }; | ||
| 1296 | break :blk ctx; | ||
| 1297 | }; | ||
| 1298 | _ = wayland_client.PresentationFeedback.init( | ||
| 1299 | globals_g.wp_presentation.?, | ||
| 1300 | surface_g, | ||
| 1301 | feedback_ctx, | ||
| 1302 | &onPresentationFeedback, | ||
| 1303 | ) catch return; | ||
| 1304 | } | ||
| 1305 | |||
| 1306 | const FeedbackCtx = struct { driver: *bench_input.BenchDriver, frame_counter: u64 }; | ||
| 1307 | |||
| 1308 | fn onPresentationFeedback( | ||
| 1309 | opaque_ctx: ?*anyopaque, | ||
| 1310 | ev: wayland_client.PresentationFeedback.Event, | ||
| 1311 | ) void { | ||
| 1312 | const ctx: *FeedbackCtx = @ptrCast(@alignCast(opaque_ctx orelse return)); | ||
| 1313 | defer alloc_g.destroy(ctx); | ||
| 1314 | switch (ev) { | ||
| 1315 | .presented => |p| { | ||
| 1316 | const ns: u64 = p.tv_sec * std.time.ns_per_s + p.tv_nsec; | ||
| 1317 | ctx.driver.recordPresented(ctx.frame_counter, ns); | ||
| 1318 | }, | ||
| 1319 | .discarded => ctx.driver.recordDiscarded(ctx.frame_counter), | ||
| 1320 | } | ||
| 1321 | } | ||
| 1322 | ``` | ||
| 1323 | |||
| 1324 | (`alloc_g`, `globals_g`, `surface_g` are file-scoped globals initialized in `main()`; declare them at the top of `main.zig` and assign during init. If the codebase prefers to avoid globals, thread the context through the hook's opaque pointer as a `struct { driver, alloc, globals, surface }` instead.) | ||
| 1325 | |||
| 1326 | - [ ] **Step 2: Add `next_expected_frame` field and increment logic to `BenchDriver`** | ||
| 1327 | |||
| 1328 | In `src/bench_input.zig`: | ||
| 1329 | |||
| 1330 | ```zig | ||
| 1331 | next_expected_frame: ?u64 = null, | ||
| 1332 | ``` | ||
| 1333 | |||
| 1334 | In `preTick`, after injecting, set: | ||
| 1335 | |||
| 1336 | ```zig | ||
| 1337 | self.next_expected_frame = frame_counter; // this frame is the candidate | ||
| 1338 | ``` | ||
| 1339 | |||
| 1340 | And after each frame renders (called from the main loop), bump: | ||
| 1341 | |||
| 1342 | ```zig | ||
| 1343 | pub fn notifyFramePresented(self: *BenchDriver, frame_counter: u64) void { | ||
| 1344 | self.next_expected_frame = frame_counter + 1; | ||
| 1345 | } | ||
| 1346 | ``` | ||
| 1347 | |||
| 1348 | - [ ] **Step 3: Build** | ||
| 1349 | |||
| 1350 | Run: `zig build` | ||
| 1351 | Expected: PASS. | ||
| 1352 | |||
| 1353 | - [ ] **Step 4: Commit** | ||
| 1354 | |||
| 1355 | ```bash | ||
| 1356 | git add src/main.zig src/bench_input.zig | ||
| 1357 | git commit -m "bench: wire pre-present hook to wp_presentation_feedback" | ||
| 1358 | ``` | ||
| 1359 | |||
| 1360 | ### Task 6.4: Grid scan after each frame | ||
| 1361 | |||
| 1362 | **Files:** | ||
| 1363 | - Modify: `src/main.zig` (around the `term.snapshot` call site) | ||
| 1364 | |||
| 1365 | - [ ] **Step 1: Find snapshot site** | ||
| 1366 | |||
| 1367 | Run: `grep -n "term.snapshot" src/main.zig` | ||
| 1368 | |||
| 1369 | - [ ] **Step 2: After snapshot, if in bench mode, scan for sentinel** | ||
| 1370 | |||
| 1371 | Within the render branch, immediately after the snapshot is taken: | ||
| 1372 | |||
| 1373 | ```zig | ||
| 1374 | if (bench_driver_ptr) |drv| { | ||
| 1375 | if (drv.in_flight) |s| { | ||
| 1376 | const found = gridContainsCodepoint(&snapshot_view, s.sentinel); | ||
| 1377 | drv.postFrameGridScan(frame_counter, found); | ||
| 1378 | } | ||
| 1379 | } | ||
| 1380 | ``` | ||
| 1381 | |||
| 1382 | Add a helper (place near other snapshot utilities): | ||
| 1383 | |||
| 1384 | ```zig | ||
| 1385 | fn gridContainsCodepoint(snap: *const vt.Snapshot, cp: u21) bool { | ||
| 1386 | // Iterate every visible cell and compare codepoint. Implementation depends | ||
| 1387 | // on the Snapshot API — use the existing row-iteration pattern. | ||
| 1388 | for (snap.rows) |row| { | ||
| 1389 | for (row.cells) |cell| { | ||
| 1390 | if (cell.codepoint == cp) return true; | ||
| 1391 | } | ||
| 1392 | } | ||
| 1393 | return false; | ||
| 1394 | } | ||
| 1395 | ``` | ||
| 1396 | |||
| 1397 | (If `Snapshot.rows[*].cells[*].codepoint` has a different shape, adapt to match. Grep for `.snapshot()` in vt.zig and follow the existing walking pattern.) | ||
| 1398 | |||
| 1399 | - [ ] **Step 3: Build** | ||
| 1400 | |||
| 1401 | Run: `zig build` | ||
| 1402 | Expected: PASS. | ||
| 1403 | |||
| 1404 | - [ ] **Step 4: Commit** | ||
| 1405 | |||
| 1406 | ```bash | ||
| 1407 | git add src/main.zig | ||
| 1408 | git commit -m "bench: scan rendered frame grid for sentinel codepoint" | ||
| 1409 | ``` | ||
| 1410 | |||
| 1411 | ### Task 6.5: Pre-tick injection wired from main loop | ||
| 1412 | |||
| 1413 | **Files:** | ||
| 1414 | - Modify: `src/main.zig` (keyboard events block) | ||
| 1415 | |||
| 1416 | - [ ] **Step 1: Call `driver.preTick` at the top of each main-loop iteration** | ||
| 1417 | |||
| 1418 | Immediately before the existing `keyboard.tickRepeat()` call (around `src/main.zig:373`): | ||
| 1419 | |||
| 1420 | ```zig | ||
| 1421 | if (bench_driver_ptr) |drv| { | ||
| 1422 | try drv.preTick(&keyboard, frame_counter); | ||
| 1423 | } | ||
| 1424 | ``` | ||
| 1425 | |||
| 1426 | - [ ] **Step 2: Declare & initialize `bench_driver_ptr`** | ||
| 1427 | |||
| 1428 | Near the other `var` declarations before the main loop: | ||
| 1429 | |||
| 1430 | ```zig | ||
| 1431 | var bench_driver_storage: ?bench_input.BenchDriver = if (bench_input_cfg) |cfg| | ||
| 1432 | bench_input.BenchDriver.init(alloc, cfg) | ||
| 1433 | else | ||
| 1434 | null; | ||
| 1435 | defer if (bench_driver_storage) |*d| d.deinit(); | ||
| 1436 | const bench_driver_ptr: ?*bench_input.BenchDriver = if (bench_driver_storage) |*d| d else null; | ||
| 1437 | if (bench_driver_ptr) |d| d.start(); | ||
| 1438 | ``` | ||
| 1439 | |||
| 1440 | - [ ] **Step 3: Build and run cold smoke** | ||
| 1441 | |||
| 1442 | Run: `zig build && WAYSTTY_INPUT_BENCH=cold ./zig-out/bin/waystty 2>/tmp/bench-in.log` | ||
| 1443 | |||
| 1444 | In a floating window: should run, inject sentinels, and eventually print "done" and exit. If it hangs, check logs for `early_timeouts` diagnostic. | ||
| 1445 | |||
| 1446 | - [ ] **Step 4: Commit** | ||
| 1447 | |||
| 1448 | ```bash | ||
| 1449 | git add src/main.zig | ||
| 1450 | git commit -m "bench: inject sentinels per iteration from BenchDriver.preTick" | ||
| 1451 | ``` | ||
| 1452 | |||
| 1453 | ### Task 6.6: Unit test the pair-on-arrival state machine | ||
| 1454 | |||
| 1455 | **Files:** | ||
| 1456 | - Modify: `src/bench_input.zig` | ||
| 1457 | |||
| 1458 | - [ ] **Step 1: Add tests** | ||
| 1459 | |||
| 1460 | ```zig | ||
| 1461 | test "BenchDriver completes sample: grid first, then feedback" { | ||
| 1462 | const cfg: Config = .{ .scenario = .cold, .samples_per_scenario = 1 }; | ||
| 1463 | var d: BenchDriver = .init(std.testing.allocator, cfg); | ||
| 1464 | defer d.deinit(); | ||
| 1465 | d.start(); | ||
| 1466 | d.in_flight = .{ .sentinel = 0xE000, .t_inject_ns = 1000, .injected_frame = 10 }; | ||
| 1467 | d.postFrameGridScan(10, true); | ||
| 1468 | try std.testing.expect(d.in_flight != null); | ||
| 1469 | d.recordPresented(10, 5000); | ||
| 1470 | try std.testing.expectEqual(@as(usize, 1), d.samples.count); | ||
| 1471 | try std.testing.expectEqual(@as(u64, 4000), d.samples.items[0].latencyNs().?); | ||
| 1472 | } | ||
| 1473 | |||
| 1474 | test "BenchDriver completes sample: feedback first, then grid" { | ||
| 1475 | const cfg: Config = .{ .scenario = .cold, .samples_per_scenario = 1 }; | ||
| 1476 | var d: BenchDriver = .init(std.testing.allocator, cfg); | ||
| 1477 | defer d.deinit(); | ||
| 1478 | d.start(); | ||
| 1479 | d.in_flight = .{ .sentinel = 0xE000, .t_inject_ns = 1000, .injected_frame = 10 }; | ||
| 1480 | d.recordPresented(11, 5500); | ||
| 1481 | d.postFrameGridScan(11, true); | ||
| 1482 | try std.testing.expectEqual(@as(usize, 1), d.samples.count); | ||
| 1483 | try std.testing.expectEqual(@as(u64, 4500), d.samples.items[0].latencyNs().?); | ||
| 1484 | } | ||
| 1485 | |||
| 1486 | test "BenchDriver times out after max_frames_per_sample" { | ||
| 1487 | const cfg: Config = .{ .scenario = .cold, .samples_per_scenario = 1, .max_frames_per_sample = 5 }; | ||
| 1488 | var d: BenchDriver = .init(std.testing.allocator, cfg); | ||
| 1489 | defer d.deinit(); | ||
| 1490 | d.start(); | ||
| 1491 | d.in_flight = .{ .sentinel = 0xE000, .t_inject_ns = 1000, .injected_frame = 10 }; | ||
| 1492 | var f: u64 = 10; | ||
| 1493 | while (f <= 15) : (f += 1) d.postFrameGridScan(f, false); | ||
| 1494 | try std.testing.expectEqual(@as(usize, 1), d.samples.count); | ||
| 1495 | try std.testing.expect(d.samples.items[0].timed_out); | ||
| 1496 | } | ||
| 1497 | ``` | ||
| 1498 | |||
| 1499 | - [ ] **Step 2: Run** | ||
| 1500 | |||
| 1501 | Run: `zig build test 2>&1 | tail -10` | ||
| 1502 | Expected: all three new tests PASS. | ||
| 1503 | |||
| 1504 | - [ ] **Step 3: Commit** | ||
| 1505 | |||
| 1506 | ```bash | ||
| 1507 | git add src/bench_input.zig | ||
| 1508 | git commit -m "bench: test pair-on-arrival state machine and timeout" | ||
| 1509 | ``` | ||
| 1510 | |||
| 1511 | --- | ||
| 1512 | |||
| 1513 | ## Phase 7 — Scenario sequencer + Makefile target | ||
| 1514 | |||
| 1515 | ### Task 7.1: Restart PTY child between cold and hot | ||
| 1516 | |||
| 1517 | **Files:** | ||
| 1518 | - Modify: `src/main.zig` | ||
| 1519 | |||
| 1520 | - [ ] **Step 1: Detect scenario completion + respawn** | ||
| 1521 | |||
| 1522 | Inside the main loop, after calling `drv.postFrameGridScan` or near the end of the loop body: | ||
| 1523 | |||
| 1524 | ```zig | ||
| 1525 | if (bench_driver_ptr) |drv| { | ||
| 1526 | if (drv.current_phase == .idle and drv.cfg.scenario == .both and drv.current_scenario == .hot) { | ||
| 1527 | // Transition cold -> hot: teardown existing child, spawn the hot one. | ||
| 1528 | p.gracefulTeardown(); | ||
| 1529 | p.deinit(); | ||
| 1530 | assertPvAvailable(alloc); | ||
| 1531 | p = try pty.Pty.spawn(.{ | ||
| 1532 | .cols = cols, | ||
| 1533 | .rows = rows, | ||
| 1534 | .shell = shell_plan.shell, | ||
| 1535 | .shell_args = &.{ "-c", "yes \"$(printf 'x%.0s' {1..500})\" | pv -qL 24K" }, | ||
| 1536 | }); | ||
| 1537 | try pty.Pty.ensureEcho(p.slave_fd); | ||
| 1538 | term.setWritePtyCallback(&p, &writePtyFromTerminal); | ||
| 1539 | drv.start(); | ||
| 1540 | } | ||
| 1541 | if (drv.finished()) { | ||
| 1542 | // Print stats and exit — see Task 8.1 | ||
| 1543 | bench_input.printStats(drv, cols, rows); | ||
| 1544 | return; | ||
| 1545 | } | ||
| 1546 | } | ||
| 1547 | ``` | ||
| 1548 | |||
| 1549 | - [ ] **Step 2: Build** | ||
| 1550 | |||
| 1551 | Run: `zig build` | ||
| 1552 | Expected: PASS. | ||
| 1553 | |||
| 1554 | - [ ] **Step 3: Commit** | ||
| 1555 | |||
| 1556 | ```bash | ||
| 1557 | git add src/main.zig | ||
| 1558 | git commit -m "bench: restart PTY child between cold and hot scenarios" | ||
| 1559 | ``` | ||
| 1560 | |||
| 1561 | ### Task 7.2: `bench-input` Makefile target | ||
| 1562 | |||
| 1563 | **Files:** | ||
| 1564 | - Modify: `Makefile` | ||
| 1565 | |||
| 1566 | - [ ] **Step 1: Add target** | ||
| 1567 | |||
| 1568 | After the `bench` target, add: | ||
| 1569 | |||
| 1570 | ```makefile | ||
| 1571 | # Expected runtime: ~15s cold + ~25s hot = ~40s total | ||
| 1572 | # Requires: pv (for hot-mode rate limiting) | ||
| 1573 | bench-input: | ||
| 1574 | $(ZIG) build -Doptimize=$(OPT) | ||
| 1575 | WAYSTTY_INPUT_BENCH=both ./zig-out/bin/waystty 2>bench-input.log || true | ||
| 1576 | @echo "--- input latency ---" | ||
| 1577 | @grep -A 20 "waystty input latency" bench-input.log || echo "(no timing data found)" | ||
| 1578 | ``` | ||
| 1579 | |||
| 1580 | Also append `bench-input` to the `.PHONY` line. | ||
| 1581 | |||
| 1582 | - [ ] **Step 2: Commit** | ||
| 1583 | |||
| 1584 | ```bash | ||
| 1585 | git add Makefile | ||
| 1586 | git commit -m "bench: add bench-input Makefile target" | ||
| 1587 | ``` | ||
| 1588 | |||
| 1589 | --- | ||
| 1590 | |||
| 1591 | ## Phase 8 — Output | ||
| 1592 | |||
| 1593 | ### Task 8.1: Print stats with grid header + per-scenario rows | ||
| 1594 | |||
| 1595 | **Files:** | ||
| 1596 | - Modify: `src/bench_input.zig` | ||
| 1597 | |||
| 1598 | - [ ] **Step 1: Add `printStats`** | ||
| 1599 | |||
| 1600 | ```zig | ||
| 1601 | pub fn printStats(drv: *const BenchDriver, cols: u16, rows: u16) void { | ||
| 1602 | // Split samples by scenario. With current design, cold samples are pushed | ||
| 1603 | // first, then hot — track via scenario transition. | ||
| 1604 | // For simplicity in v1, we tag samples with their scenario at push time: | ||
| 1605 | // TODO: add `scenario` field to Sample — see Task 8.1 step 2. | ||
| 1606 | _ = drv; | ||
| 1607 | _ = cols; | ||
| 1608 | _ = rows; | ||
| 1609 | } | ||
| 1610 | ``` | ||
| 1611 | |||
| 1612 | **Wait** — the `Sample` struct as defined in Task 6.1 doesn't carry scenario. Fix before proceeding: | ||
| 1613 | |||
| 1614 | - [ ] **Step 2: Add `scenario: Scenario` to Sample** | ||
| 1615 | |||
| 1616 | Edit Task 6.1's Sample struct: | ||
| 1617 | |||
| 1618 | ```zig | ||
| 1619 | pub const Sample = struct { | ||
| 1620 | scenario: Scenario, | ||
| 1621 | sentinel: u21, | ||
| 1622 | t_inject_ns: u64, | ||
| 1623 | injected_frame: u64, | ||
| 1624 | grid_seen_frame: ?u64 = null, | ||
| 1625 | presented_ns: ?u64 = null, | ||
| 1626 | timed_out: bool = false, | ||
| 1627 | // ... rest unchanged | ||
| 1628 | }; | ||
| 1629 | ``` | ||
| 1630 | |||
| 1631 | In `preTick`, when constructing the in-flight sample: | ||
| 1632 | |||
| 1633 | ```zig | ||
| 1634 | self.in_flight = .{ | ||
| 1635 | .scenario = self.current_scenario, | ||
| 1636 | // ... existing | ||
| 1637 | }; | ||
| 1638 | ``` | ||
| 1639 | |||
| 1640 | - [ ] **Step 3: Implement printStats** | ||
| 1641 | |||
| 1642 | ```zig | ||
| 1643 | pub fn printStats(drv: *const BenchDriver, cols: u16, rows: u16) void { | ||
| 1644 | var cold_buf: [2000]u64 = undefined; | ||
| 1645 | var hot_buf: [2000]u64 = undefined; | ||
| 1646 | var cold_to: u32 = 0; | ||
| 1647 | var hot_to: u32 = 0; | ||
| 1648 | var cold_n: usize = 0; | ||
| 1649 | var hot_n: usize = 0; | ||
| 1650 | for (drv.samples.items[0..drv.samples.count]) |s| { | ||
| 1651 | if (s.timed_out) { | ||
| 1652 | switch (s.scenario) { | ||
| 1653 | .cold => cold_to += 1, | ||
| 1654 | .hot => hot_to += 1, | ||
| 1655 | .both => unreachable, | ||
| 1656 | } | ||
| 1657 | continue; | ||
| 1658 | } | ||
| 1659 | const lat = s.latencyNs() orelse continue; | ||
| 1660 | switch (s.scenario) { | ||
| 1661 | .cold => { cold_buf[cold_n] = lat; cold_n += 1; }, | ||
| 1662 | .hot => { hot_buf[hot_n] = lat; hot_n += 1; }, | ||
| 1663 | .both => unreachable, | ||
| 1664 | } | ||
| 1665 | } | ||
| 1666 | std.debug.print( | ||
| 1667 | "\n=== waystty input latency ({d} cold, {d} hot, {d}x{d} grid) ===\n", | ||
| 1668 | .{ cold_n, hot_n, cols, rows }, | ||
| 1669 | ); | ||
| 1670 | std.debug.print("{s:<10}{s:>8}{s:>8}{s:>8}{s:>8}{s:>8} (us) timeouts\n", | ||
| 1671 | .{ "scenario", "min", "avg", "p50", "p99", "max" }); | ||
| 1672 | printRow("cold", cold_buf[0..cold_n], cold_to); | ||
| 1673 | printRow("hot", hot_buf[0..hot_n], hot_to); | ||
| 1674 | } | ||
| 1675 | |||
| 1676 | fn printRow(label: []const u8, vals: []u64, timeouts: u32) void { | ||
| 1677 | if (vals.len == 0) { | ||
| 1678 | std.debug.print("{s:<10} (no samples) timeouts {d}\n", .{ label, timeouts }); | ||
| 1679 | return; | ||
| 1680 | } | ||
| 1681 | std.mem.sort(u64, vals, {}, std.sort.asc(u64)); | ||
| 1682 | var sum: u128 = 0; | ||
| 1683 | for (vals) |v| sum += v; | ||
| 1684 | const avg = @as(u64, @intCast(sum / vals.len)); | ||
| 1685 | const p50_idx = vals.len / 2; | ||
| 1686 | const p99_idx = (vals.len * 99) / 100; | ||
| 1687 | std.debug.print( | ||
| 1688 | "{s:<10}{d:>8}{d:>8}{d:>8}{d:>8}{d:>8} {d}\n", | ||
| 1689 | .{ label, vals[0] / 1000, avg / 1000, vals[p50_idx] / 1000, vals[p99_idx] / 1000, vals[vals.len - 1] / 1000, timeouts }, | ||
| 1690 | ); | ||
| 1691 | } | ||
| 1692 | ``` | ||
| 1693 | |||
| 1694 | - [ ] **Step 4: Build and run full cycle** | ||
| 1695 | |||
| 1696 | Run: `zig build && WAYSTTY_INPUT_BENCH=both ./zig-out/bin/waystty 2>/tmp/in-full.log; tail -20 /tmp/in-full.log` | ||
| 1697 | Expected: output matches the spec's sample stats block. | ||
| 1698 | |||
| 1699 | - [ ] **Step 5: Commit** | ||
| 1700 | |||
| 1701 | ```bash | ||
| 1702 | git add src/bench_input.zig | ||
| 1703 | git commit -m "bench: printStats for input-latency with per-scenario rows" | ||
| 1704 | ``` | ||
| 1705 | |||
| 1706 | ### Task 8.2 (post-headline): Per-stage breakdown for p99 samples | ||
| 1707 | |||
| 1708 | **Files:** | ||
| 1709 | - Modify: `src/bench_input.zig` | ||
| 1710 | |||
| 1711 | - [ ] **Step 1: Join samples with `FrameTiming`** | ||
| 1712 | |||
| 1713 | Add to `bench_input.zig`: | ||
| 1714 | |||
| 1715 | ```zig | ||
| 1716 | pub fn printP99Breakdown( | ||
| 1717 | drv: *const BenchDriver, | ||
| 1718 | ring: *const bench_stats.FrameTimingRing, | ||
| 1719 | scenario: Scenario, | ||
| 1720 | ) void { | ||
| 1721 | // Find p99 sample for the given scenario by latency | ||
| 1722 | var best_idx: ?usize = null; | ||
| 1723 | var best_lat: u64 = 0; | ||
| 1724 | for (drv.samples.items[0..drv.samples.count], 0..) |s, i| { | ||
| 1725 | if (s.scenario != scenario) continue; | ||
| 1726 | const lat = s.latencyNs() orelse continue; | ||
| 1727 | if (lat > best_lat) { best_lat = lat; best_idx = i; } | ||
| 1728 | } | ||
| 1729 | const idx = best_idx orelse return; | ||
| 1730 | const sample = drv.samples.items[idx]; | ||
| 1731 | const frame = sample.grid_seen_frame orelse return; | ||
| 1732 | |||
| 1733 | // Find timing entry with that frame_counter | ||
| 1734 | var ordered: [bench_stats.FrameTimingRing.capacity]bench_stats.FrameTiming = undefined; | ||
| 1735 | const entries = ring.orderedSlice(&ordered); | ||
| 1736 | for (entries) |ft| { | ||
| 1737 | if (ft.frame_counter == frame) { | ||
| 1738 | std.debug.print( | ||
| 1739 | "\np99 {s} breakdown (latency {d}us, frame {d}):\n" ++ | ||
| 1740 | " snapshot {d}, row_rebuild {d}, atlas_upload {d}, instance_upload {d}, gpu_submit {d}\n", | ||
| 1741 | .{ @tagName(scenario), best_lat / 1000, frame, | ||
| 1742 | ft.snapshot_us, ft.row_rebuild_us, ft.atlas_upload_us, | ||
| 1743 | ft.instance_upload_us, ft.gpu_submit_us }, | ||
| 1744 | ); | ||
| 1745 | return; | ||
| 1746 | } | ||
| 1747 | } | ||
| 1748 | std.debug.print("(p99 frame {d} already evicted from timing ring)\n", .{ frame }); | ||
| 1749 | } | ||
| 1750 | ``` | ||
| 1751 | |||
| 1752 | - [ ] **Step 2: Call from main after `printStats`** | ||
| 1753 | |||
| 1754 | ```zig | ||
| 1755 | bench_input.printStats(drv, cols, rows); | ||
| 1756 | bench_input.printP99Breakdown(drv, &frame_ring, .cold); | ||
| 1757 | bench_input.printP99Breakdown(drv, &frame_ring, .hot); | ||
| 1758 | ``` | ||
| 1759 | |||
| 1760 | - [ ] **Step 3: Build and run** | ||
| 1761 | |||
| 1762 | Run: `zig build && WAYSTTY_INPUT_BENCH=both ./zig-out/bin/waystty 2>/tmp/in.log; tail -25 /tmp/in.log` | ||
| 1763 | Expected: breakdown lines appear after the main table. | ||
| 1764 | |||
| 1765 | - [ ] **Step 4: Commit** | ||
| 1766 | |||
| 1767 | ```bash | ||
| 1768 | git add src/bench_input.zig src/main.zig | ||
| 1769 | git commit -m "bench: p99 per-stage breakdown joined on frame_counter" | ||
| 1770 | ``` | ||
| 1771 | |||
| 1772 | --- | ||
| 1773 | |||
| 1774 | ## Phase 9 — Final smoke test + docs | ||
| 1775 | |||
| 1776 | ### Task 9.1: End-to-end smoke on floating window | ||
| 1777 | |||
| 1778 | **Files:** none modified. | ||
| 1779 | |||
| 1780 | - [ ] **Step 1: Launch a floating waystty on sway** | ||
| 1781 | |||
| 1782 | ```bash | ||
| 1783 | # Ensure sway config has rule: `for_window [app_id="waystty"] floating enable` | ||
| 1784 | zig build -Doptimize=ReleaseFast | ||
| 1785 | WAYSTTY_INPUT_BENCH=both ./zig-out/bin/waystty 2>bench-input.log | ||
| 1786 | ``` | ||
| 1787 | |||
| 1788 | Expected: runs for ~40s, prints stats with grid=80×24, cold < hot, both with low timeouts (< 5%). | ||
| 1789 | |||
| 1790 | - [ ] **Step 2: Record baseline numbers** | ||
| 1791 | |||
| 1792 | Commit the output to a freeform note or just eyeball for sanity: cold p50 on the order of one refresh interval (~16ms); hot p99 > cold p99. | ||
| 1793 | |||
| 1794 | ### Task 9.2: Run full test suite | ||
| 1795 | |||
| 1796 | - [ ] **Step 1: All tests pass** | ||
| 1797 | |||
| 1798 | Run: `zig build test 2>&1 | tail -30` | ||
| 1799 | Expected: every test passes. | ||
| 1800 | |||
| 1801 | - [ ] **Step 2: Existing bench still works** | ||
| 1802 | |||
| 1803 | Run: `make bench` | ||
| 1804 | Expected: output includes the new grid-size line; no regression in numbers. | ||
| 1805 | |||
| 1806 | - [ ] **Step 3: Commit any final adjustments** | ||
| 1807 | |||
| 1808 | ```bash | ||
| 1809 | git add -u | ||
| 1810 | git commit -m "bench: final polish" | ||
| 1811 | ``` | ||
| 1812 | |||
| 1813 | --- | ||
| 1814 | |||
| 1815 | ## Self-review notes | ||
| 1816 | |||
| 1817 | **Spec coverage:** | ||
| 1818 | |||
| 1819 | - Goal / cold + hot metrics → Tasks 4.3, 7.1, 8.1. | ||
| 1820 | - `wp_presentation_time` endpoint → Phase 3 + Task 6.3. | ||
| 1821 | - In-process KeyEvent injection → Task 5.2. | ||
| 1822 | - Echo-gated closed loop → Task 6.2 (in_flight guard). | ||
| 1823 | - MAILBOX preserved → no change made (default stays). | ||
| 1824 | - PUA sentinels → Task 5.1. | ||
| 1825 | - Fixed grid (shared) → Phase 1. | ||
| 1826 | - Termios ECHO → Task 4.2. | ||
| 1827 | - Child teardown → Task 4.5. | ||
| 1828 | - WSI fallback → Task 6.2 (finalizeSample's early_timeouts check). | ||
| 1829 | - Frame-counter correlation → Phase 2 + Task 8.2. | ||
| 1830 | - Pair-on-arrival + discarded handling → Tasks 6.1, 6.2, 6.3 (driver doesn't advance on discarded; keeps listening). | ||
| 1831 | |||
| 1832 | **Dependencies between tasks:** Phase 3 blocks Phase 6.3. Phase 4 depends on Phase 1 (for env vars). All others are straightforward linear. | ||
| 1833 | |||
| 1834 | **Compositor compatibility:** The whole bench assumes a compositor that (a) honors `xdg_toplevel` size hints for floating surfaces, and (b) implements `wp_presentation_time`. sway does both. Compositors that don't will fail at Task 1.3 (size mismatch) or Task 3.4 (global missing) — both with clear diagnostics. | ||