a73x

03430faf

Add Vulkan bounded-waits implementation plan

a73x   2026-04-18 11:40

Commit message
Add Vulkan bounded-waits implementation plan

Six-commit plan matching the 2026-04-18 spec: add src/vk_sync.zig
helpers, reorder resetFences with errdefer re-signal, migrate 10
timeout sites + 14 *WaitIdle sites, add grep gate, add backoff.
Includes ticket bookkeeping for ab6c92f0 and the follow-up audit of
four mid-flight recovery paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs/superpowers/plans/2026-04-18-vulkan-bounded-waits.md
Old New
@@ -0,0 +1,1285 @@
1 # Vulkan Bounded Waits 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:** Stop waystty from hanging when the NVIDIA driver drops a Vulkan fence signal. Replace unbounded `waitForFences` / `acquireNextImageKHR` / `deviceWaitIdle` / `queueWaitIdle` calls with bounded variants that return recoverable errors, and add a grep gate so future code can't regress.
6
7 **Architecture:** A new module `src/vk_sync.zig` exposes five helpers (`waitFenceBounded`, `acquireImageBounded`, `waitIdleBounded`, `waitIdleForShutdown`, `queueWaitIdleBounded`) plus a rate-limited logger. 10 blocking-wait call sites migrate to the helpers; 14 `*WaitIdle` sites migrate mechanically to `waitIdleForShutdown`. A shell script grep gate in `tests/check_unbounded_vk.sh` fails CI if any source file bypasses the helpers. On timeout, the main loop logs via the rate-limited logger, applies exponential backoff (capped at 100 ms), and retries the frame on the next iteration.
8
9 **Tech Stack:** Zig 0.15+, vulkan-zig bindings (`vk.DeviceWrapper`), waystty's existing build.zig module graph. No new dependencies.
10
11 **Source spec:** `docs/superpowers/specs/2026-04-18-vulkan-bounded-waits-design.md`
12
13 **Tracks:** git-collab issue `ab6c92f0`. Closes `793f491a` as duplicate.
14
15 ---
16
17 ## Task 1: Create `vk_sync` module with helpers + inline tests
18
19 **Files:**
20 - Create: `src/vk_sync.zig`
21 - Modify: `build.zig:311-321` (add `vk_sync_mod` similar to `cell_instance_mod`)
22
23 - [ ] **Step 1: Create `src/vk_sync.zig` with constants, helpers, and inline tests**
24
25 Write the full file:
26
27 ```zig
28 //! Bounded Vulkan synchronization primitives.
29 //!
30 //! Replaces unbounded vkWaitForFences / vkAcquireNextImageKHR / vkDeviceWaitIdle /
31 //! vkQueueWaitIdle calls. The helpers here are the ONLY path callers should use
32 //! for blocking Vulkan operations — the grep gate in tests/check_unbounded_vk.sh
33 //! enforces this at CI time.
34 //!
35 //! Motivation: NVIDIA driver 595 occasionally drops a fence signal, wedging
36 //! vkWaitForFences(UINT64_MAX) forever. See docs/superpowers/specs/
37 //! 2026-04-18-vulkan-bounded-waits-design.md for the full story.
38
39 const std = @import("std");
40 const vk = @import("vulkan");
41
42 pub const fence_wait_timeout_ns: u64 = 2_000_000_000; // 2s
43 pub const acquire_timeout_ns: u64 = 100_000_000; // 100ms
44
45 pub const SyncError = error{ VkWaitTimeout, VkAcquireTimeout };
46
47 /// Bounded fence wait. Returns error.VkWaitTimeout on timeout without touching
48 /// the fence. Caller may safely retry on the next iteration.
49 pub fn waitFenceBounded(
50 vkd: vk.DeviceWrapper,
51 device: vk.Device,
52 fence: vk.Fence,
53 ) !void {
54 const result = try vkd.waitForFences(device, 1, @ptrCast(&fence), .true, fence_wait_timeout_ns);
55 if (result == .timeout) return error.VkWaitTimeout;
56 }
57
58 /// Bounded image acquire. Returns the acquired image_index on success.
59 /// Folds VK_SUBOPTIMAL_KHR into error.OutOfDateKHR (matches existing callers,
60 /// which already collapse the two via swapchainNeedsRebuild).
61 /// Returns error.VkAcquireTimeout on VK_TIMEOUT or VK_NOT_READY.
62 pub fn acquireImageBounded(
63 vkd: vk.DeviceWrapper,
64 device: vk.Device,
65 swapchain: vk.SwapchainKHR,
66 semaphore: vk.Semaphore,
67 ) !u32 {
68 const acquire = vkd.acquireNextImageKHR(
69 device,
70 swapchain,
71 acquire_timeout_ns,
72 semaphore,
73 .null_handle,
74 ) catch |err| switch (err) {
75 error.OutOfDateKHR => return error.OutOfDateKHR,
76 else => return err,
77 };
78 switch (acquire.result) {
79 .timeout, .not_ready => return error.VkAcquireTimeout,
80 .suboptimal_khr => return error.OutOfDateKHR,
81 .success => return acquire.image_index,
82 else => return acquire.image_index, // unexpected but non-error; trust the image_index
83 }
84 }
85
86 /// Bounded device-idle wait. For mid-flight resyncs where blocking forever
87 /// would be wrong. Returns error.VkWaitTimeout on timeout.
88 pub fn waitIdleBounded(vkd: vk.DeviceWrapper, device: vk.Device, timeout_ns: u64) !void {
89 // vkDeviceWaitIdle has no timeout parameter — we emulate by waiting on a
90 // newly-created fence submitted as a no-op, then waiting with our timeout.
91 // This is the minimum-cost approximation; for cases that need true idle,
92 // callers should use waitIdleForShutdown.
93 _ = timeout_ns;
94 _ = vkd;
95 _ = device;
96 @compileError("waitIdleBounded: not used in v1, left as a stub. Remove this compileError and implement the fence-based emulation if a caller appears.");
97 }
98
99 /// Unbounded device-idle wait, named to make shutdown-drain intent obvious
100 /// at the call site. Logs (but swallows) device-lost on shutdown since it is
101 /// unactionable.
102 pub fn waitIdleForShutdown(vkd: vk.DeviceWrapper, device: vk.Device) void {
103 vkd.deviceWaitIdle(device) catch |err| {
104 std.log.warn("waitIdleForShutdown: {s}", .{@errorName(err)});
105 };
106 }
107
108 /// Bounded queue-idle wait. Same shape as waitIdleBounded.
109 pub fn queueWaitIdleBounded(vkd: vk.DeviceWrapper, queue: vk.Queue, timeout_ns: u64) !void {
110 _ = queue;
111 _ = timeout_ns;
112 _ = vkd;
113 @compileError("queueWaitIdleBounded: not used in v1, left as a stub. Remove this compileError and implement if a caller appears.");
114 }
115
116 // --- logging ---
117
118 const TimeoutKind = enum { fence, acquire, atlas };
119
120 var vk_timeout_count: std.atomic.Value(u64) = .init(0);
121 var last_log_ns: std.atomic.Value(i128) = .init(0);
122
123 const log_window_ns: i128 = 5 * std.time.ns_per_s;
124
125 pub fn logVkTimeout(src: std.builtin.SourceLocation, kind: TimeoutKind) void {
126 const n = vk_timeout_count.fetchAdd(1, .monotonic) + 1;
127 const now = std.time.nanoTimestamp();
128 const last = last_log_ns.load(.monotonic);
129 if (n == 1 or (now - last) > log_window_ns) {
130 last_log_ns.store(now, .monotonic);
131 std.log.warn(
132 "vk timeout #{} ({s}) at {s}:{d} — driver may be wedged",
133 .{ n, @tagName(kind), src.file, src.line },
134 );
135 }
136 }
137
138 // --- test helpers (internal; exposed only for inline tests) ---
139
140 fn resetLogStateForTesting() void {
141 vk_timeout_count.store(0, .monotonic);
142 last_log_ns.store(0, .monotonic);
143 }
144
145 // --- tests ---
146
147 test "constants have expected values" {
148 try std.testing.expectEqual(@as(u64, 2_000_000_000), fence_wait_timeout_ns);
149 try std.testing.expectEqual(@as(u64, 100_000_000), acquire_timeout_ns);
150 }
151
152 test "logVkTimeout rate-limits to one line per window" {
153 // We can't easily capture std.log.warn output, but we can verify the
154 // counter and last_log_ns state transitions match the rate-limit logic.
155 resetLogStateForTesting();
156
157 // First call always logs.
158 logVkTimeout(@src(), .fence);
159 try std.testing.expectEqual(@as(u64, 1), vk_timeout_count.load(.monotonic));
160 const t1 = last_log_ns.load(.monotonic);
161 try std.testing.expect(t1 > 0);
162
163 // Immediate second call: counter increments, last_log_ns stays (within 5s window).
164 logVkTimeout(@src(), .fence);
165 try std.testing.expectEqual(@as(u64, 2), vk_timeout_count.load(.monotonic));
166 try std.testing.expectEqual(t1, last_log_ns.load(.monotonic));
167
168 // 100 more calls in tight loop: counter grows, last_log_ns still stays.
169 for (0..100) |_| logVkTimeout(@src(), .fence);
170 try std.testing.expectEqual(@as(u64, 102), vk_timeout_count.load(.monotonic));
171 try std.testing.expectEqual(t1, last_log_ns.load(.monotonic));
172 }
173
174 test "logVkTimeout re-fires after simulated window elapsed" {
175 resetLogStateForTesting();
176
177 logVkTimeout(@src(), .acquire);
178 const t1 = last_log_ns.load(.monotonic);
179
180 // Simulate window expiry by rewinding last_log_ns past the 5s threshold.
181 last_log_ns.store(t1 - 6 * std.time.ns_per_s, .monotonic);
182
183 logVkTimeout(@src(), .acquire);
184 const t2 = last_log_ns.load(.monotonic);
185
186 try std.testing.expect(t2 > t1 - 6 * std.time.ns_per_s);
187 try std.testing.expectEqual(@as(u64, 2), vk_timeout_count.load(.monotonic));
188 }
189 ```
190
191 Write this to `/home/xanderle/code/rad/waystty/src/vk_sync.zig`.
192
193 - [ ] **Step 2: Wire module into `build.zig`**
194
195 After line 321 in `build.zig` (after the `cell_instance_mod` block, before the `// capture module` comment), insert:
196
197 ```zig
198 // vk_sync module — bounded Vulkan synchronization helpers
199 const vk_sync_mod = b.createModule(.{
200 .root_source_file = b.path("src/vk_sync.zig"),
201 .target = target,
202 .optimize = optimize,
203 });
204 vk_sync_mod.addImport("vulkan", vulkan_module);
205 renderer_mod.addImport("vk_sync", vk_sync_mod);
206 renderer_test_mod.addImport("vk_sync", vk_sync_mod);
207 exe_mod.addImport("vk_sync", vk_sync_mod);
208 main_test_mod.addImport("vk_sync", vk_sync_mod);
209
210 const vk_sync_test_mod = b.createModule(.{
211 .root_source_file = b.path("src/vk_sync.zig"),
212 .target = target,
213 .optimize = optimize,
214 });
215 vk_sync_test_mod.addImport("vulkan", vulkan_module);
216 const vk_sync_tests = b.addTest(.{ .root_module = vk_sync_test_mod });
217 test_step.dependOn(&b.addRunArtifact(vk_sync_tests).step);
218 ```
219
220 And extend the `capture_mod` block (currently at lines ~323-339) to also import `vk_sync`:
221
222 At the end of the `capture_mod` import chain (after `capture_mod.addImport("cell_instance", cell_instance_mod);`), add:
223
224 ```zig
225 capture_mod.addImport("vk_sync", vk_sync_mod);
226 ```
227
228 - [ ] **Step 3: Build and run tests**
229
230 Run: `cd /home/xanderle/code/rad/waystty && zig build test`
231
232 Expected: all existing tests pass, plus three new tests:
233 - `constants have expected values` — PASS
234 - `logVkTimeout rate-limits to one line per window` — PASS
235 - `logVkTimeout re-fires after simulated window elapsed` — PASS
236
237 The two `@compileError` stubs (`waitIdleBounded`, `queueWaitIdleBounded`) don't fire until something references them, so they don't block the build.
238
239 If the build fails because `vk_sync` module can't be imported by renderer/main even though they don't use it yet: that's fine, no caller imports it yet. The build only exercises the test module in this step.
240
241 - [ ] **Step 4: Commit**
242
243 ```bash
244 cd /home/xanderle/code/rad/waystty
245 git add src/vk_sync.zig build.zig
246 git commit -m "$(cat <<'EOF'
247 Add vk_sync module with bounded Vulkan wait helpers
248
249 Introduces src/vk_sync.zig with waitFenceBounded, acquireImageBounded,
250 waitIdleForShutdown, and a rate-limited logVkTimeout. No callers
251 migrated yet — follow-up commits migrate the 10 timeout sites and 14
252 *WaitIdle sites, then land the grep gate.
253
254 Part of issue ab6c92f0.
255
256 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
257 EOF
258 )"
259 ```
260
261 ---
262
263 ## Task 2: Reorder `resetFences` + add errdefer re-signal in `drawClear` and `drawCells`
264
265 **Files:**
266 - Modify: `src/renderer.zig` — `drawClear` (1273-1342), `drawCells` (1694-1800)
267
268 **Why:** Currently `resetFences` runs before `acquireNextImageKHR`. When we introduce acquire timeouts (Task 3), a timed-out acquire would leave the fence unsignaled with no submit pending → next frame's fence wait times out forever. Fixing this now, before the timeouts land, keeps the two commits independently reviewable.
269
270 We also add an `errdefer` re-signal that covers the post-acquire / pre-submit failure window. On success: no-op. On failure in that window: submit a no-op to re-signal the fence.
271
272 **No new error types yet in this commit — we're keeping `UINT64_MAX` intact. This is purely a reorder + errdefer addition.**
273
274 - [ ] **Step 1: Add a private helper `resignalFence` on `Context` in `src/renderer.zig`**
275
276 Find the `pub fn drawClear` function at line ~1273. Immediately before it, add a private helper:
277
278 ```zig
279 /// Submit an empty command batch that signals `fence`. Used as an
280 /// errdefer recovery path when acquire has succeeded, resetFences has
281 /// run, but we failed before queueSubmit — we need to put the fence
282 /// back in the signaled state so the next frame's wait succeeds.
283 fn resignalFence(self: *Context, fence: vk.Fence) void {
284 const submit_info = vk.SubmitInfo{};
285 _ = self.vkd.queueSubmit(
286 self.graphics_queue,
287 1,
288 @ptrCast(&submit_info),
289 fence,
290 ) catch |err| {
291 std.log.warn("resignalFence: {s}", .{@errorName(err)});
292 };
293 }
294 ```
295
296 - [ ] **Step 2: Reorder `drawClear` (renderer.zig:1273-1342)**
297
298 Replace lines 1275-1290 (the wait, reset, acquire chain) with:
299
300 ```zig
301 // Wait for previous frame to finish
302 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64));
303
304 // Acquire next image BEFORE reset, so an acquire failure leaves the
305 // fence in a safe state (signaled from the prior frame).
306 const acquire = self.vkd.acquireNextImageKHR(
307 self.device,
308 self.swapchain,
309 std.math.maxInt(u64),
310 self.image_available,
311 .null_handle,
312 ) catch |err| switch (err) {
313 error.OutOfDateKHR => return error.OutOfDateKHR,
314 else => return err,
315 };
316 if (swapchainNeedsRebuild(acquire.result)) return error.OutOfDateKHR;
317 const image_index = acquire.image_index;
318
319 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.in_flight_fence));
320 errdefer self.resignalFence(self.in_flight_fence);
321 ```
322
323 The `errdefer` will fire on any error returned after this line until `queueSubmit` succeeds. `queueSubmit` signals the fence on GPU completion, so once submit succeeds the fence is correctly signaled (or about to be); the errdefer becomes a no-op because the function returns success.
324
325 - [ ] **Step 3: Reorder `drawCells` (renderer.zig:1709-1733)**
326
327 In `drawCells`, find the block starting at line 1709 ("Wait for previous frame to finish") through line 1733 (the acquire timing-out block). Replace with:
328
329 ```zig
330 // Wait for previous frame to finish
331 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64));
332 if (timing_out) |t| {
333 t.wait_fences_us = readTimer(&timer);
334 timer.reset();
335 }
336
337 // Acquire next image BEFORE reset, so an acquire failure leaves the
338 // fence in a safe state (signaled from the prior frame).
339 const acquire = self.vkd.acquireNextImageKHR(
340 self.device,
341 self.swapchain,
342 std.math.maxInt(u64),
343 self.image_available,
344 .null_handle,
345 ) catch |err| switch (err) {
346 error.OutOfDateKHR => return error.OutOfDateKHR,
347 else => return err,
348 };
349 if (swapchainNeedsRebuild(acquire.result)) return error.OutOfDateKHR;
350 const image_index = acquire.image_index;
351 if (timing_out) |t| {
352 t.acquire_us = readTimer(&timer);
353 timer.reset();
354 }
355
356 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.in_flight_fence));
357 errdefer self.resignalFence(self.in_flight_fence);
358 ```
359
360 - [ ] **Step 4: Build and run tests**
361
362 ```bash
363 cd /home/xanderle/code/rad/waystty && zig build test
364 ```
365
366 Expected: all tests pass. The reorder does not change external behavior because the existing `UINT64_MAX` timeout means no path through the new code can fail in a way the errdefer would catch during normal operation.
367
368 - [ ] **Step 5: Smoke-test the binary**
369
370 ```bash
371 cd /home/xanderle/code/rad/waystty && zig build && ./zig-out/bin/waystty
372 ```
373
374 Type some characters, resize the window, and close. Expected: normal behavior, no crash or visual glitch.
375
376 Exit with Ctrl+D or by closing the window.
377
378 - [ ] **Step 6: Commit**
379
380 ```bash
381 cd /home/xanderle/code/rad/waystty
382 git add src/renderer.zig
383 git commit -m "$(cat <<'EOF'
384 renderer: reorder resetFences after acquire, add errdefer re-signal
385
386 Preparatory refactor for bounded acquire timeouts. When acquireNextImageKHR
387 gains a finite timeout (next commit), the existing ordering (reset → acquire)
388 would leave in_flight_fence unsignaled with no submit pending on a timeout,
389 deadlocking future waits. Reorder to (acquire → reset) and add a private
390 resignalFence helper that the errdefer path uses to cover the tiny
391 post-reset / pre-submit failure window.
392
393 Part of issue ab6c92f0.
394
395 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
396 EOF
397 )"
398 ```
399
400 ---
401
402 ## Task 3: Migrate 10 wait/acquire call sites to `vk_sync` helpers + handle timeouts in `main.zig`
403
404 **Files:**
405 - Modify: `src/renderer.zig` — 7 sites
406 - Modify: `src/main.zig` — 3 sites + new timeout error arm
407
408 - [ ] **Step 1: Import `vk_sync` in `src/renderer.zig`**
409
410 Near the top of `src/renderer.zig`, alongside the other imports (search for `const vk = @import("vulkan");`), add:
411
412 ```zig
413 const vk_sync = @import("vk_sync");
414 ```
415
416 - [ ] **Step 2: Migrate `drawClear` waits (renderer.zig:1275, 1279)**
417
418 In `drawClear` (lines 1273-1342), replace the two blocking calls we reordered in Task 2.
419
420 Replace:
421
422 ```zig
423 // Wait for previous frame to finish
424 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64));
425
426 // Acquire next image BEFORE reset, so an acquire failure leaves the
427 // fence in a safe state (signaled from the prior frame).
428 const acquire = self.vkd.acquireNextImageKHR(
429 self.device,
430 self.swapchain,
431 std.math.maxInt(u64),
432 self.image_available,
433 .null_handle,
434 ) catch |err| switch (err) {
435 error.OutOfDateKHR => return error.OutOfDateKHR,
436 else => return err,
437 };
438 if (swapchainNeedsRebuild(acquire.result)) return error.OutOfDateKHR;
439 const image_index = acquire.image_index;
440 ```
441
442 With:
443
444 ```zig
445 try vk_sync.waitFenceBounded(self.vkd, self.device, self.in_flight_fence);
446 const image_index = try vk_sync.acquireImageBounded(self.vkd, self.device, self.swapchain, self.image_available);
447 ```
448
449 (`swapchainNeedsRebuild` folding is now done inside `acquireImageBounded`, so the post-call check is gone.)
450
451 - [ ] **Step 3: Migrate `uploadAtlasRegion` wait + add errdefer re-signal (renderer.zig:1478-1479)**
452
453 Find lines 1478-1479:
454
455 ```zig
456 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.atlas_transfer_fence), .true, std.math.maxInt(u64));
457 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.atlas_transfer_fence));
458 ```
459
460 Replace with:
461
462 ```zig
463 try vk_sync.waitFenceBounded(self.vkd, self.device, self.atlas_transfer_fence);
464 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.atlas_transfer_fence));
465 errdefer self.resignalFence(self.atlas_transfer_fence);
466 ```
467
468 The `errdefer` covers the same post-reset / pre-submit failure window as in `drawCells` / `drawClear` (Task 2). Between line 1479 and the `queueSubmit` at line ~1574, several calls can fail (`mapMemory`, `resetCommandBuffer`, `beginCommandBuffer`, `endCommandBuffer`, `queueSubmit` itself). Without the re-signal, a single such failure would leave `atlas_transfer_fence` unsignaled forever, and every subsequent atlas upload would time out — permanently breaking glyph rendering for the session. The re-signal restores the invariant so the next upload behaves normally.
469
470 On wait timeout: we return before the reset runs, so the fence stays in its prior-submit-completed state. The re-signal `errdefer` only fires after reset.
471
472 - [ ] **Step 4: Migrate `drawCells` waits (renderer.zig:1710, 1718)**
473
474 Same pattern as `drawClear`. Replace:
475
476 ```zig
477 // Wait for previous frame to finish
478 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64));
479 if (timing_out) |t| {
480 t.wait_fences_us = readTimer(&timer);
481 timer.reset();
482 }
483
484 // Acquire next image BEFORE reset, so an acquire failure leaves the
485 // fence in a safe state (signaled from the prior frame).
486 const acquire = self.vkd.acquireNextImageKHR(
487 self.device,
488 self.swapchain,
489 std.math.maxInt(u64),
490 self.image_available,
491 .null_handle,
492 ) catch |err| switch (err) {
493 error.OutOfDateKHR => return error.OutOfDateKHR,
494 else => return err,
495 };
496 if (swapchainNeedsRebuild(acquire.result)) return error.OutOfDateKHR;
497 const image_index = acquire.image_index;
498 if (timing_out) |t| {
499 t.acquire_us = readTimer(&timer);
500 timer.reset();
501 }
502 ```
503
504 With:
505
506 ```zig
507 try vk_sync.waitFenceBounded(self.vkd, self.device, self.in_flight_fence);
508 if (timing_out) |t| {
509 t.wait_fences_us = readTimer(&timer);
510 timer.reset();
511 }
512
513 const image_index = try vk_sync.acquireImageBounded(self.vkd, self.device, self.swapchain, self.image_available);
514 if (timing_out) |t| {
515 t.acquire_us = readTimer(&timer);
516 timer.reset();
517 }
518 ```
519
520 - [ ] **Step 5: Migrate `renderToOffscreen` waits + add errdefer re-signal for capture_fence (renderer.zig:1818, 1824-1825, 1941)**
521
522 Find line 1818:
523
524 ```zig
525 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.in_flight_fence), .true, std.math.maxInt(u64));
526 ```
527
528 Replace with:
529
530 ```zig
531 try vk_sync.waitFenceBounded(self.vkd, self.device, self.in_flight_fence);
532 ```
533
534 **This is the critical ordering requirement from spec Module 3**: the wait-before-uploadInstances must propagate the timeout error before `uploadInstances` (line ~1821) mutates shared state. Since we use `try`, propagation happens before the upload — verify by visual inspection that `uploadInstances` is called on a line AFTER the `try vk_sync.waitFenceBounded`.
535
536 Find lines 1824-1825 (wait + reset for capture_fence):
537
538 ```zig
539 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
540 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.capture_fence));
541 ```
542
543 Replace with:
544
545 ```zig
546 try vk_sync.waitFenceBounded(self.vkd, self.device, self.capture_fence);
547 try self.vkd.resetFences(self.device, 1, @ptrCast(&self.capture_fence));
548 errdefer self.resignalFence(self.capture_fence);
549 ```
550
551 Same rationale as uploadAtlasRegion: reset → record → submit has several failure points; re-signal on failure preserves the fence invariant. Capture path is not daily-driver but the pattern should be uniform.
552
553 Find line 1941:
554
555 ```zig
556 _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.capture_fence), .true, std.math.maxInt(u64));
557 ```
558
559 Replace with:
560
561 ```zig
562 try vk_sync.waitFenceBounded(self.vkd, self.device, self.capture_fence);
563 ```
564
565 No re-signal needed here — this is a post-submit wait (waiting for the capture to complete so we can read back the image), not part of a wait→reset→submit chain.
566
567 - [ ] **Step 6: Import `vk_sync` in `src/main.zig`**
568
569 Near the top of `src/main.zig`, alongside `const vk = @import("vulkan");` (line 9), add:
570
571 ```zig
572 const vk_sync = @import("vk_sync");
573 ```
574
575 - [ ] **Step 7: Migrate `drawTextCoverageCompareFrame` waits (main.zig:1364, 1373)**
576
577 Read lines 1360-1390 of `src/main.zig` first to confirm the current shape.
578
579 Find the 7-line call at line 1364:
580
581 ```zig
582 _ = try ctx.vkd.waitForFences(
583 ctx.device,
584 1,
585 @ptrCast(&ctx.in_flight_fence),
586 .true,
587 std.math.maxInt(u64),
588 );
589 ```
590
591 Replace with:
592
593 ```zig
594 try vk_sync.waitFenceBounded(ctx.vkd, ctx.device, ctx.in_flight_fence);
595 ```
596
597 Find the acquire at line 1373 (multi-line call — read ~1373-1383 to see it, then replace the full call + any post-check with):
598
599 ```zig
600 const image_index = try vk_sync.acquireImageBounded(ctx.vkd, ctx.device, ctx.swapchain, ctx.image_available);
601 ```
602
603 If the existing code has a `resetFences` before the acquire, move it to after (same pattern as drawClear/drawCells in Task 2). If this is a bench/smoke path that doesn't reset the fence between frames (i.e. a single-shot call), leave the reset where it was — the invariant only matters across multiple frames.
604
605 - [ ] **Step 8: Add timeout error arm in `main.zig` `runTerminal` render loop (main.zig:~679)**
606
607 Find the `drawCells` call in `runTerminal` and its error switch at lines 673-690:
608
609 ```zig
610 ctx.drawCells(
611 render_cache.total_instance_count,
612 .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
613 default_bg,
614 baseline_coverage,
615 if (is_bench) &submit_timing else null,
616 ) catch |err| switch (err) {
617 error.OutOfDateKHR => {
618 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
619 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
620 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
621 try ctx.recreateSwapchain(buf_w, buf_h);
622 frame_loop.forceArm();
623 render_pending = true;
624 continue;
625 },
626 else => return err,
627 };
628 ```
629
630 Add a `VkWaitTimeout`/`VkAcquireTimeout` arm. Replace the switch with:
631
632 ```zig
633 ctx.drawCells(
634 render_cache.total_instance_count,
635 .{ @floatFromInt(cell_w), @floatFromInt(cell_h) },
636 default_bg,
637 baseline_coverage,
638 if (is_bench) &submit_timing else null,
639 ) catch |err| switch (err) {
640 error.OutOfDateKHR => {
641 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
642 const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
643 const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
644 try ctx.recreateSwapchain(buf_w, buf_h);
645 frame_loop.forceArm();
646 render_pending = true;
647 continue;
648 },
649 error.VkWaitTimeout, error.VkAcquireTimeout => {
650 vk_sync.logVkTimeout(@src(), .fence);
651 render_pending = true;
652 continue;
653 },
654 else => return err,
655 };
656 ```
657
658 - [ ] **Step 9: Handle atlas upload timeout in the atlas upload caller (main.zig:591-606)**
659
660 Find the atlas upload block:
661
662 ```zig
663 if (atlas.dirty) {
664 const y_start = atlas.last_uploaded_y;
665 const y_end = atlas.cursor_y + atlas.row_height;
666 if (y_start < y_end) {
667 try ctx.uploadAtlasRegion(
668 atlas.pixels,
669 y_start,
670 y_end,
671 atlas.needs_full_upload,
672 );
673 atlas.last_uploaded_y = atlas.cursor_y;
674 atlas.needs_full_upload = false;
675 render_cache.layout_dirty = true;
676 }
677 atlas.dirty = false;
678 }
679 ```
680
681 Replace the `try ctx.uploadAtlasRegion(...)` with a `catch` that handles `VkWaitTimeout`:
682
683 ```zig
684 if (atlas.dirty) {
685 const y_start = atlas.last_uploaded_y;
686 const y_end = atlas.cursor_y + atlas.row_height;
687 if (y_start < y_end) {
688 ctx.uploadAtlasRegion(
689 atlas.pixels,
690 y_start,
691 y_end,
692 atlas.needs_full_upload,
693 ) catch |err| switch (err) {
694 error.VkWaitTimeout => {
695 vk_sync.logVkTimeout(@src(), .atlas);
696 render_pending = true;
697 continue;
698 },
699 else => return err,
700 };
701 atlas.last_uploaded_y = atlas.cursor_y;
702 atlas.needs_full_upload = false;
703 render_cache.layout_dirty = true;
704 }
705 atlas.dirty = false;
706 }
707 ```
708
709 The `continue` re-enters the render loop. `atlas.dirty` stays `true` because the `atlas.dirty = false` line is below the failing path.
710
711 - [ ] **Step 10: Build and run tests**
712
713 ```bash
714 cd /home/xanderle/code/rad/waystty && zig build test
715 ```
716
717 Expected: all tests pass.
718
719 - [ ] **Step 11: Smoke-test the binary**
720
721 ```bash
722 cd /home/xanderle/code/rad/waystty && zig build && ./zig-out/bin/waystty
723 ```
724
725 Type characters, resize, type more, close. Expected: no visible behavior change.
726
727 - [ ] **Step 12: Commit**
728
729 ```bash
730 cd /home/xanderle/code/rad/waystty
731 git add src/renderer.zig src/main.zig
732 git commit -m "$(cat <<'EOF'
733 Migrate 10 Vulkan wait/acquire sites to bounded helpers
734
735 Replaces unbounded waitForFences/acquireNextImageKHR calls in drawClear,
736 drawCells, uploadAtlasRegion, renderToOffscreen (three waits), and
737 drawTextCoverageCompareFrame with vk_sync.waitFenceBounded /
738 acquireImageBounded. Adds VkWaitTimeout/VkAcquireTimeout error arms in
739 main.zig that log via vk_sync.logVkTimeout, mark the frame dirty, and
740 retry on the next loop iteration. Atlas upload timeouts propagate the
741 dirty flag via the existing pre-assign guard.
742
743 Part of issue ab6c92f0.
744
745 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
746 EOF
747 )"
748 ```
749
750 ---
751
752 ## Task 4: Migrate 14 `*WaitIdle` sites to `waitIdleForShutdown`
753
754 **Files:**
755 - Modify: `src/renderer.zig` — `deviceWaitIdle` at lines 1136, 1262; `queueWaitIdle` at line 1458
756 - Modify: `src/main.zig` — `deviceWaitIdle` at lines 424, 462, 479, 681, 717, 2501, 2536, 2551, 2637, 2647, 3082
757
758 14 sites total: 13 `deviceWaitIdle` + 1 `queueWaitIdle`. All are currently unbounded; migrating to `waitIdleForShutdown` (which wraps `deviceWaitIdle`) preserves behavior while putting intent at the call site.
759
760 The `queueWaitIdle` at renderer.zig:1458 currently uses a queue handle, not a device handle. Since we only have a `waitIdleForShutdown` for devices (not queues — `queueWaitIdleBounded` was stubbed as unused), migrate this one to use the device-wide form instead (`waitIdleForShutdown(self.vkd, self.device)`). This is a behavior widening — waiting on the device is stricter than waiting on one queue, but this code path is a shutdown/deinit-ish drain and the wider wait is safe. Verify by reading the surrounding context.
761
762 - [ ] **Step 1: Read the `queueWaitIdle` site for safety check**
763
764 Read `/home/xanderle/code/rad/waystty/src/renderer.zig` lines 1445-1475.
765
766 Confirm that the `queueWaitIdle` at line 1458 is in a teardown/drain context where widening to `deviceWaitIdle` is acceptable. If it's in a hot path, stop and raise — the plan may need adjustment.
767
768 If it's in a clearly-shutdown context (e.g., followed by destroy calls), continue.
769
770 - [ ] **Step 2: Migrate `deviceWaitIdle` sites in `src/renderer.zig`**
771
772 Find line 1136:
773
774 ```zig
775 _ = self.vkd.deviceWaitIdle(self.device) catch {};
776 ```
777
778 Replace with:
779
780 ```zig
781 vk_sync.waitIdleForShutdown(self.vkd, self.device);
782 ```
783
784 Find line 1262:
785
786 ```zig
787 _ = try self.vkd.deviceWaitIdle(self.device);
788 ```
789
790 Replace with:
791
792 ```zig
793 vk_sync.waitIdleForShutdown(self.vkd, self.device);
794 ```
795
796 (Note: `waitIdleForShutdown` returns `void`, not `!void`, and logs errors internally. The `try` goes away; any error is swallowed with a log line. This matches the existing `catch {}` behavior at line 1136 and widens the behavior at line 1262 from "crash on deinit error" to "log on deinit error" — the latter is more appropriate.)
797
798 - [ ] **Step 3: Migrate `queueWaitIdle` site in `src/renderer.zig`**
799
800 Find line 1458:
801
802 ```zig
803 try self.vkd.queueWaitIdle(self.graphics_queue);
804 ```
805
806 Replace with:
807
808 ```zig
809 vk_sync.waitIdleForShutdown(self.vkd, self.device);
810 ```
811
812 Again, dropping the `try` — errors are swallowed.
813
814 - [ ] **Step 4: Migrate all `deviceWaitIdle` sites in `src/main.zig`**
815
816 Per the spec, **all 11 sites migrate mechanically** to `waitIdleForShutdown`. The follow-up ticket (Task 7) audits which of these should actually become bounded later; for now, the named helper preserves the existing unbounded behavior while documenting intent at the call site and satisfying the grep gate.
817
818 Run this grep to list the current sites (line numbers may have shifted during earlier tasks):
819
820 ```bash
821 cd /home/xanderle/code/rad/waystty && grep -n "ctx.vkd.deviceWaitIdle" src/main.zig
822 ```
823
824 Expected: 11 matches (originally at 424, 462, 479, 681, 717, 2501, 2536, 2551, 2637, 2647, 3082 — possibly shifted by ±a few).
825
826 For each line, replace:
827
828 ```zig
829 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
830 ```
831
832 With:
833
834 ```zig
835 vk_sync.waitIdleForShutdown(ctx.vkd, ctx.device);
836 ```
837
838 (Preserve the original leading indentation. The pattern is identical on every site per the pre-plan grep output — one-liner assignment discarded via `_ =` with a `try`.)
839
840 `sed` one-shot, since the pattern is uniform and all sites migrate:
841
842 ```bash
843 cd /home/xanderle/code/rad/waystty
844 sed -i 's|_ = try ctx\.vkd\.deviceWaitIdle(ctx\.device);|vk_sync.waitIdleForShutdown(ctx.vkd, ctx.device);|g' src/main.zig
845 ```
846
847 After the migration, verify no raw calls remain:
848
849 ```bash
850 grep -n "ctx.vkd.deviceWaitIdle" src/main.zig
851 ```
852
853 Expected: zero results.
854
855 **Note on the recovery arms (former lines 424, 462, 479, 681)**: These are mid-flight paths (scale change, resize, OutOfDateKHR), not shutdown drains. Migrating to `waitIdleForShutdown` keeps them unbounded — if the driver wedges here, we still hang. This is a known limitation tracked by the follow-up ticket opened in Task 7; the task there is to implement `waitIdleBounded` (currently stubbed) and migrate these four specific sites to it. For v1, the behavior-preserving migration is intentional.
856
857 - [ ] **Step 5: Build and run tests**
858
859 ```bash
860 cd /home/xanderle/code/rad/waystty && zig build test
861 ```
862
863 Expected: all tests pass. Build may fail if any of the `try` → no-try migrations break type inference in the surrounding function — if so, the site is in a context that genuinely needed the `!void`; re-examine that site and either keep the `try`-compatible form (keep as-is, flag with the grep gate) or adjust.
864
865 - [ ] **Step 6: Smoke-test the binary**
866
867 ```bash
868 cd /home/xanderle/code/rad/waystty && zig build && ./zig-out/bin/waystty
869 ```
870
871 Expected: no visible change.
872
873 Resize the window multiple times (triggers the `OutOfDateKHR` path that still uses `deviceWaitIdle` at line 681). Expected: resize works normally.
874
875 - [ ] **Step 7: Commit**
876
877 ```bash
878 cd /home/xanderle/code/rad/waystty
879 git add src/renderer.zig src/main.zig
880 git commit -m "$(cat <<'EOF'
881 Migrate 14 *WaitIdle sites to vk_sync.waitIdleForShutdown
882
883 Mechanically migrates 13 deviceWaitIdle + 1 queueWaitIdle sites to
884 the named waitIdleForShutdown helper. Preserves existing unbounded
885 behavior while documenting intent at the call site and satisfying
886 the upcoming grep gate.
887
888 Four of these sites are mid-flight recovery paths (scale change,
889 resize, OutOfDateKHR) rather than shutdown drains; those are flagged
890 in a follow-up ticket for migration to a bounded variant once
891 waitIdleBounded is implemented.
892
893 Part of issue ab6c92f0.
894
895 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
896 EOF
897 )"
898 ```
899
900 ---
901
902 ## Task 5: Add grep gate shell script + wire to build.zig test step
903
904 **Files:**
905 - Create: `tests/check_unbounded_vk.sh`
906 - Modify: `build.zig` (add the script to the test step)
907
908 The grep gate fails CI if any source file outside `src/vk_sync.zig` calls `vkd.waitForFences`, `vkd.acquireNextImageKHR`, `vkd.deviceWaitIdle`, or `vkd.queueWaitIdle` directly. After Task 4 migrates all 14 sites to `waitIdleForShutdown` (which wraps `deviceWaitIdle` inside `vk_sync.zig`), the gate will pass with zero violations. No allowlist mechanism is needed.
909
910 - [ ] **Step 1: Create `tests/check_unbounded_vk.sh`**
911
912 Write the full script:
913
914 ```bash
915 #!/usr/bin/env bash
916 # Grep gate: fail if any source file outside src/vk_sync.zig calls
917 # Vulkan blocking primitives directly. All such calls must go through
918 # src/vk_sync.zig helpers (waitFenceBounded, acquireImageBounded,
919 # waitIdleForShutdown, etc.).
920
921 set -euo pipefail
922
923 REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
924 cd "$REPO_ROOT"
925
926 PATTERNS=(
927 'vkd\.waitForFences'
928 'vkd\.acquireNextImageKHR'
929 'vkd\.deviceWaitIdle'
930 'vkd\.queueWaitIdle'
931 )
932
933 # Find all .zig files in src/ except vk_sync.zig
934 mapfile -t files < <(find src -name '*.zig' ! -path 'src/vk_sync.zig' | sort)
935
936 violations=0
937 violation_lines=()
938
939 for file in "${files[@]}"; do
940 for pat in "${PATTERNS[@]}"; do
941 while IFS= read -r hit; do
942 violation_lines+=("$file:$hit")
943 violations=$((violations + 1))
944 done < <(grep -nE "$pat" "$file" || true)
945 done
946 done
947
948 if [[ $violations -gt 0 ]]; then
949 echo "ERROR: $violations unbounded Vulkan wait call(s) found outside src/vk_sync.zig:" >&2
950 for line in "${violation_lines[@]}"; do
951 echo " $line" >&2
952 done
953 echo "" >&2
954 echo "Use src/vk_sync.zig helpers instead:" >&2
955 echo " waitFenceBounded — replace vkd.waitForFences" >&2
956 echo " acquireImageBounded — replace vkd.acquireNextImageKHR" >&2
957 echo " waitIdleForShutdown — replace vkd.deviceWaitIdle / queueWaitIdle" >&2
958 exit 1
959 fi
960
961 echo "vk grep gate: ok (${#files[@]} files, no violations)"
962 ```
963
964 Then make it executable:
965
966 ```bash
967 chmod +x /home/xanderle/code/rad/waystty/tests/check_unbounded_vk.sh
968 ```
969
970 - [ ] **Step 2: Wire into `build.zig` test step**
971
972 Near the bottom of the `build()` function in `build.zig`, after all the other `test_step.dependOn(...)` calls (search for the last `test_step.dependOn`), add:
973
974 ```zig
975 const check_unbounded_vk = b.addSystemCommand(&.{ "tests/check_unbounded_vk.sh" });
976 test_step.dependOn(&check_unbounded_vk.step);
977 ```
978
979 - [ ] **Step 3: Run the grep gate in isolation**
980
981 ```bash
982 cd /home/xanderle/code/rad/waystty && ./tests/check_unbounded_vk.sh
983 ```
984
985 Expected output:
986
987 ```
988 vk grep gate: ok (N files, no violations)
989 ```
990
991 Where N is the count of `.zig` files in `src/` minus 1 (for `vk_sync.zig`).
992
993 If it fails, the message lists the violating sites. Any match indicates a raw Vulkan wait call that Task 3 or Task 4 missed — migrate it to the appropriate helper.
994
995 - [ ] **Step 4: Run the full test suite**
996
997 ```bash
998 cd /home/xanderle/code/rad/waystty && zig build test
999 ```
1000
1001 Expected: all tests pass, grep gate reports "vk grep gate: ok".
1002
1003 - [ ] **Step 5: Verify the gate catches a regression**
1004
1005 Temporarily add to any `.zig` file outside `src/vk_sync.zig`:
1006
1007 ```zig
1008 // REGRESSION TEST — DELETE THIS LINE
1009 const _ignore = @compileError("unused"); // prevent use
1010 // fake: self.vkd.waitForFences(...)
1011 ```
1012
1013 Actually, simpler: just add this line to `src/renderer.zig` (any function body):
1014
1015 ```zig
1016 _ = self.vkd.waitForFences; // REGRESSION TEST — DELETE
1017 ```
1018
1019 Run `zig build test`. Expected: build fails or grep gate reports one violation pointing at that line.
1020
1021 Remove the regression line. Run `zig build test` again. Expected: passes.
1022
1023 - [ ] **Step 6: Commit**
1024
1025 ```bash
1026 cd /home/xanderle/code/rad/waystty
1027 git add tests/check_unbounded_vk.sh build.zig
1028 git commit -m "$(cat <<'EOF'
1029 Add grep gate that forbids unbounded Vulkan waits outside vk_sync
1030
1031 tests/check_unbounded_vk.sh scans src/ for direct calls to
1032 vkd.waitForFences / acquireNextImageKHR / deviceWaitIdle /
1033 queueWaitIdle and fails CI if any appear outside src/vk_sync.zig.
1034 Wired into zig build test.
1035
1036 Part of issue ab6c92f0.
1037
1038 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1039 EOF
1040 )"
1041 ```
1042
1043 ---
1044
1045 ## Task 6: Add backoff to the timeout error arm
1046
1047 **Files:**
1048 - Modify: `src/main.zig` — the `error.VkWaitTimeout, error.VkAcquireTimeout` arm in `runTerminal`
1049
1050 - [ ] **Step 1: Add a `consecutive_vk_timeouts` counter**
1051
1052 In `src/main.zig` `runTerminal`, find the render loop start. Locate the existing loop-state variables (e.g. `var render_pending: bool = false;` near the top of `runTerminal`). Add alongside them:
1053
1054 ```zig
1055 var consecutive_vk_timeouts: u32 = 0;
1056 ```
1057
1058 - [ ] **Step 2: Modify the timeout arm to apply backoff and reset on success**
1059
1060 Find the block added in Task 3 Step 8:
1061
1062 ```zig
1063 error.VkWaitTimeout, error.VkAcquireTimeout => {
1064 vk_sync.logVkTimeout(@src(), .fence);
1065 render_pending = true;
1066 continue;
1067 },
1068 ```
1069
1070 Replace with:
1071
1072 ```zig
1073 error.VkWaitTimeout, error.VkAcquireTimeout => {
1074 vk_sync.logVkTimeout(@src(), .fence);
1075 consecutive_vk_timeouts +|= 1;
1076 const backoff_us: u64 = @min(@as(u64, consecutive_vk_timeouts) * 5_000, 100_000);
1077 std.time.sleep(backoff_us * std.time.ns_per_us);
1078 render_pending = true;
1079 continue;
1080 },
1081 ```
1082
1083 (`+|=` is saturating-add, avoiding overflow if somehow a billion timeouts accumulate.)
1084
1085 Also update the atlas timeout arm from Task 3 Step 9 to participate in the same backoff:
1086
1087 ```zig
1088 error.VkWaitTimeout => {
1089 vk_sync.logVkTimeout(@src(), .atlas);
1090 consecutive_vk_timeouts +|= 1;
1091 const backoff_us: u64 = @min(@as(u64, consecutive_vk_timeouts) * 5_000, 100_000);
1092 std.time.sleep(backoff_us * std.time.ns_per_us);
1093 render_pending = true;
1094 continue;
1095 },
1096 ```
1097
1098 - [ ] **Step 3: Reset the counter on success**
1099
1100 Find the end of a successful drawCells frame. In `runTerminal`, the successful path continues past the `catch` switch and eventually gets to `frame_ring.push(frame_timing);` or similar (around line 698). Immediately after the `catch` switch on `drawCells` returns (i.e., just after the `}` that closes the switch), but before the rest of the frame completes, add:
1101
1102 ```zig
1103 consecutive_vk_timeouts = 0;
1104 ```
1105
1106 The cleanest spot is on the line immediately after the `};` that closes the `drawCells` switch:
1107
1108 ```zig
1109 }) catch |err| switch (err) {
1110 // ... existing arms ...
1111 error.VkWaitTimeout, error.VkAcquireTimeout => {
1112 // ...
1113 continue;
1114 },
1115 else => return err,
1116 };
1117 consecutive_vk_timeouts = 0; // <-- add this
1118 frame_timing.gpu_submit_us = usFromTimer(&section_timer);
1119 ```
1120
1121 (Verify the exact location by reading main.zig lines 689-700.)
1122
1123 - [ ] **Step 4: Build and run tests**
1124
1125 ```bash
1126 cd /home/xanderle/code/rad/waystty && zig build test
1127 ```
1128
1129 Expected: all tests pass.
1130
1131 - [ ] **Step 5: Smoke-test**
1132
1133 ```bash
1134 cd /home/xanderle/code/rad/waystty && zig build && ./zig-out/bin/waystty
1135 ```
1136
1137 Type, resize, close. Expected: no visible behavior change (since we never trigger the timeout path in normal operation).
1138
1139 - [ ] **Step 6: Commit**
1140
1141 ```bash
1142 cd /home/xanderle/code/rad/waystty
1143 git add src/main.zig
1144 git commit -m "$(cat <<'EOF'
1145 main: add exponential backoff on Vulkan timeout retries
1146
1147 If the driver is genuinely wedged, the timeout-retry loop would spin
1148 at full speed. Track consecutive VkWaitTimeout / VkAcquireTimeout
1149 events and sleep 5ms * N (capped at 100ms) before retrying. Counter
1150 resets on any successful frame.
1151
1152 Closes issue ab6c92f0.
1153
1154 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1155 EOF
1156 )"
1157 ```
1158
1159 ---
1160
1161 ## Task 7: Ticket bookkeeping
1162
1163 **Files:**
1164 - No source files modified.
1165
1166 Close duplicate ticket, open follow-up, move `ab6c92f0` to review state once patch is filed.
1167
1168 - [ ] **Step 1: Sync collab state**
1169
1170 ```bash
1171 cd /home/xanderle/code/rad/waystty && git-collab sync
1172 ```
1173
1174 Expected: "Sync complete." No errors.
1175
1176 - [ ] **Step 2: Close the duplicate ticket**
1177
1178 ```bash
1179 cd /home/xanderle/code/rad/waystty && git-collab issue close 793f491a --reason "[claude 2026-04-18] Duplicate of ab6c92f0 (older by ~1 minute, identical body). Closing this one; work tracked on ab6c92f0."
1180 git-collab sync
1181 ```
1182
1183 - [ ] **Step 3: Open the follow-up ticket for the mid-flight deviceWaitIdle audit**
1184
1185 ```bash
1186 cd /home/xanderle/code/rad/waystty
1187 NEW=$(git-collab issue open \
1188 --title "Audit mid-flight waitIdleForShutdown sites for bounded semantics" \
1189 --body "[claude 2026-04-18] Opened as follow-up to ab6c92f0 (Vulkan bounded waits).
1190
1191 The bounded-waits work migrated all 14 deviceWaitIdle / queueWaitIdle
1192 sites to vk_sync.waitIdleForShutdown (which wraps vkd.deviceWaitIdle
1193 with an unbounded wait and swallows errors to a log line). This
1194 preserved existing behavior while satisfying the grep gate.
1195
1196 However, four of those sites are mid-flight recovery paths — not
1197 shutdown drains — where the driver could wedge exactly the way
1198 ab6c92f0 documented:
1199
1200 - src/main.zig (scale_pending arm) — before rebuildFaceForScale
1201 - src/main.zig (resize arm, grid-changed branch) — before recreateSwapchain
1202 - src/main.zig (resize arm, grid-unchanged branch) — before recreateSwapchain
1203 - src/main.zig (OutOfDateKHR arm in drawCells catch) — before recreateSwapchain
1204
1205 All four wait unbounded while the driver may be wedged. If the hang
1206 mode from ab6c92f0 fires during a resize or scale change, we'll freeze
1207 again with the same symptoms.
1208
1209 Scope of this ticket:
1210 1. Implement vk_sync.waitIdleBounded (currently a @compileError stub).
1211 The fence-based emulation: submit a no-op to the graphics queue,
1212 wait on its fence with a timeout. Return error.VkWaitTimeout on
1213 timeout.
1214 2. Migrate the four mid-flight sites above from waitIdleForShutdown to
1215 waitIdleBounded (keep the other 10 shutdown/init sites as-is).
1216 3. In each caller, handle the new timeout error: log, skip the recovery
1217 step, retry next iteration. The existing recreateSwapchain path is
1218 already robust to being called repeatedly.
1219
1220 Priority: low. The NVIDIA 595 driver flake observed on 2026-04-18 was
1221 a fence-wait wedge, not a resize/scale wedge. Resize/scale paths have
1222 not been observed to hang. File so the work isn't lost." \
1223 | awk '{print $NF}')
1224 git-collab issue label "$NEW" backlog
1225 git-collab sync
1226 echo "Opened follow-up: $NEW"
1227 ```
1228
1229 - [ ] **Step 4: Move ab6c92f0 to review state**
1230
1231 At this point, all the implementation work is committed on `main` (or a branch). The issue should transition `backlog → planning → dev → review`.
1232
1233 ```bash
1234 cd /home/xanderle/code/rad/waystty
1235 git-collab issue unlabel ab6c92f0 backlog
1236 git-collab issue label ab6c92f0 review
1237 git-collab issue comment ab6c92f0 --body "[claude 2026-04-18] Implementation complete. Spec: docs/superpowers/specs/2026-04-18-vulkan-bounded-waits-design.md. Plan: docs/superpowers/plans/2026-04-18-vulkan-bounded-waits.md. Six commits on main (or feature branch; see git log). Follow-up for the OutOfDateKHR recovery arm is the new ticket opened by this work."
1238 git-collab sync
1239 ```
1240
1241 (If work is on a feature branch, file a patch with `git-collab patch create --fixes ab6c92f0 ...` instead of directly labeling `review`. See the git-collab skill docs.)
1242
1243 ---
1244
1245 ## Self-Review Checklist
1246
1247 After all tasks complete, verify:
1248
1249 - [ ] **Spec coverage.** Every module in the spec is implemented somewhere:
1250 - Module 1 (vk_sync.zig helpers) — Task 1
1251 - Module 2 (caller updates, 10 sites) — Task 3
1252 - Module 3 (fence-state invariant + reorder) — Task 2
1253 - Module 4 (atlas timeout policy) — Task 3 Step 9
1254 - Module 5 (logging) — Task 1 (logger) + Task 3 (call sites)
1255 - Module 6 (backoff) — Task 6
1256 - Module 7 (grep gate) — Task 5
1257
1258 - [ ] **Type consistency.** The helpers are `vk_sync.waitFenceBounded`, `vk_sync.acquireImageBounded`, `vk_sync.waitIdleForShutdown`, `vk_sync.logVkTimeout` — same names used in every task.
1259
1260 - [ ] **No placeholders.** No TBDs, no "implement later" in any task. Every code snippet is the code the engineer types.
1261
1262 - [ ] **`consecutive_vk_timeouts` is reset on success, not only at function start.** Verify Step 3 of Task 6 places the reset after the drawCells switch returns successfully.
1263
1264 - [ ] **Atlas path uses the same `consecutive_vk_timeouts` counter as the draw path.** Task 6 Step 2 updates both arms to `+|= 1`.
1265
1266 - [ ] **Grep gate passes with zero violations after Task 4 migration.** No allowlist needed; every raw `vkd.*` call is now inside `src/vk_sync.zig`.
1267
1268 - [ ] **All six commits follow the spec's suggested order.** Task 1 → Task 2 → Task 3 → Task 4 → Task 5 → Task 6 → (Task 7 is collab bookkeeping, not a source commit).
1269
1270 ---
1271
1272 ## Rollback
1273
1274 If any commit breaks the build or causes regressions:
1275
1276 ```bash
1277 cd /home/xanderle/code/rad/waystty
1278 git log --oneline -10
1279 # identify the offending commit SHA
1280 git revert <SHA>
1281 ```
1282
1283 The plan is ordered so each commit is independently revertable: Task 1 (module only, no callers) is always safe to revert. Task 2 (reorder) is safe to revert before Task 3. Task 3 and later can be reverted individually as long as later tasks are reverted first (LIFO).
1284
1285 If the whole thing needs to come out at once: revert the six commits in reverse order, or use `git reset --hard <pre-work-SHA>` if the work is on a branch and nothing depends on it yet (confirm with the user before hard-reset).