a73x

2c50c63e

Add vk_sync module with bounded Vulkan wait helpers

a73x   2026-04-18 12:28

Commit message
Add vk_sync module with bounded Vulkan wait helpers

Introduces src/vk_sync.zig with waitFenceBounded, acquireImageBounded,
waitIdleForShutdown, and a rate-limited logVkTimeout. No callers
migrated yet — follow-up commits migrate the 10 timeout sites and 14
*WaitIdle sites, then land the grep gate.

Part of issue ab6c92f0.

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

build.zig
Old New
@@ -320,6 +320,27 @@ pub fn build(b: *std.Build) void {
320 exe_mod.addImport("cell_instance", cell_instance_mod); 320 exe_mod.addImport("cell_instance", cell_instance_mod);
321 main_test_mod.addImport("cell_instance", cell_instance_mod); 321 main_test_mod.addImport("cell_instance", cell_instance_mod);
322 322
323 // vk_sync module — bounded Vulkan synchronization helpers
324 const vk_sync_mod = b.createModule(.{
325 .root_source_file = b.path("src/vk_sync.zig"),
326 .target = target,
327 .optimize = optimize,
328 });
329 vk_sync_mod.addImport("vulkan", vulkan_module);
330 renderer_mod.addImport("vk_sync", vk_sync_mod);
331 renderer_test_mod.addImport("vk_sync", vk_sync_mod);
332 exe_mod.addImport("vk_sync", vk_sync_mod);
333 main_test_mod.addImport("vk_sync", vk_sync_mod);
334
335 const vk_sync_test_mod = b.createModule(.{
336 .root_source_file = b.path("src/vk_sync.zig"),
337 .target = target,
338 .optimize = optimize,
339 });
340 vk_sync_test_mod.addImport("vulkan", vulkan_module);
341 const vk_sync_tests = b.addTest(.{ .root_module = vk_sync_test_mod });
342 test_step.dependOn(&b.addRunArtifact(vk_sync_tests).step);
343
323 // capture module — --capture mode (render a VT script to PNG) 344 // capture module — --capture mode (render a VT script to PNG)
324 const capture_mod = b.createModule(.{ 345 const capture_mod = b.createModule(.{
325 .root_source_file = b.path("src/capture.zig"), 346 .root_source_file = b.path("src/capture.zig"),
@@ -336,6 +357,7 @@ pub fn build(b: *std.Build) void {
336 capture_mod.addImport("png", png_mod); 357 capture_mod.addImport("png", png_mod);
337 capture_mod.addImport("vulkan", vulkan_module); 358 capture_mod.addImport("vulkan", vulkan_module);
338 capture_mod.addImport("cell_instance", cell_instance_mod); 359 capture_mod.addImport("cell_instance", cell_instance_mod);
360 capture_mod.addImport("vk_sync", vk_sync_mod);
339 exe_mod.addImport("capture", capture_mod); 361 exe_mod.addImport("capture", capture_mod);
340 362
341 // imgdiff — standalone PNG comparison tool 363 // imgdiff — standalone PNG comparison tool
src/vk_sync.zig
Old New
@@ -0,0 +1,161 @@
1 //! Bounded Vulkan synchronization primitives.
2 //!
3 //! Replaces unbounded vkWaitForFences / vkAcquireNextImageKHR / vkDeviceWaitIdle /
4 //! vkQueueWaitIdle calls. The helpers here are the ONLY path callers should use
5 //! for blocking Vulkan operations — the grep gate in tests/check_unbounded_vk.sh
6 //! enforces this at CI time.
7 //!
8 //! Motivation: NVIDIA driver 595 occasionally drops a fence signal, wedging
9 //! vkWaitForFences(UINT64_MAX) forever. See docs/superpowers/specs/
10 //! 2026-04-18-vulkan-bounded-waits-design.md for the full story.
11
12 const std = @import("std");
13 const vk = @import("vulkan");
14
15 pub const fence_wait_timeout_ns: u64 = 2_000_000_000; // 2s
16 pub const acquire_timeout_ns: u64 = 100_000_000; // 100ms
17
18 pub const SyncError = error{ VkWaitTimeout, VkAcquireTimeout };
19
20 /// Bounded fence wait. Returns error.VkWaitTimeout on timeout without touching
21 /// the fence. Caller may safely retry on the next iteration.
22 pub fn waitFenceBounded(
23 vkd: vk.DeviceWrapper,
24 device: vk.Device,
25 fence: vk.Fence,
26 ) !void {
27 const result = try vkd.waitForFences(device, 1, @ptrCast(&fence), .true, fence_wait_timeout_ns);
28 if (result == .timeout) return error.VkWaitTimeout;
29 }
30
31 /// Bounded image acquire. Returns the acquired image_index on success.
32 /// Folds VK_SUBOPTIMAL_KHR into error.OutOfDateKHR (matches existing callers,
33 /// which already collapse the two via swapchainNeedsRebuild).
34 /// Returns error.VkAcquireTimeout on VK_TIMEOUT or VK_NOT_READY.
35 pub fn acquireImageBounded(
36 vkd: vk.DeviceWrapper,
37 device: vk.Device,
38 swapchain: vk.SwapchainKHR,
39 semaphore: vk.Semaphore,
40 ) !u32 {
41 const acquire = vkd.acquireNextImageKHR(
42 device,
43 swapchain,
44 acquire_timeout_ns,
45 semaphore,
46 .null_handle,
47 ) catch |err| switch (err) {
48 error.OutOfDateKHR => return error.OutOfDateKHR,
49 else => return err,
50 };
51 switch (acquire.result) {
52 .timeout, .not_ready => return error.VkAcquireTimeout,
53 .suboptimal_khr => return error.OutOfDateKHR,
54 .success => return acquire.image_index,
55 else => return acquire.image_index, // unexpected but non-error; trust the image_index
56 }
57 }
58
59 /// Bounded device-idle wait. For mid-flight resyncs where blocking forever
60 /// would be wrong. Returns error.VkWaitTimeout on timeout.
61 pub fn waitIdleBounded(vkd: vk.DeviceWrapper, device: vk.Device, timeout_ns: u64) !void {
62 // vkDeviceWaitIdle has no timeout parameter — we emulate by waiting on a
63 // newly-created fence submitted as a no-op, then waiting with our timeout.
64 // This is the minimum-cost approximation; for cases that need true idle,
65 // callers should use waitIdleForShutdown.
66 _ = timeout_ns;
67 _ = vkd;
68 _ = device;
69 @compileError("waitIdleBounded: not used in v1, left as a stub. Remove this compileError and implement the fence-based emulation if a caller appears.");
70 }
71
72 /// Unbounded device-idle wait, named to make shutdown-drain intent obvious
73 /// at the call site. Logs (but swallows) device-lost on shutdown since it is
74 /// unactionable.
75 pub fn waitIdleForShutdown(vkd: vk.DeviceWrapper, device: vk.Device) void {
76 vkd.deviceWaitIdle(device) catch |err| {
77 std.log.warn("waitIdleForShutdown: {s}", .{@errorName(err)});
78 };
79 }
80
81 /// Bounded queue-idle wait. Same shape as waitIdleBounded.
82 pub fn queueWaitIdleBounded(vkd: vk.DeviceWrapper, queue: vk.Queue, timeout_ns: u64) !void {
83 _ = queue;
84 _ = timeout_ns;
85 _ = vkd;
86 @compileError("queueWaitIdleBounded: not used in v1, left as a stub. Remove this compileError and implement if a caller appears.");
87 }
88
89 // --- logging ---
90
91 const TimeoutKind = enum { fence, acquire, atlas };
92
93 var vk_timeout_count: std.atomic.Value(u64) = .init(0);
94 var last_log_ns: std.atomic.Value(i64) = .init(0);
95
96 const log_window_ns: i64 = 5 * std.time.ns_per_s;
97
98 pub fn logVkTimeout(src: std.builtin.SourceLocation, kind: TimeoutKind) void {
99 const n = vk_timeout_count.fetchAdd(1, .monotonic) + 1;
100 const now: i64 = @truncate(std.time.nanoTimestamp());
101 const last = last_log_ns.load(.monotonic);
102 if (n == 1 or (now - last) > log_window_ns) {
103 last_log_ns.store(now, .monotonic);
104 std.log.warn(
105 "vk timeout #{} ({s}) at {s}:{d} — driver may be wedged",
106 .{ n, @tagName(kind), src.file, src.line },
107 );
108 }
109 }
110
111 // --- test helpers (internal; exposed only for inline tests) ---
112
113 fn resetLogStateForTesting() void {
114 vk_timeout_count.store(0, .monotonic);
115 last_log_ns.store(0, .monotonic);
116 }
117
118 // --- tests ---
119
120 test "constants have expected values" {
121 try std.testing.expectEqual(@as(u64, 2_000_000_000), fence_wait_timeout_ns);
122 try std.testing.expectEqual(@as(u64, 100_000_000), acquire_timeout_ns);
123 }
124
125 test "logVkTimeout rate-limits to one line per window" {
126 // We can't easily capture std.log.warn output, but we can verify the
127 // counter and last_log_ns state transitions match the rate-limit logic.
128 resetLogStateForTesting();
129
130 // First call always logs.
131 logVkTimeout(@src(), .fence);
132 try std.testing.expectEqual(@as(u64, 1), vk_timeout_count.load(.monotonic));
133 const t1: i64 = last_log_ns.load(.monotonic);
134 try std.testing.expect(t1 > 0);
135
136 // Immediate second call: counter increments, last_log_ns stays (within 5s window).
137 logVkTimeout(@src(), .fence);
138 try std.testing.expectEqual(@as(u64, 2), vk_timeout_count.load(.monotonic));
139 try std.testing.expectEqual(t1, last_log_ns.load(.monotonic));
140
141 // 100 more calls in tight loop: counter grows, last_log_ns still stays.
142 for (0..100) |_| logVkTimeout(@src(), .fence);
143 try std.testing.expectEqual(@as(u64, 102), vk_timeout_count.load(.monotonic));
144 try std.testing.expectEqual(t1, last_log_ns.load(.monotonic));
145 }
146
147 test "logVkTimeout re-fires after simulated window elapsed" {
148 resetLogStateForTesting();
149
150 logVkTimeout(@src(), .acquire);
151 const t1: i64 = last_log_ns.load(.monotonic);
152
153 // Simulate window expiry by rewinding last_log_ns past the 5s threshold.
154 last_log_ns.store(t1 - 6 * std.time.ns_per_s, .monotonic);
155
156 logVkTimeout(@src(), .acquire);
157 const t2: i64 = last_log_ns.load(.monotonic);
158
159 try std.testing.expect(t2 > t1 - 6 * std.time.ns_per_s);
160 try std.testing.expectEqual(@as(u64, 2), vk_timeout_count.load(.monotonic));
161 }