Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AutoClose module #3356

Merged
merged 12 commits into from
Jan 30, 2024
28 changes: 28 additions & 0 deletions arrow-libs/core/arrow-autoclose/api/arrow-autoclose.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
public abstract interface class arrow/AutoCloseScope {
public abstract fun autoClose (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;)Ljava/lang/Object;
public abstract fun install (Ljava/lang/AutoCloseable;)Ljava/lang/AutoCloseable;
}

public final class arrow/AutoCloseScope$DefaultImpls {
public static fun install (Larrow/AutoCloseScope;Ljava/lang/AutoCloseable;)Ljava/lang/AutoCloseable;
}

public final class arrow/AutoCloseScopeKt {
public static final fun autoCloseScope (Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
}

public final class arrow/AutoCloseableExtensionsKt {
public static final fun install (Larrow/AutoCloseScope;Ljava/lang/AutoCloseable;)Ljava/lang/AutoCloseable;
}

public final class arrow/DefaultAutoCloseScope : arrow/AutoCloseScope {
public fun <init> ()V
public fun autoClose (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;)Ljava/lang/Object;
public final fun close (Ljava/lang/Throwable;)Ljava/lang/Void;
public fun install (Ljava/lang/AutoCloseable;)Ljava/lang/AutoCloseable;
}

public final class arrow/ThrowIfFatalKt {
public static final fun throwIfFatal (Ljava/lang/Throwable;)Ljava/lang/Throwable;
}

61 changes: 61 additions & 0 deletions arrow-libs/core/arrow-autoclose/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@file:Suppress("DSL_SCOPE_VIOLATION")

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile


plugins {
id(libs.plugins.kotlin.multiplatform.get().pluginId)
alias(libs.plugins.arrowGradleConfig.kotlin)
alias(libs.plugins.arrowGradleConfig.publish)
alias(libs.plugins.spotless)
}

spotless {
kotlin {
ktlint().editorConfigOverride(mapOf("ktlint_standard_filename" to "disabled"))
}
}

apply(from = property("ANIMALSNIFFER_MPP"))

kotlin {
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlin.stdlibCommon)
implementation(projects.arrowAtomic)
}
}
commonTest {
dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotest.assertionsCore)
implementation(libs.coroutines.test)
}
}
jvmMain {
dependencies {
implementation(libs.kotlin.stdlib)
}
}

jsMain {
dependencies {
implementation(libs.kotlin.stdlibJS)
}
}
}

jvm {
tasks.jvmJar {
manifest {
attributes["Automatic-Module-Name"] = "arrow.autocloseable"
}
}
}
}

// enables context receivers for Jvm Tests
tasks.named<KotlinCompile>("compileTestKotlinJvm") {
kotlinOptions.freeCompilerArgs += "-Xcontext-receivers"
}
2 changes: 2 additions & 0 deletions arrow-libs/core/arrow-autoclose/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Maven publishing configuration
pom.name=Arrow AutoCloseable
18 changes: 18 additions & 0 deletions arrow-libs/core/arrow-autoclose/knit.code.include
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// This file was automatically generated from ${file.name} by Knit tool. Do not edit.
@file:OptIn(ExperimentalStdlibApi::class)
package ${knit.package}.${knit.name}

import arrow.AutoCloseScope
import arrow.autoCloseScope
import arrow.install

public class Scanner(
private val path: String,
) : AutoCloseable, Iterable<String> by listOf("Hello", "World", "!") {
override fun close(): Unit = Unit
}

public class Printer(private val path: String) : AutoCloseable {
public fun print(line: String): Unit = Unit
override fun close(): Unit = Unit
}
7 changes: 7 additions & 0 deletions arrow-libs/core/arrow-autoclose/knit.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
knit.package=arrow.autocloseable.examples
knit.dir=src/jvmTest/kotlin/examples/

test.package=arrow.autocloseable.examples.test
test.dir=src/jvmTest/kotlin/examples/autogenerated/

knit.include=knit.code.include
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package arrow

import arrow.atomic.Atomic
import arrow.atomic.update
import kotlin.coroutines.cancellation.CancellationException

/**
* The [AutoCloseScope] DSL allows for elegantly working with close-ables,
* without having to worry about intermediate errors, composing them,
* or keeping track of different close-ables and when they need to be closed.
*
* Often when working with multiple [AutoCloseable],
* `Closeable` from Java we need to use multiple nested [use] blocks
* and this can become tedious. An example below for copying files in Java:
* <!--- INCLUDE
* public fun main() {
* -->
* <!--- SUFFIX
* }
* -->
* ```kotlin
* Scanner("testRead.txt")
* .use { scanner ->
* Printer("testWrite.txt")
* .use { printer ->
* for(line in scanner) {
* printer.print(line)
* }
* }
* }
* ```
* We can also write this code as follows:
* ```kotlin
* autoCloseScope {
* val scanner = install(Scanner("testRead.txt"))
* val printer = install(Printer("testWrite.txt"))
* for(line in scanner) {
* printer.print(line)
* }
* }
* ```
* <!--- KNIT example-autocloseable-01.kt -->
* In the snippet above, `Scanner`, and `File` just like the nested [use] blocks
*
* This also works with `suspend`, since [autoCloseScope] is `inline` however if
* you need to take into _inspect_ cancellation then you need to use `ResourceScope` from Arrow Fx Coroutines.
* So both [AutoCloseScope], and `ResourceScope` behave correctly when encountering cancellation, by closing the source,
* but `ResourceScope` allows inspecting _complete_, _failure_, **and** _cancellation_ in the finalizer.
*
* This DSL works very well with Kotlin's experimental feature context receivers, soon called context parameters.
* We can write the same code from above as a function:
* ```kotlin
* context(AutoCloseScope)
* fun copyFiles(input: String, output: String) {
* val scanner = install(Scanner(input))
* val printer = install(Printer(output))
* for(line in scanner) {
* printer.print(line)
* }
* }
* ```
* <!--- KNIT example-autocloseable-02.kt -->
*/
public inline fun <A> autoCloseScope(block: AutoCloseScope.() -> A): A {
val scope = DefaultAutoCloseScope()
return try {
block(scope)
.also { scope.close(null) }
} catch (e: CancellationException) {
scope.close(e) ?: throw e
} catch (e: Throwable) {
scope.close(e.throwIfFatal()) ?: throw e
}
}

