Repository navigation
M4.06
Pre-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 nowrender_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 theOriginalHistoryNand 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. WithframesInFlight == 1one of each is
enough, as before. - C API:
slangvk_chain_options+slangvk_chain_create_ex()expose
frames_in_flight;slangvk_chain_create()is unchanged.
- The chain's mutable per-frame state (the uniform buffers, the three
-
Device-lost recovery (
Device::recover,Device::generation) — a lost
Vulkan device can be rebuilt.recover()recreates theVkDevice, 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 asunsupported_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 reportsvk_device_lostto 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_LOSTresult now surfaces asErrc::vk_device_lostat
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 genericVkResult).Errc::device_lost_recovery_failedwas
added for a failed recovery attempt. -
C API:
slangvk_device_recover()mirrorsDevice::recover(), and
SLANGVK_ERR_DEVICE_LOST_RECOVERY_FAILEDkeepsslangvk_resultaligned
withslv::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 stalebeforestill 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::createdocuments 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::framesInFlightand 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'smaxSetswas one short of that. The present
set is allocated last, so the failure surfaced as "present descriptor set
allocation failed" on drivers that enforcemaxSetsstrictly. lavapipe
hands out more sets thanmaxSets, 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
Full Changelog: M4.05...M4.06