Skip to content

Commit bc1ab77

Browse files
authored
Add a timeout to shutdown (#369)
Partially resolves #368
1 parent ab8da33 commit bc1ab77

File tree

3 files changed

+77
-4
lines changed

3 files changed

+77
-4
lines changed

core/src/main/scala/ox/OxApp.scala

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ox
33
import scala.util.boundary.*
44
import scala.util.control.NonFatal
55
import java.util.concurrent.ThreadFactory
6+
import scala.concurrent.duration.*
67

78
enum ExitCode(val code: Int):
89
case Success extends ExitCode(0)
@@ -47,7 +48,11 @@ trait OxApp:
4748
}
4849

4950
// on shutdown, the above fork is cancelled, causing interruption
50-
val interruptThread = new Thread(() => cancellableMainFork.cancel().discard)
51+
val interruptThread =
52+
new Thread(() =>
53+
if timeoutOption(settings.shutdownTimeout)(cancellableMainFork.cancel()).isEmpty then
54+
Console.err.println(s"Clean shutdown timed out after ${settings.shutdownTimeout}, exiting.")
55+
)
5156
interruptThread.setName("ox-interrupt-hook")
5257
mountShutdownHook(interruptThread)
5358

@@ -90,15 +95,44 @@ object OxApp:
9095
* The thread factory that should be used to create threads in Ox scopes ([[supervised]], [[unsupervised]] etc.). Useful e.g. when
9196
* integrating with third-party libraries to propagate context across (virtual) thread boundaries. If left unspecified, the default
9297
* virtual thread factory is used.
98+
* @param shutdownTimeout
99+
* The maximum amount of time a clean shutdown might take. This might prevent deadlocks due to usage of `System.exit` in the user's
100+
* code. After the timeout passes, the application will forcibly exit.
93101
*/
94102
case class Settings(
95103
interruptedExitCode: ExitCode,
96104
handleInterruptedException: InterruptedException => Unit,
97105
handleException: Throwable => Unit,
98-
threadFactory: Option[ThreadFactory]
99-
)
106+
threadFactory: Option[ThreadFactory],
107+
shutdownTimeout: FiniteDuration
108+
):
109+
// required for binary compatibility
110+
def this(
111+
interruptedExitCode: ExitCode,
112+
handleInterruptedException: InterruptedException => Unit,
113+
handleException: Throwable => Unit,
114+
threadFactory: Option[ThreadFactory]
115+
) = this(interruptedExitCode, handleInterruptedException, handleException, threadFactory, 10.seconds)
116+
117+
// required for binary compatibility
118+
def copy(
119+
interruptedExitCode: ExitCode,
120+
handleInterruptedException: InterruptedException => Unit,
121+
handleException: Throwable => Unit,
122+
threadFactory: Option[ThreadFactory]
123+
): Settings = Settings(interruptedExitCode, handleInterruptedException, handleException, threadFactory, shutdownTimeout)
124+
end Settings
100125

101126
object Settings:
127+
// required for binary compatibility
128+
def apply(
129+
interruptedExitCode: ExitCode,
130+
handleInterruptedException: InterruptedException => Unit,
131+
handleException: Throwable => Unit,
132+
threadFactory: Option[ThreadFactory]
133+
): Settings =
134+
Settings(interruptedExitCode, handleInterruptedException, handleException, threadFactory, 10.seconds)
135+
102136
val DefaultLogException: Throwable => Unit = (t: Throwable) =>
103137
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler
104138
if defaultHandler != null then defaultHandler.uncaughtException(Thread.currentThread(), t) else t.printStackTrace()
@@ -111,7 +145,7 @@ object OxApp:
111145
case _ => logException(t2)
112146

113147
val Default: Settings =
114-
Settings(ExitCode.Success, defaultHandleInterruptedException(DefaultLogException), DefaultLogException, None)
148+
Settings(ExitCode.Success, defaultHandleInterruptedException(DefaultLogException), DefaultLogException, None, 10.seconds)
115149
end Settings
116150

117151
/** Simple variant of OxApp does not pass command line arguments and exits with exit code 0 if no exceptions were thrown. */

core/src/test/scala/ox/OxAppTest.scala

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import java.io.{PrintWriter, StringWriter}
88
import java.util.concurrent.CountDownLatch
99
import scala.util.boundary.*
1010
import scala.concurrent.duration.*
11+
import ox.OxApp.Settings
12+
import java.util.concurrent.atomic.AtomicLong
13+
import java.util.concurrent.{Semaphore, TimeUnit}
1114

1215
class OxAppTest extends AnyFlatSpec with Matchers:
1316

@@ -25,6 +28,39 @@ class OxAppTest extends AnyFlatSpec with Matchers:
2528
ec shouldEqual 0
2629
}
2730

31+
"OxApp" should "shutdown despite cleanup taking a long time" in {
32+
var ec = Int.MinValue
33+
34+
val shutdownHookStarted = new AtomicLong(0L)
35+
val shutdownHookFinished = new AtomicLong(0L)
36+
37+
object Main15 extends OxApp:
38+
override protected def settings: Settings = Settings.Default.copy(shutdownTimeout = 100.millis)
39+
override private[ox] def mountShutdownHook(thread: Thread): Unit =
40+
Thread.sleep(100) // let the fork with the main logic start
41+
// running the shutdown hook - will interrupt the main logic
42+
shutdownHookStarted.set(System.currentTimeMillis())
43+
thread.start()
44+
thread.join()
45+
shutdownHookFinished.set(System.currentTimeMillis())
46+
end mountShutdownHook
47+
48+
override private[ox] def exit(exitCode: ExitCode): Unit = ec = exitCode.code
49+
50+
override def run(args: Vector[String])(using Ox): ExitCode =
51+
try
52+
// will be interrupted by the shutdown hook
53+
never
54+
finally sleep(200.millis) // simulating cleanup longer than the shutdown timeout
55+
end Main15
56+
57+
supervised:
58+
Main15.main(Array.empty)
59+
ec shouldEqual 0
60+
(shutdownHookFinished.get() - shutdownHookStarted.get()) should be >= 100L
61+
(shutdownHookFinished.get() - shutdownHookStarted.get()) should be < 200L
62+
}
63+
2864
"OxApp" should "work in interrupted case" in {
2965
var ec = Int.MinValue
3066
val shutdownLatch = CountDownLatch(1)

doc/utils/oxapp.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ All `ox.OxApp` instances can be configured by overriding the `def settings: Sett
103103
* `threadFactory`: the thread factory that is used to create threads in Ox scopes ([[supervised]], [[unsupervised]]
104104
etc.). Useful e.g. when integrating with third-party libraries to propagate context across (virtual) thread
105105
boundaries.
106+
* `shutdownTimeout`: the maximum amount of time a clean shutdown might take. By default 10 seconds. This might
107+
prevent deadlocks due to usage of `System.exit` in the user's code. After the timeout passes, the application
108+
will forcibly exit.
106109

107110
Settings can be overridden:
108111

0 commit comments

Comments
 (0)