public interface AutoCloseScope {
public fun <A> autoClose(
acquire: () -> A,
release: (A, Throwable?) -> Unit
): A

@ExperimentalStdlibApi
public fun <A : AutoCloseable> install(autoCloseable: A): A =
autoClose({ autoCloseable }) { a, _ -> a.close() }
}

@PublishedApi
internal class DefaultAutoCloseScope : AutoCloseScope {
private val finalizers = Atomic(emptyList<(Throwable?) -> Unit>())

override fun <A> autoClose(acquire: () -> A, release: (A, Throwable?) -> Unit): A =
try {
acquire().also { a ->
finalizers.update { it + { e -> release(a, e) } }
}
} catch (e: Throwable) {
throw e
}

fun close(error: Throwable?): Nothing? {
return finalizers.get().fold(error) { acc, function ->
acc.add(runCatching { function.invoke(error) }.exceptionOrNull())
}?.let { throw it }
}

private fun Throwable?.add(other: Throwable?): Throwable? =
this?.apply {
other?.let { addSuppressed(it) }
} ?: other
}

@PublishedApi
internal expect fun Throwable.throwIfFatal(): Throwable
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package arrow

import arrow.atomic.AtomicBoolean
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.Test

@OptIn(ExperimentalStdlibApi::class)
class AutoCloseTest {

@Test
fun canInstallResource() = runTest {
val promise = CompletableDeferred<Throwable?>()
val wasActive = CompletableDeferred<Boolean>()
val res = Resource()

autoCloseScope {
val r = autoClose({ res }) { r, e ->
promise.complete(e)
r.shutdown()
}
wasActive.complete(r.isActive())
}

promise.await() shouldBe null
wasActive.await() shouldBe true
res.isActive() shouldBe false
}

@Test
fun canHandleWithFailingAutoClose() = runTest {
val promise = CompletableDeferred<Throwable?>()
val wasActive = CompletableDeferred<Boolean>()
val error = RuntimeException("BOOM!")
val res = Resource()

shouldThrow<RuntimeException> {
autoCloseScope {
val r = autoClose({ res }) { r, e ->
promise.complete(e)
r.shutdown()
}
wasActive.complete(r.isActive())
throw error
}
} shouldBe error

promise.await() shouldBe error
wasActive.await() shouldBe true
res.isActive() shouldBe false
}

@Test
fun addsSuppressedErrors() = runTest {
val promise = CompletableDeferred<Throwable?>()
val wasActive = CompletableDeferred<Boolean>()
val error = RuntimeException("BOOM!")
val error2 = RuntimeException("BOOM 2!")
val error3 = RuntimeException("BOOM 3!")
val res = Resource()

val e = shouldThrow<RuntimeException> {
autoCloseScope {
val r = autoClose({ res }) { r, e ->
promise.complete(e)
r.shutdown()
throw error2
}
autoClose({ Resource() }) { _, _ -> throw error3 }
wasActive.complete(r.isActive())
throw error
}
}

e shouldBe error
e.suppressedExceptions shouldBe listOf(error2, error3)
promise.await() shouldBe error
wasActive.await() shouldBe true
res.isActive() shouldBe false
}

@Test
fun handlesAcquireFailure() = runTest {
val promise = CompletableDeferred<Throwable?>()
val error = RuntimeException("BOOM!")
val error2 = RuntimeException("BOOM 2!")

val e = shouldThrow<RuntimeException> {
autoCloseScope {
autoClose({ Resource() }) { r, e ->
promise.complete(e)
r.shutdown()
throw error2
}
autoClose<Int>({ throw error }) { _, _ -> }
}
}
e shouldBe error
e.suppressedExceptions shouldBe listOf(error2)
promise.await() shouldBe error
}

@Test
fun canInstallAutoCloseable() = runTest {
val wasActive = CompletableDeferred<Boolean>()
val res = Resource()

autoCloseScope {
val r = install(res)
wasActive.complete(r.isActive())
}

wasActive.await() shouldBe true
res.isActive() shouldBe false
}

@Test
fun closeTheAutoScopeOnCancellation() = runTest {
val wasActive = CompletableDeferred<Boolean>()
val res = Resource()

shouldThrow<CancellationException> {
autoCloseScope {
val r = install(res)
wasActive.complete(r.isActive())
throw CancellationException("BOOM!")
}
}.message shouldBe "BOOM!"

wasActive.await() shouldBe true
res.isActive() shouldBe false
}

@OptIn(ExperimentalStdlibApi::class)
private class Resource : AutoCloseable {
private val isActive = AtomicBoolean(true)

fun isActive(): Boolean = isActive.get()

fun shutdown() {
require(isActive.compareAndSet(expected = true, new = false)) {
"Already shut down"
}
}

override fun close() {
shutdown()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package arrow

@PublishedApi
internal actual fun Throwable.throwIfFatal(): Throwable = this
Loading
Loading