-
Notifications
You must be signed in to change notification settings - Fork 472
fix(suspendapp): avoid deadlock and align JVM behaviour on system.exit #3930
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
|
|
||
| actual fun exitProcess(code: Int): Nothing { | ||
| js("process.exit(code);") | ||
| error("non-exiting process.exit") | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import kotlin.system.exitProcess | ||
|
|
||
| actual fun exitProcess(code: Int): Nothing = exitProcess(code) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import kotlin.system.exitProcess | ||
|
|
||
| actual fun exitProcess(code: Int): Nothing = exitProcess(code) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| @file:OptIn(ExperimentalWasmJsInterop::class) | ||
|
|
||
| actual fun exitProcess(code: Int): Nothing { | ||
| jsExit(code) | ||
| error("did not exit") | ||
| } | ||
|
|
||
| @Suppress("unused") | ||
| private fun jsExit(code: Int) { | ||
| js("process.exit(code);") | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ package arrow.continuations | |
|
|
||
| import arrow.AutoCloseScope | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
| import java.util.concurrent.atomic.AtomicInteger | ||
| import kotlin.coroutines.CoroutineContext | ||
| import kotlin.system.exitProcess | ||
| import kotlinx.coroutines.CoroutineScope | ||
|
|
@@ -14,42 +15,85 @@ internal actual fun AutoCloseScope.process(): Process = JvmProcess | |
| private object JvmProcess : Process { | ||
| override fun onShutdown(block: suspend () -> Unit): () -> Unit { | ||
| val isShutdown = AtomicBoolean(false) | ||
| val lastSignal = AtomicInteger(-1) | ||
| onSigInt(lastSignal::set) | ||
| onSigTerm(lastSignal::set) | ||
|
|
||
| fun shutdown() { | ||
| if (!isShutdown.getAndSet(true)) | ||
| runBlocking { | ||
| // We don't call exit from ShutdownHook on JVM | ||
| try { | ||
| block() | ||
| } catch (e: Throwable) { | ||
| e.printStackTrace() | ||
| if (!isShutdown.getAndSet(true)) { | ||
| /* | ||
| `SuspendApp` is intended to be invoked from the main entrypoint (a non-daemon thread) and, in that normal usage, | ||
| will unregister this hook before main completes. | ||
|
|
||
| This hook can run when: | ||
| 1) JVM receives SIGINT/SIGTERM | ||
| 2) last non-daemon thread exits | ||
| 3) `System.exit` is called | ||
|
|
||
| We run graceful shutdown only for (1), detected by a captured signal. | ||
|
|
||
| Regarding (2): | ||
| - In the expected usage, a non-daemon caller thread (usually main) remains alive while | ||
| `SuspendApp` is running. | ||
| - So if this hook is invoked because the last non-daemon thread exited, that likely reflects an | ||
| unexpected invocation pattern (for example, daemon-thread-driven usage). | ||
| - We choose not to provide graceful-shutdown guarantees for that edge case. | ||
|
|
||
| Regarding (3) and deadlock risk: | ||
| - `System.exit` may be called from code running in the `SuspendApp` coroutine scope. | ||
| - `System.exit` will never return to that caller. | ||
| - If this hook attempts graceful termination (`block()`), it can end up waiting for the | ||
| `SuspendApp` scope to complete (join/drain), while that scope is waiting on a call path that | ||
| includes the non-returning `System.exit`. | ||
| - Since that wait cannot resolve, the shutdown hook will deadlock. | ||
|
|
||
| Other platform behaviour: | ||
| - Other targets do not have a JVM-style shutdown hook with equivalent process-exit semantics, | ||
| and currently do not attempt graceful shutdown either, except when signalled. | ||
|
|
||
| So, graceful shutdown is gated solely on SIGINT/SIGTERM capture, both to align behaviour with | ||
| other platforms and to avoid the potential deadlock of an explicit `System.exit`. | ||
| */ | ||
| if (lastSignal.get() != -1) { | ||
|
tKe marked this conversation as resolved.
|
||
| runBlocking { | ||
| // We don't call exit from ShutdownHook on JVM | ||
| try { | ||
| block() | ||
| } catch (e: Throwable) { | ||
| e.printStackTrace() | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| val hook = Thread(::shutdown, "Arrow-kt SuspendApp JVM ShutdownHook") | ||
| Runtime.getRuntime().addShutdownHook(hook) | ||
| return { | ||
| if (!isShutdown.get()) { | ||
| Runtime.getRuntime().removeShutdownHook(hook) | ||
| try { | ||
| Runtime.getRuntime().removeShutdownHook(hook) | ||
| } catch (_: IllegalStateException) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this occur? It should be gated by
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unlikely though it is, there's the possibility the runtime shutdown process has been started but our shutdown hook thread has not yet started or set the |
||
| // Shutdown hook already running, ignore | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| override fun onSigTerm(block: suspend (code: Int) -> Unit): Unit = | ||
| addSignalHandler("SIGTERM") { block(15) } | ||
| addSignalHandler("TERM", block) | ||
|
tKe marked this conversation as resolved.
|
||
|
|
||
| override fun onSigInt(block: suspend (code: Int) -> Unit): Unit = | ||
| addSignalHandler("SIGINT") { block(2) } | ||
| addSignalHandler("INT", block) | ||
|
|
||
| private fun addSignalHandler(signal: String, action: suspend () -> Unit): Unit = | ||
| private fun addSignalHandler(signal: String, action: suspend (code: Int) -> Unit): Unit = | ||
| try { | ||
| var handle: SignalHandler? = null | ||
| @Suppress("ASSIGNED_VALUE_IS_NEVER_READ") | ||
| handle = | ||
| Signal.handle(Signal(signal)) { prev -> | ||
| runBlocking { action() } | ||
| Signal.handle(Signal(signal)) { sig -> | ||
| runBlocking { action(sig.number) } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This violates the "exit code formula" The exit code formula
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is consistent with the other platforms where the onSigInt/onSigTerm blocks receive the signal number (i.e. The other platforms handlers tend to do the For the JVM, we let the JVM handle the exit code calculation based on the default signal handler so we needn't do the calculation here (although we could theoretically call System.halt we don't know if other shutdown hooks have finished or not, nor have control of the ordering) |
||
| if (handle != SignalHandler.SIG_DFL && handle != SignalHandler.SIG_IGN) { | ||
| handle?.handle(prev) | ||
| handle?.handle(sig) | ||
| } | ||
| } | ||
| } catch (_: Throwable) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like an inconsistency introduced in this PR? See other comment https://github.com/arrow-kt/arrow/pull/3930/changes#r3519183429.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently, I believe the inconsistency is on JVM still exiting with the correct signalled exit code (128+sig) but the other platforms are exiting with exit code 255 due to the timeout exception.