diff --git a/arrow-libs/suspendapp/suspendapp-test-app/src/commonMain/kotlin/app.kt b/arrow-libs/suspendapp/suspendapp-test-app/src/commonMain/kotlin/app.kt index 7baf49baf4e..fe6ec184fc2 100644 --- a/arrow-libs/suspendapp/suspendapp-test-app/src/commonMain/kotlin/app.kt +++ b/arrow-libs/suspendapp/suspendapp-test-app/src/commonMain/kotlin/app.kt @@ -1,6 +1,8 @@ import arrow.continuations.SuspendApp +import arrow.continuations.SuspendAppScope import arrow.fx.coroutines.resourceScope -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -8,7 +10,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds fun interface Work { - suspend fun CoroutineScope.work() + suspend fun SuspendAppScope.work() } sealed interface Mode : Work @@ -21,6 +23,12 @@ data object Wait : Mode, Work by Work({ data object Fail : Mode, Work by Work({ error("BOOM!") }) data object ChildFail : Mode, Work by Work({ launch { error("boom.") } }) +data object ExitApp : Mode, Work by Work({ exit(42) }) +data object ChildExitApp : Mode, Work by Work({ async { exit(2) }.await() }) +data object ChildLaunchExitApp : Mode, Work by Work({ + launch { exit(24) } + awaitCancellation() +}) fun app(mode: String?) = app( when (mode) { @@ -28,6 +36,9 @@ fun app(mode: String?) = app( "wait" -> Wait "fail" -> Fail "childfail" -> ChildFail + "exitapp" -> ExitApp + "childexitapp" -> ChildExitApp + "childlaunchexitapp" -> ChildLaunchExitApp else -> Delay() } ) diff --git a/arrow-libs/suspendapp/suspendapp-test-runner/src/test/kotlin/SuspendAppTest.kt b/arrow-libs/suspendapp/suspendapp-test-runner/src/test/kotlin/SuspendAppTest.kt index 327ba41b1b1..e11510f6c5c 100644 --- a/arrow-libs/suspendapp/suspendapp-test-runner/src/test/kotlin/SuspendAppTest.kt +++ b/arrow-libs/suspendapp/suspendapp-test-runner/src/test/kotlin/SuspendAppTest.kt @@ -30,6 +30,27 @@ abstract class SuspendAppTest : ProcessProvider { .shouldForOne { it.line shouldEndWith "IllegalStateException: boom." } } + @Test + fun exitApp() = runTest { + val (process, output) = execute("exitapp") + process.exitValue() shouldBe 42 + output.shouldForOne { it.line shouldBe "resource clean complete" } + } + + @Test + fun childExitApp() = runTest { + val (process, output) = execute("childexitapp") + process.exitValue() shouldBe 2 + output.shouldForOne { it.line shouldBe "resource clean complete" } + } + + @Test + fun childLaunchExitApp() = runTest { + val (process, output) = execute("childlaunchexitapp") + process.exitValue() shouldBe 24 + output.shouldForOne { it.line shouldBe "resource clean complete" } + } + @Test fun waitAndSignalSigterm() = waitAndSignal(Signal.SIGTERM) diff --git a/arrow-libs/suspendapp/suspendapp/api/suspendapp.api b/arrow-libs/suspendapp/suspendapp/api/suspendapp.api index bdfac61d21f..65c5ff35d3d 100644 --- a/arrow-libs/suspendapp/suspendapp/api/suspendapp.api +++ b/arrow-libs/suspendapp/suspendapp/api/suspendapp.api @@ -3,3 +3,7 @@ public final class arrow/continuations/SuspendAppKt { public static synthetic fun SuspendApp-exY8QGI$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } +public abstract interface class arrow/continuations/SuspendAppScope : kotlinx/coroutines/CoroutineScope { + public abstract fun exit (I)V +} + diff --git a/arrow-libs/suspendapp/suspendapp/api/suspendapp.klib.api b/arrow-libs/suspendapp/suspendapp/api/suspendapp.klib.api index afb85e987fa..8d46a17f9cf 100644 --- a/arrow-libs/suspendapp/suspendapp/api/suspendapp.klib.api +++ b/arrow-libs/suspendapp/suspendapp/api/suspendapp.klib.api @@ -6,4 +6,8 @@ // - Show declarations: true // Library unique name: -final fun arrow.continuations/SuspendApp(kotlin.coroutines/CoroutineContext = ..., kotlin/Function1 = ..., kotlin.time/Duration = ..., kotlin.coroutines/SuspendFunction1) // arrow.continuations/SuspendApp|SuspendApp(kotlin.coroutines.CoroutineContext;kotlin.Function1;kotlin.time.Duration;kotlin.coroutines.SuspendFunction1){}[0] +abstract interface arrow.continuations/SuspendAppScope : kotlinx.coroutines/CoroutineScope { // arrow.continuations/SuspendAppScope|null[0] + abstract fun exit(kotlin/Int) // arrow.continuations/SuspendAppScope.exit|exit(kotlin.Int){}[0] +} + +final fun arrow.continuations/SuspendApp(kotlin.coroutines/CoroutineContext = ..., kotlin/Function1 = ..., kotlin.time/Duration = ..., kotlin.coroutines/SuspendFunction1) // arrow.continuations/SuspendApp|SuspendApp(kotlin.coroutines.CoroutineContext;kotlin.Function1;kotlin.time.Duration;kotlin.coroutines.SuspendFunction1){}[0] diff --git a/arrow-libs/suspendapp/suspendapp/src/commonMain/kotlin/arrow/continuations/SuspendApp.kt b/arrow-libs/suspendapp/suspendapp/src/commonMain/kotlin/arrow/continuations/SuspendApp.kt index 16008c435bd..268bf385daf 100644 --- a/arrow-libs/suspendapp/suspendapp/src/commonMain/kotlin/arrow/continuations/SuspendApp.kt +++ b/arrow-libs/suspendapp/suspendapp/src/commonMain/kotlin/arrow/continuations/SuspendApp.kt @@ -7,8 +7,28 @@ import kotlin.time.Duration import kotlinx.coroutines.* /** - * An unsafe blocking edge that wires the [CoroutineScope] (and structured concurrency) to the - * [SuspendApp], such that the [CoroutineScope] gets cancelled when the `App` is requested to + * Scope for [SuspendApp], with operations that are specific to the application lifecycle. + */ +public interface SuspendAppScope : CoroutineScope { + /** + * Gracefully exits the enclosing [SuspendApp] with process exit status [code]. + * + * This signals [SuspendApp] to complete with the given process exit status, while allowing + * resource finalizers to run before [SuspendApp] exits the process. On Unix-like systems, this + * status is reported to the parent process or shell; `0` conventionally denotes success, and + * non-zero values denote failure or application-specific outcomes. + * + * To be used as a replacement for `System.exit(code)` and for `kotlin.system.exitProcess(code)`, + * which are unsafe to use in a coroutines-driven `main`, leading to deadlocks. + * + * @param code the exit status of the process. + */ + public fun exit(code: Int) +} + +/** + * An unsafe blocking edge that wires the [SuspendAppScope] (and structured concurrency) to the + * [SuspendApp], such that the [SuspendAppScope] gets cancelled when the `App` is requested to * gracefully shutdown. => `SIGTERM` & `SIGINT` on Native & NodeJS and a ShutdownHook for JVM. * * It applies backpressure to the process such that they can gracefully shutdown. @@ -24,13 +44,16 @@ public fun SuspendApp( context: CoroutineContext = Dispatchers.Default, uncaught: (Throwable) -> Unit = Throwable::printStackTrace, timeout: Duration = Duration.INFINITE, - block: suspend CoroutineScope.() -> Unit, + block: suspend SuspendAppScope.() -> Unit, ): Unit = autoCloseScope { val env = process() env.runScope(context) { val result = supervisorScope { val app = - async(start = CoroutineStart.LAZY, block = block) + async(start = CoroutineStart.LAZY) { + val appScope = DefaultSuspendAppScope(this) + appScope.block() + } val unregister = env.onShutdown { withTimeout(timeout) { @@ -42,13 +65,27 @@ public fun SuspendApp( .also { unregister() } } result.fold({ env.exit(0) }) { e -> - if (e !is SuspendAppShutdown) { - uncaught(e) - env.exit(-1) + when (e) { + is SuspendAppShutdown -> env.exit(e.code ?: -1) + else -> { + uncaught(e) + env.exit(-1) + } } } } } -/** Marker type to track shutdown signal */ -private class SuspendAppShutdown : CancellationException("SuspendApp shutting down.") +/** Marker type to track the shutdown signal */ +private class SuspendAppShutdown(val code: Int? = null) : + CancellationException(code?.let { "SuspendApp exiting with code $it." } ?: "SuspendApp shutting down.") + +private class DefaultSuspendAppScope( + coroutineScope: CoroutineScope, +) : SuspendAppScope, CoroutineScope by coroutineScope { + override fun exit(code: Int) { + val shutdown = SuspendAppShutdown(code) + coroutineContext.cancel(shutdown) + throw shutdown + } +}