Skip to content

Handles.kt

src/jvmMain/kotlin/afield/audio/spatial/steamaudio/Handles.kt · 163 lines

package afield.audio.spatial.steamaudio

Lifetime management for Steam Audio native objects: SteamAudioObject wraps a reference-counted handle, releases it once on close, and keeps a cleaner as a backstop for objects that are dropped without closing.

import java.lang.foreign.Arena
import java.lang.foreign.MemorySegment
import java.lang.foreign.ValueLayout
import java.lang.ref.Cleaner
import java.util.concurrent.atomic.AtomicBoolean

public abstract class SteamAudioObject internal constructor

Base class for the reference-counted Steam Audio objects.

Every Steam Audio object follows the same C convention: iplXxxCreate writes a handle into an out-parameter, iplXxxRetain increments a reference count, and iplXxxRelease takes a pointer to the handle, decrements the count, and nulls the caller's copy.

Lifetime

Objects are AutoCloseable; close decrements the native reference count exactly once, however many times it is called. A Cleaner provides a safety net so that a dropped object does not leak native memory, but it is not a substitute for closing: cleaner timing is unspecified, and in a long-running audio application leaked impulse responses and scenes are large enough to matter. Use use { }.

Shared ownership

retain exposes the native reference count directly: it returns a new wrapper around the same native object, with its own close. Use it when an object genuinely outlives the scope that created it -- for instance a Scene referenced both by a Simulator and by the meshes inside it. Each wrapper must be closed exactly once.

public abstract class SteamAudioObject internal constructor(
    /** The raw handle: a pointer value, as returned by `iplXxxCreate`. */
    internal val handle: MemorySegment,
) : AutoCloseable {

    private val closed = AtomicBoolean(false)
    private val cleanable: Cleaner.Cleanable

init

    init {
        require(handle.address() != 0L) { "refusing to wrap a null Steam Audio handle" }
        // The cleanup action must not capture `this`, or the object is never collectable.
        // Capturing the handle and a function reference to the (stateless) release routine
        // keeps the action independent of the wrapper.
        val release = releaseAction()
        cleanable = CLEANER.register(this) { releaseHandle(handle, release) }
    }

internal abstract fun releaseAction(): (MemorySegment) -> Unit

Returns the iplXxxRelease invoker for this object type.

Called once during construction. Implementations must return a lambda that does not capture the wrapper object -- typically a direct reference to a generated binding.

internal open val typeName: String get() = this::class.simpleName ?: "SteamAudioObject"

A short human-readable name for this object type, used in messages.

public val isClosed: Boolean get() = closed.get()

True once close has run.

final override fun close()

Decrements the native reference count, releasing the object when the count reaches zero.

Idempotent: subsequent calls do nothing.

    final override fun close() {
        if (closed.compareAndSet(false, true)) {
            cleanable.clean()
            onClosed()
        }
    }

internal open fun onClosed() {}

Called exactly once, after the native object has been released.

Wrappers that own extra JVM-side resources -- an Arena holding upcall stubs, say -- free them here.

internal fun checkOpen()

Throws if this object has already been closed.

    internal fun checkOpen() {
        if (closed.get()) {
            throw IllegalStateException("$typeName has already been closed")
        }
    }

internal val liveHandle: MemorySegment

The handle, after verifying the object is still open.

Every wrapper method that calls into native code should go through this rather than touching handle directly, so that use-after-close produces a clear Kotlin exception instead of a native crash.

    internal val liveHandle: MemorySegment
        get() {
            checkOpen()
            return handle
        }

override fun toString(): String

    override fun toString(): String =
        "$typeName(0x${handle.address().toString(16)}${if (isClosed) ", closed" else ""})"

internal companion object

    internal companion object {
        private val CLEANER: Cleaner = Cleaner.create { runnable ->
            Thread(runnable, "steamaudio-cleaner").apply { isDaemon = true }
        }

Calls an iplXxxRelease function.

Release takes a pointer to the handle and writes null back through it, so it needs a writable slot. The slot is scratch -- the reference-count decrement lands on the object itself, not the slot -- so a short-lived confined arena is all that is needed.

        private fun releaseHandle(handle: MemorySegment, release: (MemorySegment) -> Unit) {
            Arena.ofConfined().use { arena ->
                val slot = arena.allocate(ValueLayout.ADDRESS)
                slot.set(ValueLayout.ADDRESS, 0L, handle)
                release(slot)
            }
        }
    }
}

internal inline fun createHandle(what: String, block: (MemorySegment) -> Int): MemorySegment {

Runs block with a freshly allocated handle-sized out-parameter slot, and returns the handle written into it.

Used to bridge the iplXxxCreate(..., IPLXxx* out) convention.

internal inline fun createHandle(what: String, block: (MemorySegment) -> Int): MemorySegment {
    Arena.ofConfined().use { arena ->
        val slot = arena.allocate(ValueLayout.ADDRESS)
        block(slot).checkSuccess(what)
        val handle = slot.get(ValueLayout.ADDRESS, 0L)
        if (handle.address() == 0L) {
            throw SteamAudioException(SteamAudioError.FAILURE, "$what returned success but a null handle")
        }
        return handle
    }
}

internal inline fun readHandle(block: (MemorySegment) -> Unit): MemorySegment {

Reads a handle out of a slot without checking a return code, for void-returning creators.

internal inline fun readHandle(block: (MemorySegment) -> Unit): MemorySegment {
    Arena.ofConfined().use { arena ->
        val slot = arena.allocate(ValueLayout.ADDRESS)
        block(slot)
        return slot.get(ValueLayout.ADDRESS, 0L)
    }
}

internal fun SteamAudioObject?.handleOrNull(): MemorySegment

The handle of this object, or MemorySegment.NULL when the reference is absent.

internal fun SteamAudioObject?.handleOrNull(): MemorySegment =
    this?.liveHandle ?: MemorySegment.NULL