a73x

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));
}