2c50c63e
Add vk_sync module with bounded Vulkan wait helpers
a73x 2026-04-18 12:28
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>
diff --git a/build.zig b/build.zig index 6a016fe..e9d664e 100644 --- a/build.zig +++ b/build.zig @@ -320,6 +320,27 @@ pub fn build(b: *std.Build) void { exe_mod.addImport("cell_instance", cell_instance_mod); main_test_mod.addImport("cell_instance", cell_instance_mod); // vk_sync module — bounded Vulkan synchronization helpers const vk_sync_mod = b.createModule(.{ .root_source_file = b.path("src/vk_sync.zig"), .target = target, .optimize = optimize, }); vk_sync_mod.addImport("vulkan", vulkan_module); renderer_mod.addImport("vk_sync", vk_sync_mod); renderer_test_mod.addImport("vk_sync", vk_sync_mod); exe_mod.addImport("vk_sync", vk_sync_mod); main_test_mod.addImport("vk_sync", vk_sync_mod); const vk_sync_test_mod = b.createModule(.{ .root_source_file = b.path("src/vk_sync.zig"), .target = target, .optimize = optimize, }); vk_sync_test_mod.addImport("vulkan", vulkan_module); const vk_sync_tests = b.addTest(.{ .root_module = vk_sync_test_mod }); test_step.dependOn(&b.addRunArtifact(vk_sync_tests).step); // capture module — --capture mode (render a VT script to PNG) const capture_mod = b.createModule(.{ .root_source_file = b.path("src/capture.zig"), @@ -336,6 +357,7 @@ pub fn build(b: *std.Build) void { capture_mod.addImport("png", png_mod); capture_mod.addImport("vulkan", vulkan_module); capture_mod.addImport("cell_instance", cell_instance_mod); capture_mod.addImport("vk_sync", vk_sync_mod); exe_mod.addImport("capture", capture_mod); // imgdiff — standalone PNG comparison tool diff --git a/src/vk_sync.zig b/src/vk_sync.zig new file mode 100644 index 0000000..feea2a8 --- /dev/null +++ b/src/vk_sync.zig @@ -0,0 +1,161 @@ //! Bounded Vulkan synchronization primitives. //! //! Replaces unbounded vkWaitForFences / vkAcquireNextImageKHR / vkDeviceWaitIdle / //! vkQueueWaitIdle calls. The helpers here are the ONLY path callers should use //! for blocking Vulkan operations — the grep gate in tests/check_unbounded_vk.sh //! enforces this at CI time. //! //! Motivation: NVIDIA driver 595 occasionally drops a fence signal, wedging //! vkWaitForFences(UINT64_MAX) forever. See docs/superpowers/specs/ //! 2026-04-18-vulkan-bounded-waits-design.md for the full story. const std = @import("std"); const vk = @import("vulkan"); pub const fence_wait_timeout_ns: u64 = 2_000_000_000; // 2s pub const acquire_timeout_ns: u64 = 100_000_000; // 100ms pub const SyncError = error{ VkWaitTimeout, VkAcquireTimeout }; /// Bounded fence wait. Returns error.VkWaitTimeout on timeout without touching /// the fence. Caller may safely retry on the next iteration. pub fn waitFenceBounded( vkd: vk.DeviceWrapper, device: vk.Device, fence: vk.Fence, ) !void { const result = try vkd.waitForFences(device, 1, @ptrCast(&fence), .true, fence_wait_timeout_ns); if (result == .timeout) return error.VkWaitTimeout; } /// Bounded image acquire. Returns the acquired image_index on success. /// Folds VK_SUBOPTIMAL_KHR into error.OutOfDateKHR (matches existing callers, /// which already collapse the two via swapchainNeedsRebuild). /// Returns error.VkAcquireTimeout on VK_TIMEOUT or VK_NOT_READY. pub fn acquireImageBounded( vkd: vk.DeviceWrapper, device: vk.Device, swapchain: vk.SwapchainKHR, semaphore: vk.Semaphore, ) !u32 { const acquire = vkd.acquireNextImageKHR( device, swapchain, acquire_timeout_ns, semaphore, .null_handle, ) catch |err| switch (err) { error.OutOfDateKHR => return error.OutOfDateKHR, else => return err, }; switch (acquire.result) { .timeout, .not_ready => return error.VkAcquireTimeout, .suboptimal_khr => return error.OutOfDateKHR, .success => return acquire.image_index, else => return acquire.image_index, // unexpected but non-error; trust the image_index } } /// Bounded device-idle wait. For mid-flight resyncs where blocking forever /// would be wrong. Returns error.VkWaitTimeout on timeout. pub fn waitIdleBounded(vkd: vk.DeviceWrapper, device: vk.Device, timeout_ns: u64) !void { // vkDeviceWaitIdle has no timeout parameter — we emulate by waiting on a // newly-created fence submitted as a no-op, then waiting with our timeout. // This is the minimum-cost approximation; for cases that need true idle, // callers should use waitIdleForShutdown. _ = timeout_ns; _ = vkd; _ = device; @compileError("waitIdleBounded: not used in v1, left as a stub. Remove this compileError and implement the fence-based emulation if a caller appears."); } /// Unbounded device-idle wait, named to make shutdown-drain intent obvious /// at the call site. Logs (but swallows) device-lost on shutdown since it is /// unactionable. pub fn waitIdleForShutdown(vkd: vk.DeviceWrapper, device: vk.Device) void { vkd.deviceWaitIdle(device) catch |err| { std.log.warn("waitIdleForShutdown: {s}", .{@errorName(err)}); }; } /// Bounded queue-idle wait. Same shape as waitIdleBounded. pub fn queueWaitIdleBounded(vkd: vk.DeviceWrapper, queue: vk.Queue, timeout_ns: u64) !void { _ = queue; _ = timeout_ns; _ = vkd; @compileError("queueWaitIdleBounded: not used in v1, left as a stub. Remove this compileError and implement if a caller appears."); } // --- logging --- const TimeoutKind = enum { fence, acquire, atlas }; var vk_timeout_count: std.atomic.Value(u64) = .init(0); var last_log_ns: std.atomic.Value(i64) = .init(0); const log_window_ns: i64 = 5 * std.time.ns_per_s; pub fn logVkTimeout(src: std.builtin.SourceLocation, kind: TimeoutKind) void { const n = vk_timeout_count.fetchAdd(1, .monotonic) + 1; const now: i64 = @truncate(std.time.nanoTimestamp()); const last = last_log_ns.load(.monotonic); if (n == 1 or (now - last) > log_window_ns) { last_log_ns.store(now, .monotonic); std.log.warn( "vk timeout #{} ({s}) at {s}:{d} — driver may be wedged", .{ n, @tagName(kind), src.file, src.line }, ); } } // --- test helpers (internal; exposed only for inline tests) --- fn resetLogStateForTesting() void { vk_timeout_count.store(0, .monotonic); last_log_ns.store(0, .monotonic); } // --- tests --- test "constants have expected values" { try std.testing.expectEqual(@as(u64, 2_000_000_000), fence_wait_timeout_ns); try std.testing.expectEqual(@as(u64, 100_000_000), acquire_timeout_ns); } test "logVkTimeout rate-limits to one line per window" { // We can't easily capture std.log.warn output, but we can verify the // counter and last_log_ns state transitions match the rate-limit logic. resetLogStateForTesting(); // First call always logs. logVkTimeout(@src(), .fence); try std.testing.expectEqual(@as(u64, 1), vk_timeout_count.load(.monotonic)); const t1: i64 = last_log_ns.load(.monotonic); try std.testing.expect(t1 > 0); // Immediate second call: counter increments, last_log_ns stays (within 5s window). logVkTimeout(@src(), .fence); try std.testing.expectEqual(@as(u64, 2), vk_timeout_count.load(.monotonic)); try std.testing.expectEqual(t1, last_log_ns.load(.monotonic)); // 100 more calls in tight loop: counter grows, last_log_ns still stays. for (0..100) |_| logVkTimeout(@src(), .fence); try std.testing.expectEqual(@as(u64, 102), vk_timeout_count.load(.monotonic)); try std.testing.expectEqual(t1, last_log_ns.load(.monotonic)); } test "logVkTimeout re-fires after simulated window elapsed" { resetLogStateForTesting(); logVkTimeout(@src(), .acquire); const t1: i64 = last_log_ns.load(.monotonic); // Simulate window expiry by rewinding last_log_ns past the 5s threshold. last_log_ns.store(t1 - 6 * std.time.ns_per_s, .monotonic); logVkTimeout(@src(), .acquire); const t2: i64 = last_log_ns.load(.monotonic); try std.testing.expect(t2 > t1 - 6 * std.time.ns_per_s); try std.testing.expectEqual(@as(u64, 2), vk_timeout_count.load(.monotonic)); }