Skip to content

AsyncOperation.kt

src/jvmMain/kotlin/afield/runtime/lifecycle/AsyncOperation.kt · 201 lines

@file:afield.runtime.completion.CompletionCategory("Execution")

package afield.runtime.lifecycle

import afield.document.graph.Box
import java.util.concurrent.CancellationException
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.FutureTask

A future-backed operation whose callbacks stop firing once the box run that started it ends.

AsyncOperation wraps a java.util.concurrent.CompletableFuture so that progress, result and error callbacks run in the starting box's context and are suppressed after that run is stopped, re-run or retired. Closing it cancels the source future and runs any registered cancellation hooks.

class AsyncOperation<T> internal constructor

One asynchronous operation owned by the run that started it. progress and the result/error callbacks run only while that run is alive, in its box context, on the completing thread. Keep these callbacks short and non-blocking. Shared clients and cached results are not owned here.

close suppresses further publication, cancels the source future and invokes onCancel hooks. Cancelling a future does not in general stop its worker: register an explicit hook if the underlying API supports cancellation. own releases operation-local resources on every ending.

class AsyncOperation<T> internal constructor(
    private val scope: RunScope?,
    private val onResult: (T) -> Unit,
    private val onError: (Throwable) -> Unit,
) : AutoCloseable {

val future = CompletableFuture<T>()

The result, cancelled when its run ends. Cancelling it also releases this operation.

    private val lock = Any()
    private var active = true
    private var abandoned = false
    private var source: CompletableFuture<T>? = null
    private val resources = mutableListOf<AutoCloseable>()
    private val cancellations = mutableListOf<() -> Unit>()

init

    init {
        future.whenComplete { _, _ -> if (future.isCancelled) close() }
    }

fun <P> progress(callback: (P) -> Unit): (P) -> Unit = { value ->

Guard a progress callback with this operation's lifetime and originating box context.

    fun <P> progress(callback: (P) -> Unit): (P) -> Unit = { value ->
        try {
            synchronized(lock) { if (isCurrent()) inContext { callback(value) } }
        } catch (failure: Throwable) {
            fail(failure)
        }
    }

fun <R : AutoCloseable> own(resource: R): R

Release an operation-local resource on completion, failure or cancellation; never own a shared client.

    fun <R : AutoCloseable> own(resource: R): R {
        synchronized(lock) {
            if (active) resources += resource else release(resource)
        }
        return resource
    }

fun onCancel(cancel: () -> Unit) {

Request actual worker cancellation on abandonment, separately from cancelling its future.

    fun onCancel(cancel: () -> Unit) {
        synchronized(lock) {
            if (active) cancellations += cancel else if (abandoned) runCatching(cancel)
        }
    }

fun fail(failure: Throwable) = finish(null, unwrapAsyncFailure(failure))

Fail this operation, including failures from separately arriving progress transports.

internal fun start(block: AsyncOperation<T>.() -> CompletableFuture<T>) {

body — 52 lines, from line 75
    internal fun start(block: AsyncOperation<T>.() -> CompletableFuture<T>) {
        synchronized(lock) {
            if (!isCurrent()) { close(); return }
            try {
                val pending = inContext { block() }
                source = pending
                if (!active) pending.cancel(true)
                else pending.whenComplete { value, failure -> finish(value, failure?.let(::unwrapAsyncFailure)) }
            } catch (failure: Throwable) {
                fail(failure)
            }
        }
    }

    private fun isCurrent(): Boolean = active && scope?.let { !it.isClosed && !it.isStopped } != false

    private fun <R> inContext(block: () -> R): R =
        if (scope == null) block()
        else Box.withBoxContextNow(scope.box) { RunScope.withCurrent(scope, block) }

    private fun finish(value: T?, failure: Throwable?) {
        synchronized(lock) {
            if (!active) return
            if (!isCurrent() || failure is CancellationException) { close(); return }
            var error = failure
            try {
                inContext {
                    if (error == null) {
                        @Suppress("UNCHECKED_CAST")
                        onResult(value as T)
                    } else onError(error!!)
                }
            } catch (callbackFailure: Throwable) {
                error = callbackFailure
                if (failure == null) runCatching { inContext { onError(callbackFailure) } }
            }
            if (!active) return
            active = false
            if (error != null) {
                abandoned = true
                cancellations.asReversed().forEach { runCatching(it) }
                source?.cancel(true)
            }
            cancellations.clear()
            releaseResources()
            scope?.disown(this)
            if (error == null) {
                @Suppress("UNCHECKED_CAST")
                future.complete(value as T)
            } else future.completeExceptionally(error)
        }
    }

override fun close()

Abandon this operation without closing shared clients or removing cached service results.

    override fun close() {
        synchronized(lock) {
            if (!active) return
            active = false
            abandoned = true
            cancellations.asReversed().forEach { runCatching(it) }
            cancellations.clear()
            source?.cancel(true)
            releaseResources()
            scope?.disown(this)
            future.cancel(false)
        }
    }

    private fun releaseResources() {
        resources.asReversed().forEach(::release)
        resources.clear()
    }

    private fun release(resource: AutoCloseable) {
        runCatching { resource.close() }.onFailure { failure ->
            scope?.box?.displayError("Async resource cleanup failed: ${failure.message}")
                ?: System.err.println("Async resource cleanup failed: $failure")
        }
    }
}

fun <T> runAsync

Start background work with run-owned publication and cleanup. Capture progress before starting:

runAsync(onResult = { result: LorisResult -> display("audio", result.samples) }) {
    client.analyzeWavAsync(path, onProgress = progress { text: String -> println(text) })
}
The start block must return promptly with a future. Use AsyncOperation.onCancel for an API's supported worker cancellation and AsyncOperation.own for resources local to this request.

fun <T> runAsync(
    onResult: (T) -> Unit = {},
    onError: ((Throwable) -> Unit)? = null,
    start: AsyncOperation<T>.() -> CompletableFuture<T>,
): AsyncOperation<T> {
    val scope = RunScope.current()
    val operation = AsyncOperation(scope, onResult, onError ?: { failure ->
        scope?.box?.displayError("Async operation failed: ${failure.message ?: failure.javaClass.simpleName}")
            ?: System.err.println("Async operation failed: $failure")
    })
    scope?.own(operation)
    operation.start(start)
    return operation
}

internal fun unwrapAsyncFailure(failure: Throwable): Throwable

internal fun unwrapAsyncFailure(failure: Throwable): Throwable =
    if ((failure is CompletionException || failure is ExecutionException) && failure.cause != null)
        unwrapAsyncFailure(failure.cause!!) else failure

private val asyncWorkers = Executors.newCachedThreadPool { task ->
    Thread(task, "Field-async-worker").apply { isDaemon = true }
}

internal fun <T> interruptibleFuture(block: () -> T): CompletableFuture<T> {

Interruptible worker bridge for blocking native clients that clean up their process in finally.

internal fun <T> interruptibleFuture(block: () -> T): CompletableFuture<T> {
    val result = CompletableFuture<T>()
    val task = FutureTask {
        try { result.complete(block()) }
        catch (failure: Throwable) { result.completeExceptionally(failure) }
    }
    result.whenComplete { _, _ -> if (result.isCancelled) task.cancel(true) }
    asyncWorkers.execute(task)
    return result
}