Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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
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
Expand All @@ -21,13 +23,22 @@ 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) {
"delay" -> Delay()
"wait" -> Wait
"fail" -> Fail
"childfail" -> ChildFail
"exitapp" -> ExitApp
"childexitapp" -> ChildExitApp
"childlaunchexitapp" -> ChildLaunchExitApp
else -> Delay()
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions arrow-libs/suspendapp/suspendapp/api/suspendapp.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

6 changes: 5 additions & 1 deletion arrow-libs/suspendapp/suspendapp/api/suspendapp.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@
// - Show declarations: true

// Library unique name: <io.arrow-kt:suspendapp>
final fun arrow.continuations/SuspendApp(kotlin.coroutines/CoroutineContext = ..., kotlin/Function1<kotlin/Throwable, kotlin/Unit> = ..., kotlin.time/Duration = ..., kotlin.coroutines/SuspendFunction1<kotlinx.coroutines/CoroutineScope, kotlin/Unit>) // arrow.continuations/SuspendApp|SuspendApp(kotlin.coroutines.CoroutineContext;kotlin.Function1<kotlin.Throwable,kotlin.Unit>;kotlin.time.Duration;kotlin.coroutines.SuspendFunction1<kotlinx.coroutines.CoroutineScope,kotlin.Unit>){}[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/Throwable, kotlin/Unit> = ..., kotlin.time/Duration = ..., kotlin.coroutines/SuspendFunction1<arrow.continuations/SuspendAppScope, kotlin/Unit>) // arrow.continuations/SuspendApp|SuspendApp(kotlin.coroutines.CoroutineContext;kotlin.Function1<kotlin.Throwable,kotlin.Unit>;kotlin.time.Duration;kotlin.coroutines.SuspendFunction1<arrow.continuations.SuspendAppScope,kotlin.Unit>){}[0]
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be possible to maintain binary compatibility here:

  • Give a different JvmName to the version using SuspendAppScope,
  • Create a new overload using CoroutineScope without JvmName (so the name is maintained).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This maintains JVM compatibility. I do agree that it breaks Klib compatibility, but we deemed that to be okay (see main conversation).

Simply, SuspendAppScope implements CoroutineScope, so no CCE will happen. Function types are erased on JVM, so no ABI breaks happen

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take my suggestion as a simple change that would bring 100% fidelity in JVM. But I don't want it to devolve into discussion about what exactly counts as binary compatible (e.g., should we take reflection into account?), so I'm also fine leaving it as is.

): 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) {
Expand All @@ -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
}
}
Loading