src/vk_sync.zig
Ref: Size: 6.5 KiB
//! 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 => {
std.log.warn("acquireImageBounded: unexpected VkResult {s}", .{@tagName(acquire.result)});
return error.VkAcquireTimeout;
},
}
}
/// 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, // waitFenceBounded timeouts
acquire, // acquireImageBounded timeouts
atlas, // uploadAtlasRegion fence timeouts (used by renderer.zig, Task 3)
};
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) {
// Benign TOCTOU: two threads racing can fire twice in the same window.
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));
}