Skip to content

WindowCaptureQueue.kt

src/jvmMain/kotlin/afield/graphics/vulkan/WindowCaptureQueue.kt · 86 lines

package afield.graphics.vulkan

import afield.graphics.scene.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout

internal class NativeWindowCaptureStalledException(message: String) : IllegalStateException(message)

internal class NativeWindowCaptureStalledException(message: String) : IllegalStateException(message)

internal class WindowCaptureRequest

internal class WindowCaptureRequest(
    val windowIndex: Int,
    val outputPath: String,
    val requestedAtNs: Long,
    val frameAtRequest: Long,
    val completion: CompletableDeferred<String> = CompletableDeferred()
)

internal class WindowCaptureQueue

Capture queue and per-window progress, independent of Vulkan so cancellation is testable.

internal class WindowCaptureQueue {
    private val lock = Any()
    private val pending = mutableMapOf<Int, ArrayDeque<WindowCaptureRequest>>()
    private val frames = mutableMapOf<Int, Long>()

fun enqueue(windowIndex: Int, outputPath: String, requestedAtNs: Long): WindowCaptureRequest

    fun enqueue(windowIndex: Int, outputPath: String, requestedAtNs: Long): WindowCaptureRequest =
        synchronized(lock) {
            WindowCaptureRequest(windowIndex, outputPath, requestedAtNs, frames[windowIndex] ?: 0L).also {
                pending.getOrPut(windowIndex) { ArrayDeque() }.addLast(it)
            }
        }

fun pendingCount(windowIndex: Int): Int = synchronized(lock) { pending[windowIndex]?.size ?: 0 }

fun takeForFrame(windowIndex: Int): List<WindowCaptureRequest>

Called unconditionally at this window's capture point, even when its queue is empty.

    fun takeForFrame(windowIndex: Int): List<WindowCaptureRequest> = synchronized(lock) {
        frames[windowIndex] = (frames[windowIndex] ?: 0L) + 1L
        pending.remove(windowIndex)?.filter { it.completion.isActive } ?: emptyList()
    }

suspend fun await

body — 44 lines, from line 42
    suspend fun await(
        request: WindowCaptureRequest,
        timeoutMs: Long,
        noFrameTimeoutMs: Long = 5_000L
    ): String {
        try {
            require(timeoutMs > 0 && noFrameTimeoutMs > 0) { "Capture budgets must be positive" }
            return withTimeout(timeoutMs) {
                coroutineScope {
                    // Do not require the render/runloop dispatcher to execute its own watchdog.
                    val watchdog = launch(Dispatchers.Default) {
                        delay(noFrameTimeoutMs)
                        synchronized(lock) {
                            val current = frames[request.windowIndex] ?: 0L
                            if (pending[request.windowIndex]?.contains(request) == true &&
                                current == request.frameAtRequest
                            ) {
                                request.completion.completeExceptionally(NativeWindowCaptureStalledException(
                                    "Native window capture stalled: windowIndex=${request.windowIndex} " +
                                        "frame=$current unchanged for ${noFrameTimeoutMs}ms; " +
                                        "the renderer did not reach this window's capture point"
                                ))
                            }
                        }
                    }
                    try {
                        request.completion.await()
                    } finally {
                        watchdog.cancel()
                    }
                }
            }
        } finally {
            // A timed-out caller must not leave work behind for a future renderer frame.
            synchronized(lock) {
                pending[request.windowIndex]?.let { queue ->
                    queue.remove(request)
                    if (queue.isEmpty()) pending.remove(request.windowIndex)
                }
                request.completion.cancel()
            }
        }
    }
}