LiveFrameBufferLifetimeSmoke.kt
src/jvmMain/kotlin/afield/examples/four/LiveFrameBufferLifetimeSmoke.kt · 148 lines
package afield.examples.four
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.buffers.VulkanBuffer
import afield.graphics.vulkan.util.Options
import afield.graphics.vulkan.util.Window
import org.lwjgl.vulkan.VK10.VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT
import org.lwjgl.vulkan.VK10.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
import org.lwjgl.vulkan.VK10.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT
import kotlin.concurrent.thread
import kotlin.system.exitProcess
FIELD_TEST id: four.live-frame-buffer-lifetime.smoke requiresDisplay: true runner: jvm-main entrypoint: afield.examples.four.LiveFrameBufferLifetimeSmoke tags: [smoke, four, gpu, lifecycle] timeoutSec: 180 expect: exitCode: 0 stdoutContains: - "live-frame buffer lifetime PASSED" stdoutNotContains: - "VUID-" - "Validation Error" stderrNotContains: - "VUID-" - "Validation Error"
object LiveFrameBufferLifetimeSmoke
The three lifetimes behind the live-frame crash in dev_docs/renderer_teardown_order.md
("Live-frame crash"), checked on a real device without depending on a driver to fault.
- While one thread
reallocs a uniform buffer as fast as it can, another, standing in for the render worker that recordsModule$Uniform.pushDescriptorSet, never reads a zero handle. Before the fixrealloccleared the handle and then allocated, and on MoltenVK a push in that window was a SIGSEGV inMVKBuffer::getMTLBuffer(3 of 3 crashed in a live Field). - A buffer released with
cleanup()stays valid until the frames in flight have retired (it used to be destroyed at once, under them), then refuses a descriptor binding by name. - Once
Renderer.cleanup()has begun,Queue.submitanddescriptorBinding()refuse by name.
fun main(args: Array<String>)
body — 103 lines, from line 45
@JvmStatic
fun main(args: Array<String>) {
Options.doVulkanSDK()
Options.enableVR = false
Options.validate = true
val window = Window("Live-frame buffer lifetime")
val renderer = Renderer(window)
try {
check(renderer.instance.validationActive) {
"This lifecycle regression requires the Vulkan validation layer and debug messenger"
}
val ubo = VulkanBuffer(
renderer, 128L, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
)
// 1. No zero handle is ever visible across a realloc.
val reallocs = 20_000
val done = java.util.concurrent.atomic.AtomicBoolean(false)
var failure: Throwable? = null
val writer = thread(name = "realloc-stress") {
try {
for (n in 0 until reallocs) ubo.realloc(128L + (n % 7) * 64L)
} catch (t: Throwable) {
failure = t
} finally {
done.set(true)
}
}
var samples = 0L
var zeroHandles = 0L
var zeroBindings = 0L
while (!done.get()) {
if (ubo.buffer == 0L) zeroHandles++
if (ubo.descriptorBinding().buffer == 0L) zeroBindings++
samples++
}
writer.join()
failure?.let { throw IllegalStateException("realloc thread threw", it) }
// One real frame, so the retirement queue sees the replaced allocations too.
window.pollEvents()
renderer.render()
println("sampled the handle $samples times across $reallocs reallocs: $zeroHandles zero handles, $zeroBindings zero bindings")
check(samples > 1_000) { "premise: the reader must actually have overlapped the reallocs (sampled $samples times)" }
check(zeroHandles == 0L && zeroBindings == 0L) {
"a recorder could read a VK_NULL_HANDLE mid-realloc ($zeroHandles handles, $zeroBindings bindings)"
}
// 2. cleanup() retires instead of destroying: the handle stays valid for the frames that
// may use it, then the buffer refuses by name.
val handleBefore = ubo.descriptorBinding().buffer
ubo.cleanup()
check(ubo.descriptorBinding().buffer == handleBefore) {
"cleanup() must not destroy (or unbind) a buffer a frame may still use"
}
var framesToRetire = 0
while (runCatching { ubo.descriptorBinding() }.isSuccess) {
window.pollEvents()
renderer.render()
framesToRetire++
check(framesToRetire < 200) { "cleanup() never retired the buffer" }
}
val released = runCatching { ubo.descriptorBinding() }.exceptionOrNull()
check(released is IllegalStateException && "destroyed" in released.message.orEmpty()) {
"descriptorBinding() after retirement must refuse by name, got $released"
}
check(framesToRetire > renderer.numImages) {
"cleanup() retired after $framesToRetire frame(s), not after the frames in flight"
}
println("released buffer stayed valid for $framesToRetire frame(s), then refuses: ${released.message}")
// 2b. The same for a module's pipeline is covered in a live Field (see the dev_docs page);
// here, a second cleanup() is harmless.
ubo.cleanup()
// 3. After renderer teardown begins, submits and bindings refuse by name.
val live = VulkanBuffer(
renderer, 64L, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
)
renderer.cleanup()
val submit = runCatching { renderer.graphQueue.submit(null, null, null, null, null) }.exceptionOrNull()
check(submit is IllegalStateException && "began disposing" in submit.message.orEmpty()) {
"Queue.submit after Renderer.cleanup() must refuse by name, got $submit"
}
println("submit after teardown refuses: ${submit.message}")
val bound = runCatching { live.descriptorBinding() }.exceptionOrNull()
check(bound is IllegalStateException && "disposed" in bound.message.orEmpty()) {
"descriptorBinding() after Renderer.cleanup() must refuse by name, got $bound"
}
println("binding after teardown refuses: ${bound.message}")
java.lang.ref.Reference.reachabilityFence(live)
} finally {
try {
renderer.cleanup()
} finally {
window.cleanup()
}
}
println("live-frame buffer lifetime PASSED")
exitProcess(0)
}
}