Skip to content
Merged
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
42 changes: 38 additions & 4 deletions core/src/main/scala/ox/OxApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ox
import scala.util.boundary.*
import scala.util.control.NonFatal
import java.util.concurrent.ThreadFactory
import scala.concurrent.duration.*

enum ExitCode(val code: Int):
case Success extends ExitCode(0)
Expand Down Expand Up @@ -47,7 +48,11 @@ trait OxApp:
}

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

Expand Down Expand Up @@ -90,15 +95,44 @@ object OxApp:
* The thread factory that should be used to create threads in Ox scopes ([[supervised]], [[unsupervised]] etc.). Useful e.g. when
* integrating with third-party libraries to propagate context across (virtual) thread boundaries. If left unspecified, the default
* virtual thread factory is used.
* @param shutdownTimeout
* The maximum amount of time a clean shutdown might take. This might prevent deadlocks due to usage of `System.exit` in the user's
* code. After the timeout passes, the application will forcibly exit.
*/
case class Settings(
interruptedExitCode: ExitCode,
handleInterruptedException: InterruptedException => Unit,
handleException: Throwable => Unit,
threadFactory: Option[ThreadFactory]
)
threadFactory: Option[ThreadFactory],
shutdownTimeout: FiniteDuration
):
// required for binary compatibility
def this(
interruptedExitCode: ExitCode,
handleInterruptedException: InterruptedException => Unit,
handleException: Throwable => Unit,
threadFactory: Option[ThreadFactory]
) = this(interruptedExitCode, handleInterruptedException, handleException, threadFactory, 10.seconds)

// required for binary compatibility
def copy(
interruptedExitCode: ExitCode,
handleInterruptedException: InterruptedException => Unit,
handleException: Throwable => Unit,
threadFactory: Option[ThreadFactory]
): Settings = Settings(interruptedExitCode, handleInterruptedException, handleException, threadFactory, shutdownTimeout)
end Settings

object Settings:
// required for binary compatibility
def apply(
interruptedExitCode: ExitCode,
handleInterruptedException: InterruptedException => Unit,
handleException: Throwable => Unit,
threadFactory: Option[ThreadFactory]
): Settings =
Settings(interruptedExitCode, handleInterruptedException, handleException, threadFactory, 10.seconds)

val DefaultLogException: Throwable => Unit = (t: Throwable) =>
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler
if defaultHandler != null then defaultHandler.uncaughtException(Thread.currentThread(), t) else t.printStackTrace()
Expand All @@ -111,7 +145,7 @@ object OxApp:
case _ => logException(t2)

val Default: Settings =
Settings(ExitCode.Success, defaultHandleInterruptedException(DefaultLogException), DefaultLogException, None)
Settings(ExitCode.Success, defaultHandleInterruptedException(DefaultLogException), DefaultLogException, None, 10.seconds)
end Settings

/** Simple variant of OxApp does not pass command line arguments and exits with exit code 0 if no exceptions were thrown. */
Expand Down
36 changes: 36 additions & 0 deletions core/src/test/scala/ox/OxAppTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import java.io.{PrintWriter, StringWriter}
import java.util.concurrent.CountDownLatch
import scala.util.boundary.*
import scala.concurrent.duration.*
import ox.OxApp.Settings
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.{Semaphore, TimeUnit}

class OxAppTest extends AnyFlatSpec with Matchers:

Expand All @@ -25,6 +28,39 @@ class OxAppTest extends AnyFlatSpec with Matchers:
ec shouldEqual 0
}

"OxApp" should "shutdown despite cleanup taking a long time" in {
var ec = Int.MinValue

val shutdownHookStarted = new AtomicLong(0L)
val shutdownHookFinished = new AtomicLong(0L)

object Main15 extends OxApp:
override protected def settings: Settings = Settings.Default.copy(shutdownTimeout = 100.millis)
override private[ox] def mountShutdownHook(thread: Thread): Unit =
Thread.sleep(100) // let the fork with the main logic start
// running the shutdown hook - will interrupt the main logic
shutdownHookStarted.set(System.currentTimeMillis())
thread.start()
thread.join()
shutdownHookFinished.set(System.currentTimeMillis())
end mountShutdownHook

override private[ox] def exit(exitCode: ExitCode): Unit = ec = exitCode.code

override def run(args: Vector[String])(using Ox): ExitCode =
try
// will be interrupted by the shutdown hook
never
finally sleep(200.millis) // simulating cleanup longer than the shutdown timeout
end Main15

supervised:
Main15.main(Array.empty)
ec shouldEqual 0
(shutdownHookFinished.get() - shutdownHookStarted.get()) should be >= 100L
(shutdownHookFinished.get() - shutdownHookStarted.get()) should be < 200L
}

"OxApp" should "work in interrupted case" in {
var ec = Int.MinValue
val shutdownLatch = CountDownLatch(1)
Expand Down
3 changes: 3 additions & 0 deletions doc/utils/oxapp.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ All `ox.OxApp` instances can be configured by overriding the `def settings: Sett
* `threadFactory`: the thread factory that is used to create threads in Ox scopes ([[supervised]], [[unsupervised]]
etc.). Useful e.g. when integrating with third-party libraries to propagate context across (virtual) thread
boundaries.
* `shutdownTimeout`: the maximum amount of time a clean shutdown might take. By default 10 seconds. This might
prevent deadlocks due to usage of `System.exit` in the user's code. After the timeout passes, the application
will forcibly exit.

Settings can be overridden:

Expand Down