03430faf
Add Vulkan bounded-waits implementation plan
a73x 2026-04-18 11:40
Commit message
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(§ion_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). | ||