Skip to content

M4.06

Pre-release
Pre-release

Choose a tag to compare

@VTSTech VTSTech released this 21 Oct 18:59
· 12 commits to main since this release

[M4.06] — 2026-10-21

The robustness wave: the three Medium findings from the codebase audit land as
code. The frame path is split into record / submit / wait so a host can hold
a frame in flight, several frames can be pipelined at once, and a lost Vulkan
device can be rebuilt — together with the presets and chains that lived on it
— instead of ending the process.

Added

  • Async render path (Chain::render_async + Chain::wait_async) — the
    frame path is split into record, submit and wait, so a host can keep a
    frame in flight while it does its own CPU work.

    • render_async(input, target, frame) records and submits the frame, then
      returns a fence. It does not block on the GPU.
    • wait_async(fence) blocks until the frame retires, then releases the
      frame's fence and command buffer. The host must call it before the
      frame's output is consumed.
    • Chain::render() is now render_async() + wait_async() — one
      recording implementation serves both entry points, so existing callers
      are unaffected.
    auto fence = chain.render_async(input, target, frame);
    // ... the host's own work ...
    chain.wait_async(*fence);   // before the output is presented or read back

    At most one async frame may be in flight per chain: the frame's texture
    bindings are written in place into the chain's shared descriptor sets, so a
    second frame cannot be recorded while the first is still executing.
    render_async() drains the previous frame first. (ROB-34, below, lifts this
    limit.)

  • Multi-frame pipelining (ChainOptions::framesInFlight, up to 8) — a chain
    configured for N frames in flight keeps N frames outstanding on the GPU at
    once, so the GPU is not idle while the host prepares the next frame.

    • The chain's mutable per-frame state (the uniform buffers, the three
      descriptor sets per pass, and the present draw's descriptor set) moved into
      a per-frame slot, so concurrent frames never overwrite each other's
      parameters or texture bindings.
    • The output buffer a frame writes and the history ring slot it fills are
      derived from a logical frame index rather than a toggled write index, which
      keeps the OriginalHistoryN and feedback semantics correct when frames
      overlap. The history ring grows by one slot per in-flight frame so an
      in-flight frame cannot overwrite the slot another in-flight frame reads.
    • The in-flight frame's fence and command buffer live in the slot that owns
      it; a slot is reused only after its previous occupant retired, which is
      where the backpressure lives.
    • Host contract: a frame borrows its input image and its output target
      for the frame's duration, so frames that overlap in time need one input
      image and one output target each. With framesInFlight == 1 one of each is
      enough, as before.
    • C API: slangvk_chain_options + slangvk_chain_create_ex() expose
      frames_in_flight; slangvk_chain_create() is unchanged.
  • Device-lost recovery (Device::recover, Device::generation) — a lost
    Vulkan device can be rebuilt. recover() recreates the VkDevice, its
    command pool and the library's memory allocator from the same instance and
    physical device; generation() returns a counter that is bumped by every
    successful recovery, so objects can tell that the device they were built on
    is gone. An adopted device (Device::adopt) stays the host's to recreate
    and is reported as unsupported_feature.

    • Preset::rebuildLuts(Device&) re-uploads the preset's LUT textures on
      the new device. It is a const operation — the preset's pass data and
      parameters survive a device loss and are not reloaded.
    • Chain::rebuild() recreates the chain's pipelines, render passes,
      framebuffers, descriptor sets, UBOs and pass targets from its preset.
    • Chain::render() detects a device loss, rebuilds the device, the
      preset's textures and the chain, and then reports vk_device_lost to the
      host without retrying the frame: the host's own images died with the
      old device and the library cannot recreate them. The chain is left
      rebuilt; the host recreates its images and renders again.
    • The device keeps a registry of the objects created on it (chains,
      presets, images) and releases them before swapping the device, so
      they are destroyed while they still belong to a live device. Objects
      remember the generation they were created on and forget their handles
      rather than destroying them through the new device.

    Recovery is best-effort: a driver that lost the device may also refuse to
    hand out a new one.

  • Precise device-loss detection (vk::VkErrorCode) — a
    VK_ERROR_DEVICE_LOST result now surfaces as Errc::vk_device_lost at
    every call site, instead of the previous approach of sniffing the error
    message for the string "VK_ERROR_DEVICE_LOST" (which missed device losses
    surfacing as a generic VkResult). Errc::device_lost_recovery_failed was
    added for a failed recovery attempt.

  • C API: slangvk_device_recover() mirrors Device::recover(), and
    SLANGVK_ERR_DEVICE_LOST_RECOVERY_FAILED keeps slangvk_result aligned
    with slv::Errc (the C enum mirrors it code-for-code).

  • CI: skip the build jobs for documentation-only commits — the workflow
    diffs the commit range (three-dot, so a stale before still yields the
    real change set) and skips the heavy jobs when every changed file is
    *.md/*.txt/*.rst. No third-party action is involved; the range comes
    from the event payload and the checkout is a full clone so the diff has
    the history it needs.

  • CI: self-containment check (tools/check_self_contained.sh) — every
    public header and every test translation unit is compiled on its own, with
    only the includes it declares. A translation unit that leans on another
    header pulling in <optional> (or <string>, ...) can build fine locally
    and fail on CI; the check turns that into a failure in seconds instead of
    at the end of a long build.

Changed

  • SubmitAndWait() frees its own command buffer instead of calling
    vkResetCommandPool: a pool-wide reset would also free command buffers
    that are still in flight on an async submission.
  • The feedback double-buffer flip moved from "after the frame completed" to
    "after the frame was recorded", so the recorded commands and the next
    frame's descriptor writes agree on which buffer holds frame T-N.
  • Chain::create documents that the preset it borrows must outlive the
    chain and must not be moved while the chain exists.
  • Version bumped to 0.4.06 (major 0, minor 4, patch 6).

Fixed

  • ROB-33 — synchronous render contract limitation: closed by the async
    render path.
  • ROB-34 — no async execution or pipelining support: closed by
    ChainOptions::framesInFlight and the per-frame resource slots.
  • ROB-35 — limited error recovery for device-lost scenarios: closed by
    Device::recover() and the automatic rebuild.
  • The descriptor pool's set budget — the per-frame resources changed what
    a chain allocates (three sets per pass per frame slot, plus the empty and
    present sets), and the pool's maxSets was one short of that. The present
    set is allocated last, so the failure surfaced as "present descriptor set
    allocation failed" on drivers that enforce maxSets strictly. lavapipe
    hands out more sets than maxSets, so the local and CI suites did not
    notice. The chain now also counts the sets it allocates and refuses to
    exceed the pool's budget, so a mis-sizing fails the same way on every
    driver.

Validation

  • Downstream hosts — the M4.06 build was exercised against two downstream
    consumers (Doom and Diablo): both load presets and render with the shaders
    working again. The descriptor pool budget fix below was found through the
    first of those runs.

Tests

  • tests/test_runner_async.cpp — the async path: byte-identical output
    against the synchronous render, four back-to-back async frames, and
    wait_async()'s error contract.
  • tests/test_runner_pipeline.cpp — the pipelining path: several frames
    outstanding on the GPU at once, overlapped frames producing byte-identical
    output to a serial render, and the history and feedback presets producing
    the same pixels pipelined as they do serially. It also builds a two-pass
    chain with 1, 3 and 8 frame slots, which is the case the descriptor pool
    budget got wrong.
  • tests/test_runner_recovery.cpp — the recovery path: device replacement,
    the chain rebuilding itself, byte-identical output afterwards, further
    recoveries, and the release of stale objects.

Audited

  • The two findings are removed from audit.md and recorded in
    deltas.md with what actually shipped.

Full Changelog: M4.05...M4.06