From 15c3de58fe01405305ca8e0820f327e2d4c57d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Bia=C5=82y?= Date: Thu, 20 Jun 2024 13:32:54 +0200 Subject: [PATCH 1/8] first draft of OxApp with extension companion traits --- core/src/main/scala/ox/OxApp.scala | 65 +++++++ core/src/test/scala/ox/OxAppTest.scala | 254 +++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 core/src/main/scala/ox/OxApp.scala create mode 100644 core/src/test/scala/ox/OxAppTest.scala diff --git a/core/src/main/scala/ox/OxApp.scala b/core/src/main/scala/ox/OxApp.scala new file mode 100644 index 00000000..fd3a78d4 --- /dev/null +++ b/core/src/main/scala/ox/OxApp.scala @@ -0,0 +1,65 @@ +package ox + +import scala.util.boundary.* +import scala.util.control.NonFatal + +enum ExitCode(val code: Int): + case Success extends ExitCode(0) + case Failure(exitCode: Int = 1) extends ExitCode(exitCode) + +trait OxApp: + def main(args: Array[String]): Unit = + unsupervised: + val cancellableMainFork = forkCancellable(supervised(handleRun(args.toVector))) + + val interruptThread = new Thread(() => { + cancellableMainFork.cancel() + () + }) + + interruptThread.setName("ox-interrupt-hook") + + mountShutdownHook(interruptThread) + + cancellableMainFork.joinEither() match + case Left(iex: InterruptedException) => exit(0) + case Left(fatalErr) => throw fatalErr + case Right(exitCode) => exit(exitCode.code) + + private[ox] def exit(code: Int): Unit = System.exit(code) + + private[ox] def mountShutdownHook(thread: Thread): Unit = + try Runtime.getRuntime.addShutdownHook(thread) + catch case _: IllegalStateException => () + + private[ox] def printStackTrace(t: Throwable): Unit = t.printStackTrace() + + private[OxApp] final def handleRun(args: Vector[String])(using Ox): ExitCode = + try run(args) + catch + case NonFatal(err) => + printStackTrace(err) + ExitCode.Failure() + + def run(args: Vector[String])(using Ox): ExitCode + +object OxApp: + trait Simple extends OxApp: + override final def run(args: Vector[String])(using Ox): ExitCode = + run + ExitCode.Success + + def run(using Ox): Unit + + trait WithErrors[E] extends OxApp: + + type EitherScope[Err] = Label[Either[Err, ExitCode]] + + override final def run(args: Vector[String])(using ox: Ox): ExitCode = + either[E, ExitCode](label ?=> run(args)(using ox, label)) match + case Left(e) => handleErrors(e) + case Right(ec) => ec + + def handleErrors(e: E): ExitCode + + def run(args: Vector[String])(using Ox, EitherScope[E]): ExitCode diff --git a/core/src/test/scala/ox/OxAppTest.scala b/core/src/test/scala/ox/OxAppTest.scala new file mode 100644 index 00000000..1fc4ae0a --- /dev/null +++ b/core/src/test/scala/ox/OxAppTest.scala @@ -0,0 +1,254 @@ +package ox + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import ox.ExitCode.* + +import java.io.{PrintWriter, StringWriter} +import java.util.concurrent.CountDownLatch +import scala.util.boundary.* + +class OxAppTest extends AnyFlatSpec with Matchers: + + "OxApp" should "work in happy case" in { + var ec = Int.MinValue + + object Main1 extends OxApp: + override def exit(code: Int): Unit = + ec = code + + def run(args: Vector[String])(using Ox): ExitCode = Success + + Main1.main(Array.empty) + + ec shouldEqual 0 + } + + "OxApp" should "work in interrupted case" in { + var ec = Int.MinValue + val shutdownLatch = CountDownLatch(1) + + object Main2 extends OxApp: + override private[ox] def mountShutdownHook(thread: Thread): Unit = + val damoclesThread = Thread(() => { + shutdownLatch.await() + thread.start() + thread.join() + }) + + damoclesThread.start() + + override private[ox] def exit(code: Int): Unit = + ec = code + + def run(args: Vector[String])(using Ox): ExitCode = + forever: // will never finish + Thread.sleep(10) + + Success + + supervised: + fork(Main2.main(Array.empty)) + Thread.sleep(10) + shutdownLatch.countDown() + + ec shouldEqual 0 + } + + "OxApp" should "work in failed case" in { + var ec = Int.MinValue + var stackTrace = "" + + object Main3 extends OxApp: + override def run(args: Vector[String])(using Ox): ExitCode = + Failure(23) + + override private[ox] def exit(code: Int): Unit = + ec = code + + Main3.main(Array.empty) + + ec shouldEqual 23 + + ec = Int.MinValue + + object Main4 extends OxApp: + override def run(args: Vector[String])(using Ox): ExitCode = + throw Exception("oh no") + + override private[ox] def printStackTrace(t: Throwable): Unit = + val sw = StringWriter() + val pw = PrintWriter(sw) + t.printStackTrace(pw) + stackTrace = sw.toString + + override private[ox] def exit(code: Int): Unit = + ec = code + + Main4.main(Array.empty) + + ec shouldEqual 1 + assert(stackTrace.contains("oh no")) + } + + "OxApp.Simple" should "work in happy case" in { + var ec = Int.MinValue + + object Main5 extends OxApp.Simple: + override def exit(code: Int): Unit = + ec = code + + override def run(using Ox): Unit = () + + Main5.main(Array.empty) + + ec shouldEqual 0 + } + + "OxApp.Simple" should "work in interrupted case" in { + var ec = Int.MinValue + val shutdownLatch = CountDownLatch(1) + + object Main6 extends OxApp.Simple: + override private[ox] def mountShutdownHook(thread: Thread): Unit = + val damoclesThread = Thread(() => { + shutdownLatch.await() + thread.start() + thread.join() + }) + + damoclesThread.start() + + override def exit(code: Int): Unit = + ec = code + + override def run(using Ox): Unit = + forever: + Thread.sleep(10) + + supervised: + fork(Main6.main(Array.empty)) + Thread.sleep(10) + shutdownLatch.countDown() + + ec shouldEqual 0 + } + + "OxApp.Simple" should "work in failed case" in { + var ec = Int.MinValue + var stackTrace = "" + + object Main7 extends OxApp.Simple: + override def run(using Ox): Unit = throw Exception("oh no") + + override private[ox] def printStackTrace(t: Throwable): Unit = + val sw = StringWriter() + val pw = PrintWriter(sw) + t.printStackTrace(pw) + stackTrace = sw.toString + + override private[ox] def exit(code: Int): Unit = + ec = code + + Main7.main(Array.empty) + + ec shouldEqual 1 + assert(stackTrace.contains("oh no")) + } + + case class FunException(code: Int) extends Exception("") + + import ox.either.* + + "OxApp.WithErrors" should "work in happy case" in { + var ec = Int.MinValue + val errOrEc: Either[FunException, ExitCode] = Right(Success) + + object Main8 extends OxApp.WithErrors[FunException]: + override def exit(code: Int): Unit = + ec = code + + override def handleErrors(e: FunException): ExitCode = Failure(e.code) + + override def run(args: Vector[String])(using Ox, EitherScope[FunException]): ExitCode = + errOrEc.ok() + + Main8.main(Array.empty) + + ec shouldEqual 0 + } + + "OxApp.WithErrors" should "work in interrupted case" in { + var ec = Int.MinValue + val shutdownLatch = CountDownLatch(1) + val errOrEc: Either[FunException, ExitCode] = Left(FunException(23)) + + object Main9 extends OxApp.WithErrors[FunException]: + override private[ox] def mountShutdownHook(thread: Thread): Unit = + val damoclesThread = Thread(() => { + shutdownLatch.await() + thread.start() + thread.join() + }) + + damoclesThread.start() + + override def handleErrors(e: FunException): ExitCode = Failure(e.code) + + override private[ox] def exit(code: Int): Unit = + ec = code + + override def run(args: Vector[String])(using Ox, EitherScope[FunException]): ExitCode = + forever: // will never finish + Thread.sleep(10) + + errOrEc.ok() + + supervised: + fork(Main9.main(Array.empty)) + Thread.sleep(10) + shutdownLatch.countDown() + + ec shouldEqual 0 + } + + "OxApp.WithErrors" should "work in failed case" in { + var ec = Int.MinValue + val errOrEc: Either[FunException, ExitCode] = Left(FunException(23)) + var stackTrace = "" + + object Main10 extends OxApp.WithErrors[FunException]: + override def run(args: Vector[String])(using Ox, EitherScope[FunException]): ExitCode = + errOrEc.ok() + + override private[ox] def exit(code: Int): Unit = + ec = code + + override def handleErrors(e: FunException): ExitCode = Failure(e.code) + + Main10.main(Array.empty) + + ec shouldEqual 23 + + ec = Int.MinValue + + object Main11 extends OxApp.WithErrors[FunException]: + override def run(args: Vector[String])(using Ox, EitherScope[FunException]): ExitCode = + throw Exception("oh no") + + override private[ox] def exit(code: Int): Unit = + ec = code + + override private[ox] def printStackTrace(t: Throwable): Unit = + val sw = StringWriter() + val pw = PrintWriter(sw) + t.printStackTrace(pw) + stackTrace = sw.toString + + def handleErrors(e: FunException): ExitCode = ??? // should not get called! + + Main11.main(Array.empty) + + ec shouldEqual 1 + assert(stackTrace.contains("oh no")) + } From 7ed38e5c1823e3370033cf5efdbb7f49789ecb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Bia=C5=82y?= Date: Sat, 29 Jun 2024 14:10:29 +0200 Subject: [PATCH 2/8] maybe like this? --- core/src/main/scala/ox/OxApp.scala | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/core/src/main/scala/ox/OxApp.scala b/core/src/main/scala/ox/OxApp.scala index fd3a78d4..71a2a919 100644 --- a/core/src/main/scala/ox/OxApp.scala +++ b/core/src/main/scala/ox/OxApp.scala @@ -22,7 +22,7 @@ trait OxApp: mountShutdownHook(interruptThread) cancellableMainFork.joinEither() match - case Left(iex: InterruptedException) => exit(0) + case Left(iex: InterruptedException) => exit(130) case Left(fatalErr) => throw fatalErr case Right(exitCode) => exit(exitCode.code) @@ -51,15 +51,21 @@ object OxApp: def run(using Ox): Unit - trait WithErrors[E] extends OxApp: + trait WithErrorMode[E, F[_]](em: ErrorMode[E, F]) extends OxApp: + override def run(args: Vector[String])(using ox: Ox): ExitCode = + val result = run(args.toList) + if em.isError(result) then handleError(em.getError(result)) + else ExitCode.Success - type EitherScope[Err] = Label[Either[Err, ExitCode]] + def handleError(e: E): ExitCode + + def run(args: List[String])(using Ox): F[ExitCode] - override final def run(args: Vector[String])(using ox: Ox): ExitCode = - either[E, ExitCode](label ?=> run(args)(using ox, label)) match - case Left(e) => handleErrors(e) - case Right(ec) => ec + abstract class WithEitherErrors[E] extends WithErrorMode(EitherMode[E]()): + + type EitherScope[Err] = Label[Either[Err, ExitCode]] - def handleErrors(e: E): ExitCode + override final def run(args: List[String])(using ox: Ox): Either[E, ExitCode] = + either[E, ExitCode](label ?=> run(args.toVector)(using ox, label)) def run(args: Vector[String])(using Ox, EitherScope[E]): ExitCode From 7914adca35d3cf6606ba0e1b83046b2de63af1bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Bia=C5=82y?= Date: Tue, 2 Jul 2024 10:55:53 +0200 Subject: [PATCH 3/8] added scaladocs, fixed things from review --- core/src/main/scala/ox/OxApp.scala | 77 ++++++++++++++++++++---- core/src/test/scala/ox/OxAppTest.scala | 81 +++++++++++++------------- 2 files changed, 108 insertions(+), 50 deletions(-) diff --git a/core/src/main/scala/ox/OxApp.scala b/core/src/main/scala/ox/OxApp.scala index 71a2a919..52f4802b 100644 --- a/core/src/main/scala/ox/OxApp.scala +++ b/core/src/main/scala/ox/OxApp.scala @@ -8,8 +8,12 @@ enum ExitCode(val code: Int): case Failure(exitCode: Int = 1) extends ExitCode(exitCode) trait OxApp: - def main(args: Array[String]): Unit = - unsupervised: + import OxApp.AppSettings + + protected def settings: AppSettings = AppSettings.defaults + + final def main(args: Array[String]): Unit = + unsupervised { val cancellableMainFork = forkCancellable(supervised(handleRun(args.toVector))) val interruptThread = new Thread(() => { @@ -22,16 +26,30 @@ trait OxApp: mountShutdownHook(interruptThread) cancellableMainFork.joinEither() match - case Left(iex: InterruptedException) => exit(130) + case Left(iex: InterruptedException) => exit(settings.gracefulShutdownExitCode) case Left(fatalErr) => throw fatalErr - case Right(exitCode) => exit(exitCode.code) - - private[ox] def exit(code: Int): Unit = System.exit(code) - + case Right(exitCode) => exit(exitCode) + } + + /** For testing - trapping System.exit is impossible due to SecurityManager removal so it's just overrideable in tests. + * @param code + * Int exit code + */ + private[ox] def exit(exitCode: ExitCode): Unit = System.exit(exitCode.code) + + /** For testing - allows to trigger shutdown hook without actually stopping the jvm. + * @param thread + * Thread + */ private[ox] def mountShutdownHook(thread: Thread): Unit = try Runtime.getRuntime.addShutdownHook(thread) catch case _: IllegalStateException => () + /** For testing - allows to capture the stack trace printed to the console + * + * @param t + * Throwable + */ private[ox] def printStackTrace(t: Throwable): Unit = t.printStackTrace() private[OxApp] final def handleRun(args: Vector[String])(using Ox): ExitCode = @@ -44,6 +62,18 @@ trait OxApp: def run(args: Vector[String])(using Ox): ExitCode object OxApp: + + case class AppSettings( + /** This value is returned to the operating system as the exit code when the app receives SIGINT and shuts itself down gracefully. */ + gracefulShutdownExitCode: ExitCode = ExitCode.Success + ) + + object AppSettings { + lazy val defaults: AppSettings = AppSettings() + } + + /** Simple variant of OxApp does not pass command line arguments and exits with exit code 0 if no exceptions were thrown. + */ trait Simple extends OxApp: override final def run(args: Vector[String])(using Ox): ExitCode = run @@ -51,21 +81,48 @@ object OxApp: def run(using Ox): Unit + /** WithErrorMode variant of OxApp allows to specify what kind of error handling for the main function should be used. Base trait for + * integrations. + * + * @tparam E + * Error type + * @tparam F + * wrapper type for given ErrorMode + */ trait WithErrorMode[E, F[_]](em: ErrorMode[E, F]) extends OxApp: - override def run(args: Vector[String])(using ox: Ox): ExitCode = + override final def run(args: Vector[String])(using ox: Ox): ExitCode = val result = run(args.toList) if em.isError(result) then handleError(em.getError(result)) else ExitCode.Success + /** Allows implementor of this trait to translate an error that app finished with into a concrete ExitCode. + * @param e + * E Error type + * @return + * ExitCode + */ def handleError(e: E): ExitCode + /** This template method has to take a List[String] argument to avoid signature clash with `run` inherited from OxApp. + * + * @param args + * List[String] + * @return + * F[ExitCode] + */ def run(args: List[String])(using Ox): F[ExitCode] + /** WithEitherErrors variant of OxApp integrates OxApp with an `either` block and allows for usage of `.ok()` combinators in the body of + * the main function. + * + * @tparam E + * Error type + */ abstract class WithEitherErrors[E] extends WithErrorMode(EitherMode[E]()): - type EitherScope[Err] = Label[Either[Err, ExitCode]] + type EitherError[Err] = Label[Either[Err, ExitCode]] override final def run(args: List[String])(using ox: Ox): Either[E, ExitCode] = either[E, ExitCode](label ?=> run(args.toVector)(using ox, label)) - def run(args: Vector[String])(using Ox, EitherScope[E]): ExitCode + def run(args: Vector[String])(using Ox, EitherError[E]): ExitCode diff --git a/core/src/test/scala/ox/OxAppTest.scala b/core/src/test/scala/ox/OxAppTest.scala index 1fc4ae0a..685821f0 100644 --- a/core/src/test/scala/ox/OxAppTest.scala +++ b/core/src/test/scala/ox/OxAppTest.scala @@ -7,6 +7,7 @@ import ox.ExitCode.* import java.io.{PrintWriter, StringWriter} import java.util.concurrent.CountDownLatch import scala.util.boundary.* +import scala.concurrent.duration.* class OxAppTest extends AnyFlatSpec with Matchers: @@ -14,8 +15,8 @@ class OxAppTest extends AnyFlatSpec with Matchers: var ec = Int.MinValue object Main1 extends OxApp: - override def exit(code: Int): Unit = - ec = code + override def exit(exitCode: ExitCode): Unit = + ec = exitCode.code def run(args: Vector[String])(using Ox): ExitCode = Success @@ -38,18 +39,18 @@ class OxAppTest extends AnyFlatSpec with Matchers: damoclesThread.start() - override private[ox] def exit(code: Int): Unit = - ec = code + override private[ox] def exit(exitCode: ExitCode): Unit = + ec = exitCode.code def run(args: Vector[String])(using Ox): ExitCode = forever: // will never finish - Thread.sleep(10) + sleep(10.millis) Success supervised: fork(Main2.main(Array.empty)) - Thread.sleep(10) + sleep(10.millis) shutdownLatch.countDown() ec shouldEqual 0 @@ -63,8 +64,8 @@ class OxAppTest extends AnyFlatSpec with Matchers: override def run(args: Vector[String])(using Ox): ExitCode = Failure(23) - override private[ox] def exit(code: Int): Unit = - ec = code + override private[ox] def exit(exitCode: ExitCode): Unit = + ec = exitCode.code Main3.main(Array.empty) @@ -82,8 +83,8 @@ class OxAppTest extends AnyFlatSpec with Matchers: t.printStackTrace(pw) stackTrace = sw.toString - override private[ox] def exit(code: Int): Unit = - ec = code + override private[ox] def exit(exitCode: ExitCode): Unit = + ec = exitCode.code Main4.main(Array.empty) @@ -95,8 +96,8 @@ class OxAppTest extends AnyFlatSpec with Matchers: var ec = Int.MinValue object Main5 extends OxApp.Simple: - override def exit(code: Int): Unit = - ec = code + override def exit(exitCode: ExitCode): Unit = + ec = exitCode.code override def run(using Ox): Unit = () @@ -119,16 +120,16 @@ class OxAppTest extends AnyFlatSpec with Matchers: damoclesThread.start() - override def exit(code: Int): Unit = - ec = code + override def exit(exitCode: ExitCode): Unit = + ec = exitCode.code override def run(using Ox): Unit = forever: - Thread.sleep(10) + sleep(10.millis) supervised: fork(Main6.main(Array.empty)) - Thread.sleep(10) + sleep(10.millis) shutdownLatch.countDown() ec shouldEqual 0 @@ -147,8 +148,8 @@ class OxAppTest extends AnyFlatSpec with Matchers: t.printStackTrace(pw) stackTrace = sw.toString - override private[ox] def exit(code: Int): Unit = - ec = code + override private[ox] def exit(exitCode: ExitCode): Unit = + ec = exitCode.code Main7.main(Array.empty) @@ -164,13 +165,13 @@ class OxAppTest extends AnyFlatSpec with Matchers: var ec = Int.MinValue val errOrEc: Either[FunException, ExitCode] = Right(Success) - object Main8 extends OxApp.WithErrors[FunException]: - override def exit(code: Int): Unit = - ec = code + object Main8 extends OxApp.WithEitherErrors[FunException]: + override def exit(exitCode: ExitCode): Unit = + ec = exitCode.code - override def handleErrors(e: FunException): ExitCode = Failure(e.code) + override def handleError(e: FunException): ExitCode = Failure(e.code) - override def run(args: Vector[String])(using Ox, EitherScope[FunException]): ExitCode = + override def run(args: Vector[String])(using Ox, EitherError[FunException]): ExitCode = errOrEc.ok() Main8.main(Array.empty) @@ -183,7 +184,7 @@ class OxAppTest extends AnyFlatSpec with Matchers: val shutdownLatch = CountDownLatch(1) val errOrEc: Either[FunException, ExitCode] = Left(FunException(23)) - object Main9 extends OxApp.WithErrors[FunException]: + object Main9 extends OxApp.WithEitherErrors[FunException]: override private[ox] def mountShutdownHook(thread: Thread): Unit = val damoclesThread = Thread(() => { shutdownLatch.await() @@ -193,20 +194,20 @@ class OxAppTest extends AnyFlatSpec with Matchers: damoclesThread.start() - override def handleErrors(e: FunException): ExitCode = Failure(e.code) + override def handleError(e: FunException): ExitCode = Failure(e.code) - override private[ox] def exit(code: Int): Unit = - ec = code + override private[ox] def exit(exitCode: ExitCode): Unit = + ec = exitCode.code - override def run(args: Vector[String])(using Ox, EitherScope[FunException]): ExitCode = + override def run(args: Vector[String])(using Ox, EitherError[FunException]): ExitCode = forever: // will never finish - Thread.sleep(10) + sleep(10.millis) errOrEc.ok() supervised: fork(Main9.main(Array.empty)) - Thread.sleep(10) + sleep(10.millis) shutdownLatch.countDown() ec shouldEqual 0 @@ -217,14 +218,14 @@ class OxAppTest extends AnyFlatSpec with Matchers: val errOrEc: Either[FunException, ExitCode] = Left(FunException(23)) var stackTrace = "" - object Main10 extends OxApp.WithErrors[FunException]: - override def run(args: Vector[String])(using Ox, EitherScope[FunException]): ExitCode = + object Main10 extends OxApp.WithEitherErrors[FunException]: + override def run(args: Vector[String])(using Ox, EitherError[FunException]): ExitCode = errOrEc.ok() - override private[ox] def exit(code: Int): Unit = - ec = code + override private[ox] def exit(exitCode: ExitCode): Unit = + ec = exitCode.code - override def handleErrors(e: FunException): ExitCode = Failure(e.code) + override def handleError(e: FunException): ExitCode = Failure(e.code) Main10.main(Array.empty) @@ -232,12 +233,12 @@ class OxAppTest extends AnyFlatSpec with Matchers: ec = Int.MinValue - object Main11 extends OxApp.WithErrors[FunException]: - override def run(args: Vector[String])(using Ox, EitherScope[FunException]): ExitCode = + object Main11 extends OxApp.WithEitherErrors[FunException]: + override def run(args: Vector[String])(using Ox, EitherError[FunException]): ExitCode = throw Exception("oh no") - override private[ox] def exit(code: Int): Unit = - ec = code + override private[ox] def exit(exitCode: ExitCode): Unit = + ec = exitCode.code override private[ox] def printStackTrace(t: Throwable): Unit = val sw = StringWriter() @@ -245,7 +246,7 @@ class OxAppTest extends AnyFlatSpec with Matchers: t.printStackTrace(pw) stackTrace = sw.toString - def handleErrors(e: FunException): ExitCode = ??? // should not get called! + override def handleError(e: FunException): ExitCode = ??? // should not get called! Main11.main(Array.empty) From 05bc9bad479761eb1aabe9ef7859b17e7bca021d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Bia=C5=82y?= Date: Tue, 2 Jul 2024 20:08:45 +0200 Subject: [PATCH 4/8] rename the contentious run method to runWithErrors --- core/src/main/scala/ox/OxApp.scala | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/src/main/scala/ox/OxApp.scala b/core/src/main/scala/ox/OxApp.scala index 52f4802b..82aec1bf 100644 --- a/core/src/main/scala/ox/OxApp.scala +++ b/core/src/main/scala/ox/OxApp.scala @@ -91,7 +91,7 @@ object OxApp: */ trait WithErrorMode[E, F[_]](em: ErrorMode[E, F]) extends OxApp: override final def run(args: Vector[String])(using ox: Ox): ExitCode = - val result = run(args.toList) + val result = runWithErrors(args) if em.isError(result) then handleError(em.getError(result)) else ExitCode.Success @@ -103,14 +103,15 @@ object OxApp: */ def handleError(e: E): ExitCode - /** This template method has to take a List[String] argument to avoid signature clash with `run` inherited from OxApp. + /** This template method is to be implemented by abstract classes that add integration for particular error handling data structure of + * type F[_]. * * @param args * List[String] * @return * F[ExitCode] */ - def run(args: List[String])(using Ox): F[ExitCode] + def runWithErrors(args: Vector[String])(using Ox): F[ExitCode] /** WithEitherErrors variant of OxApp integrates OxApp with an `either` block and allows for usage of `.ok()` combinators in the body of * the main function. @@ -122,7 +123,7 @@ object OxApp: type EitherError[Err] = Label[Either[Err, ExitCode]] - override final def run(args: List[String])(using ox: Ox): Either[E, ExitCode] = + override final def runWithErrors(args: Vector[String])(using ox: Ox): Either[E, ExitCode] = either[E, ExitCode](label ?=> run(args.toVector)(using ox, label)) def run(args: Vector[String])(using Ox, EitherError[E]): ExitCode From eb9c89737934972fa24b8832738d60d599f0d065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Bia=C5=82y?= Date: Tue, 9 Jul 2024 00:57:17 +0200 Subject: [PATCH 5/8] added docs --- core/src/main/scala/ox/OxApp.scala | 30 ++++++--- doc/index.md | 1 + doc/oxapp.md | 102 +++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 doc/oxapp.md diff --git a/core/src/main/scala/ox/OxApp.scala b/core/src/main/scala/ox/OxApp.scala index 82aec1bf..f1b36b75 100644 --- a/core/src/main/scala/ox/OxApp.scala +++ b/core/src/main/scala/ox/OxApp.scala @@ -3,11 +3,13 @@ package ox import scala.util.boundary.* import scala.util.control.NonFatal -enum ExitCode(val code: Int): +enum ExitCode(val code: Int) { case Success extends ExitCode(0) case Failure(exitCode: Int = 1) extends ExitCode(exitCode) +} + +trait OxApp { -trait OxApp: import OxApp.AppSettings protected def settings: AppSettings = AppSettings.defaults @@ -32,12 +34,14 @@ trait OxApp: } /** For testing - trapping System.exit is impossible due to SecurityManager removal so it's just overrideable in tests. + * * @param code * Int exit code */ private[ox] def exit(exitCode: ExitCode): Unit = System.exit(exitCode.code) /** For testing - allows to trigger shutdown hook without actually stopping the jvm. + * * @param thread * Thread */ @@ -61,10 +65,14 @@ trait OxApp: def run(args: Vector[String])(using Ox): ExitCode -object OxApp: +} + +object OxApp { case class AppSettings( - /** This value is returned to the operating system as the exit code when the app receives SIGINT and shuts itself down gracefully. */ + /** This value is returned to the operating system as the exit code when the app receives SIGINT and shuts itself down gracefully. + * Default value is `ExitCode.Success` (0). JVM itself returns code `130` when it receives `SIGINT`. + */ gracefulShutdownExitCode: ExitCode = ExitCode.Success ) @@ -74,12 +82,13 @@ object OxApp: /** Simple variant of OxApp does not pass command line arguments and exits with exit code 0 if no exceptions were thrown. */ - trait Simple extends OxApp: + trait Simple extends OxApp { override final def run(args: Vector[String])(using Ox): ExitCode = run ExitCode.Success def run(using Ox): Unit + } /** WithErrorMode variant of OxApp allows to specify what kind of error handling for the main function should be used. Base trait for * integrations. @@ -89,13 +98,14 @@ object OxApp: * @tparam F * wrapper type for given ErrorMode */ - trait WithErrorMode[E, F[_]](em: ErrorMode[E, F]) extends OxApp: + trait WithErrorMode[E, F[_]](em: ErrorMode[E, F]) extends OxApp { override final def run(args: Vector[String])(using ox: Ox): ExitCode = val result = runWithErrors(args) if em.isError(result) then handleError(em.getError(result)) else ExitCode.Success /** Allows implementor of this trait to translate an error that app finished with into a concrete ExitCode. + * * @param e * E Error type * @return @@ -112,6 +122,7 @@ object OxApp: * F[ExitCode] */ def runWithErrors(args: Vector[String])(using Ox): F[ExitCode] + } /** WithEitherErrors variant of OxApp integrates OxApp with an `either` block and allows for usage of `.ok()` combinators in the body of * the main function. @@ -119,11 +130,14 @@ object OxApp: * @tparam E * Error type */ - abstract class WithEitherErrors[E] extends WithErrorMode(EitherMode[E]()): + abstract class WithEitherErrors[E] extends WithErrorMode(EitherMode[E]()) { type EitherError[Err] = Label[Either[Err, ExitCode]] override final def runWithErrors(args: Vector[String])(using ox: Ox): Either[E, ExitCode] = - either[E, ExitCode](label ?=> run(args.toVector)(using ox, label)) + either[E, ExitCode](label ?=> run(args)(using ox, label)) def run(args: Vector[String])(using Ox, EitherError[E]): ExitCode + } + +} diff --git a/doc/index.md b/doc/index.md index 84943737..2d72f846 100644 --- a/doc/index.md +++ b/doc/index.md @@ -47,6 +47,7 @@ In addition to this documentation, ScalaDocs can be browsed at [https://javadoc. :maxdepth: 2 :caption: Resiliency, I/O & utilities + oxapp io retries resources diff --git a/doc/oxapp.md b/doc/oxapp.md new file mode 100644 index 00000000..425fa477 --- /dev/null +++ b/doc/oxapp.md @@ -0,0 +1,102 @@ +# OxApp + +Ox provides a way to define application entry points in the Ox way using `OxApp` trait. Starting the app this way comes +with some useful benefits like the main `run` function being executed on a virtual thread with the root `Ox` scope provided +and application interruption handling built-in. The latter is handled using `Runtime.addShutdownHook` and will interrupt +the main virtual thread should app receive a, for example, SIGINT due to Ctrl + C being pressed by the user. +Here's an example: + +```scala mdoc:compile-only +import ox.* +import scala.concurrent.duration.* + +object MyApp extends OxApp { + def run(args: Vector[String])(using Ox): ExitCode = { + forkUser { + sleep(500.millis) + println("Fork finished!") + } + println(s"Started app with args: ${args.mkString(", ")}!") + ExitCode.Success + } +} +``` + +The `run` function receives command line arguments as a `Vector` of `String`s, a given `Ox` capability and has to +return an `ox.ExitCode` value which translates to the exit code returned from the program. `ox.ExitCode` is defined as: + +```scala mdoc:compile-only +enum ExitCode(val code: Int) { + case Success extends ExitCode(0) + case Failure(exitCode: Int = 1) extends ExitCode(exitCode) +} +``` + +There's also a simplified variant of `OxApp` for situations where you don't care about command line arguments. +The `run` function doesn't take any arguments beyond the root `Ox` scope capability, expects no `ExitCode` and will +handle any exceptions thrown by printing a stack trace and returning an exit code of `1`: + + +```scala mdoc:compile-only +import ox.* + +object MyApp extends OxApp.Simple { + def run(using Ox): Unit = println("All done!") +} +``` + +`OxApp` has also a variant that integrates with [either](basics/error-handling.md#boundary-break-for-eithers) +blocks for direct-style error handling called `OxApp.WithEitherErrors[E]` where `E` is the type of errors from the +`run` function that you want to handle. The interesting bit is that `run` function in `OxApp.WithEitherErrors` receives +an `either` block token of type `EitherError[E]` (which itself is an alias for `Label[Either[E, ExitCode]]` as `either` +operates on boundary/break mechanism) and therefore it's possible to use `.ok()` combinators directly in the `run` +function scope. `OxApp.WithEitherErrors` requires that one implements a function that translates application errors +into `ExitCode` instances. Here's an example that always fails and exits with exit code `23`: + +```scala mdoc:compile-only +import ox.* +import ox.either.* + +sealed trait MyAppError +case class ComputationError(msg: String) extends Exception(msg) with MyAppError + +object MyApp extends OxApp.WithEitherErrors[MyAppError] { + + def doWork(): Either[MyAppError, Unit] = Left(ComputationError("oh no")) + + def handleError(myAppError: MyAppError): ExitCode = myAppError match { + case ComputationError(_) => ExitCode.Failure(23) + } + + def run(args: Vector[String])(using Ox, EitherError[MyAppError]): ExitCode = { + doWork().ok() // will close the scope with MyAppError as `doWork` returns a Left + ExitCode.Success + } + +} +``` + +## Additional configuration + +All `ox.OxApp` instances can be configured by overriding the `def settings: AppSettings` method. For now `AppSettings` +contains only the `gracefulShutdownExitCode` setting that allows one to decide what exit code should be returned by +the application once it gracefully shutdowns after it was interrupted (for example Ctrl + C was pressed by the user). +By default `OxApp` will exit in such scenario with exit code `0` meaning successful graceful shutdown but it can be +overridden: + +```scala mdoc:compile-only +import ox.* +import scala.concurrent.duration.* +import OxApp.AppSettings + +object MyApp extends OxApp { + override def settings: AppSettings = AppSettings( + gracefulShutdownExitCode = ExitCode.Failure(130) + ) + + def run(args: Vector[String])(using Ox): ExitCode = { + sleep(60.seconds) + ExitCode.Success + } +} +``` From d537c9126e756bd511ca9b6c37c1ab86c34b59a3 Mon Sep 17 00:00:00 2001 From: adamw Date: Tue, 9 Jul 2024 17:19:43 +0200 Subject: [PATCH 6/8] Cosmetics --- core/src/main/scala/ox/OxApp.scala | 58 ++++++++++-------------------- 1 file changed, 18 insertions(+), 40 deletions(-) diff --git a/core/src/main/scala/ox/OxApp.scala b/core/src/main/scala/ox/OxApp.scala index f1b36b75..c8c39e2e 100644 --- a/core/src/main/scala/ox/OxApp.scala +++ b/core/src/main/scala/ox/OxApp.scala @@ -3,13 +3,11 @@ package ox import scala.util.boundary.* import scala.util.control.NonFatal -enum ExitCode(val code: Int) { +enum ExitCode(val code: Int): case Success extends ExitCode(0) case Failure(exitCode: Int = 1) extends ExitCode(exitCode) -} - -trait OxApp { +trait OxApp: import OxApp.AppSettings protected def settings: AppSettings = AppSettings.defaults @@ -33,27 +31,15 @@ trait OxApp { case Right(exitCode) => exit(exitCode) } - /** For testing - trapping System.exit is impossible due to SecurityManager removal so it's just overrideable in tests. - * - * @param code - * Int exit code - */ + /** For testing - trapping System.exit is impossible due to SecurityManager removal so it's just overrideable in tests. */ private[ox] def exit(exitCode: ExitCode): Unit = System.exit(exitCode.code) - /** For testing - allows to trigger shutdown hook without actually stopping the jvm. - * - * @param thread - * Thread - */ + /** For testing - allows to trigger shutdown hook without actually stopping the jvm. */ private[ox] def mountShutdownHook(thread: Thread): Unit = try Runtime.getRuntime.addShutdownHook(thread) catch case _: IllegalStateException => () - /** For testing - allows to capture the stack trace printed to the console - * - * @param t - * Throwable - */ + /** For testing - allows to capture the stack trace printed to the console */ private[ox] def printStackTrace(t: Throwable): Unit = t.printStackTrace() private[OxApp] final def handleRun(args: Vector[String])(using Ox): ExitCode = @@ -64,31 +50,26 @@ trait OxApp { ExitCode.Failure() def run(args: Vector[String])(using Ox): ExitCode +end OxApp -} - -object OxApp { - - case class AppSettings( - /** This value is returned to the operating system as the exit code when the app receives SIGINT and shuts itself down gracefully. - * Default value is `ExitCode.Success` (0). JVM itself returns code `130` when it receives `SIGINT`. - */ - gracefulShutdownExitCode: ExitCode = ExitCode.Success - ) +object OxApp: + /** @param gracefulShutdownExitCode + * This value is returned to the operating system as the exit code when the app receives SIGINT and shuts itself down gracefully. + * Default value is `ExitCode.Success` (0). JVM itself returns code `130` when it receives `SIGINT`. + */ + case class AppSettings(gracefulShutdownExitCode: ExitCode = ExitCode.Success) - object AppSettings { + object AppSettings: lazy val defaults: AppSettings = AppSettings() - } /** Simple variant of OxApp does not pass command line arguments and exits with exit code 0 if no exceptions were thrown. */ - trait Simple extends OxApp { + trait Simple extends OxApp: override final def run(args: Vector[String])(using Ox): ExitCode = run ExitCode.Success def run(using Ox): Unit - } /** WithErrorMode variant of OxApp allows to specify what kind of error handling for the main function should be used. Base trait for * integrations. @@ -98,7 +79,7 @@ object OxApp { * @tparam F * wrapper type for given ErrorMode */ - trait WithErrorMode[E, F[_]](em: ErrorMode[E, F]) extends OxApp { + trait WithErrorMode[E, F[_]](em: ErrorMode[E, F]) extends OxApp: override final def run(args: Vector[String])(using ox: Ox): ExitCode = val result = runWithErrors(args) if em.isError(result) then handleError(em.getError(result)) @@ -122,7 +103,7 @@ object OxApp { * F[ExitCode] */ def runWithErrors(args: Vector[String])(using Ox): F[ExitCode] - } + end WithErrorMode /** WithEitherErrors variant of OxApp integrates OxApp with an `either` block and allows for usage of `.ok()` combinators in the body of * the main function. @@ -130,14 +111,11 @@ object OxApp { * @tparam E * Error type */ - abstract class WithEitherErrors[E] extends WithErrorMode(EitherMode[E]()) { - + abstract class WithEitherErrors[E] extends WithErrorMode(EitherMode[E]()): type EitherError[Err] = Label[Either[Err, ExitCode]] override final def runWithErrors(args: Vector[String])(using ox: Ox): Either[E, ExitCode] = either[E, ExitCode](label ?=> run(args)(using ox, label)) def run(args: Vector[String])(using Ox, EitherError[E]): ExitCode - } - -} +end OxApp From b27f10b876bd7ad628d82bd382f46e0975d82b13 Mon Sep 17 00:00:00 2001 From: adamw Date: Tue, 9 Jul 2024 17:20:44 +0200 Subject: [PATCH 7/8] Remove default from AppSettings,gracefulShutdownExitCode --- core/src/main/scala/ox/OxApp.scala | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/main/scala/ox/OxApp.scala b/core/src/main/scala/ox/OxApp.scala index c8c39e2e..9518b5dc 100644 --- a/core/src/main/scala/ox/OxApp.scala +++ b/core/src/main/scala/ox/OxApp.scala @@ -10,7 +10,7 @@ enum ExitCode(val code: Int): trait OxApp: import OxApp.AppSettings - protected def settings: AppSettings = AppSettings.defaults + protected def settings: AppSettings = AppSettings.Default final def main(args: Array[String]): Unit = unsupervised { @@ -54,13 +54,13 @@ end OxApp object OxApp: /** @param gracefulShutdownExitCode - * This value is returned to the operating system as the exit code when the app receives SIGINT and shuts itself down gracefully. - * Default value is `ExitCode.Success` (0). JVM itself returns code `130` when it receives `SIGINT`. + * This value is returned to the operating system as the exit code when the app receives SIGINT and shuts itself down gracefully. In + * the [[AppSettings.Default]] settings, the value is `ExitCode.Success` (0). JVM itself returns code `130` when it receives `SIGINT`. */ - case class AppSettings(gracefulShutdownExitCode: ExitCode = ExitCode.Success) + case class AppSettings(gracefulShutdownExitCode: ExitCode) object AppSettings: - lazy val defaults: AppSettings = AppSettings() + lazy val Default: AppSettings = AppSettings(ExitCode.Success) /** Simple variant of OxApp does not pass command line arguments and exits with exit code 0 if no exceptions were thrown. */ From b12452513d07e2219feac0a53f426e9fe4b65e47 Mon Sep 17 00:00:00 2001 From: adamw Date: Tue, 9 Jul 2024 17:35:51 +0200 Subject: [PATCH 8/8] Docs --- core/src/main/scala/ox/OxApp.scala | 25 +++++++++--------- doc/oxapp.md | 42 ++++++++++++------------------ 2 files changed, 29 insertions(+), 38 deletions(-) diff --git a/core/src/main/scala/ox/OxApp.scala b/core/src/main/scala/ox/OxApp.scala index 9518b5dc..56d27e61 100644 --- a/core/src/main/scala/ox/OxApp.scala +++ b/core/src/main/scala/ox/OxApp.scala @@ -7,6 +7,18 @@ enum ExitCode(val code: Int): case Success extends ExitCode(0) case Failure(exitCode: Int = 1) extends ExitCode(exitCode) +/** Extend this trait when defining application entry points. Comes in several variants: + * + * - [[OxApp.Simple]] for applications which don't use command-line arguments + * - [[OxApp]] for applications which use command-line arguments + * - [[OxApp.WithEitherErrors]] to be able to unwrap `Either`s (see [[either.apply()]]) in the entry point's body. If case of failure, + * the applications ends with an error + * - [[OxApp.WithErrorMode]] to report errors (which end the application) using other [[ErrorMode]]s + * + * The benefit of using `OxApp` compared to normal `@main` methods is that application interruptions is handled properly. A fork in a scope + * is created to run the application's logic. Interrupting the application (e.g. using CTRL+C) will cause the scope to end and all forks to + * be interrupted, allowing for a clean shutdown. + */ trait OxApp: import OxApp.AppSettings @@ -85,22 +97,11 @@ object OxApp: if em.isError(result) then handleError(em.getError(result)) else ExitCode.Success - /** Allows implementor of this trait to translate an error that app finished with into a concrete ExitCode. - * - * @param e - * E Error type - * @return - * ExitCode - */ + /** Allows implementor of this trait to translate an error that app finished with into a concrete ExitCode. */ def handleError(e: E): ExitCode /** This template method is to be implemented by abstract classes that add integration for particular error handling data structure of * type F[_]. - * - * @param args - * List[String] - * @return - * F[ExitCode] */ def runWithErrors(args: Vector[String])(using Ox): F[ExitCode] end WithErrorMode diff --git a/doc/oxapp.md b/doc/oxapp.md index 425fa477..0009fec9 100644 --- a/doc/oxapp.md +++ b/doc/oxapp.md @@ -1,55 +1,50 @@ # OxApp -Ox provides a way to define application entry points in the Ox way using `OxApp` trait. Starting the app this way comes -with some useful benefits like the main `run` function being executed on a virtual thread with the root `Ox` scope provided +Ox provides a way to define application entry points in the "Ox way" using `OxApp` trait. Starting the app this way comes +with the benefit of the main `run` function being executed on a virtual thread, with a root `Ox` scope provided, and application interruption handling built-in. The latter is handled using `Runtime.addShutdownHook` and will interrupt -the main virtual thread should app receive a, for example, SIGINT due to Ctrl + C being pressed by the user. +the main virtual thread, should the app receive, for example, a SIGINT due to Ctrl+C being issued by the user. Here's an example: ```scala mdoc:compile-only import ox.* import scala.concurrent.duration.* -object MyApp extends OxApp { - def run(args: Vector[String])(using Ox): ExitCode = { +object MyApp extends OxApp: + def run(args: Vector[String])(using Ox): ExitCode = forkUser { sleep(500.millis) println("Fork finished!") } println(s"Started app with args: ${args.mkString(", ")}!") ExitCode.Success - } -} ``` The `run` function receives command line arguments as a `Vector` of `String`s, a given `Ox` capability and has to return an `ox.ExitCode` value which translates to the exit code returned from the program. `ox.ExitCode` is defined as: ```scala mdoc:compile-only -enum ExitCode(val code: Int) { +enum ExitCode(val code: Int): case Success extends ExitCode(0) case Failure(exitCode: Int = 1) extends ExitCode(exitCode) -} ``` There's also a simplified variant of `OxApp` for situations where you don't care about command line arguments. The `run` function doesn't take any arguments beyond the root `Ox` scope capability, expects no `ExitCode` and will handle any exceptions thrown by printing a stack trace and returning an exit code of `1`: - ```scala mdoc:compile-only import ox.* -object MyApp extends OxApp.Simple { +object MyApp extends OxApp.Simple: def run(using Ox): Unit = println("All done!") -} ``` `OxApp` has also a variant that integrates with [either](basics/error-handling.md#boundary-break-for-eithers) -blocks for direct-style error handling called `OxApp.WithEitherErrors[E]` where `E` is the type of errors from the +blocks for direct-style error handling called `OxApp.WithEitherErrors[E]`. Here, `E` is the type of errors from the `run` function that you want to handle. The interesting bit is that `run` function in `OxApp.WithEitherErrors` receives an `either` block token of type `EitherError[E]` (which itself is an alias for `Label[Either[E, ExitCode]]` as `either` -operates on boundary/break mechanism) and therefore it's possible to use `.ok()` combinators directly in the `run` +operates on boundary/break mechanism). Therefore, it's possible to use `.ok()` combinators directly in the `run` function scope. `OxApp.WithEitherErrors` requires that one implements a function that translates application errors into `ExitCode` instances. Here's an example that always fails and exits with exit code `23`: @@ -60,28 +55,25 @@ import ox.either.* sealed trait MyAppError case class ComputationError(msg: String) extends Exception(msg) with MyAppError -object MyApp extends OxApp.WithEitherErrors[MyAppError] { - +object MyApp extends OxApp.WithEitherErrors[MyAppError]: def doWork(): Either[MyAppError, Unit] = Left(ComputationError("oh no")) def handleError(myAppError: MyAppError): ExitCode = myAppError match { case ComputationError(_) => ExitCode.Failure(23) } - def run(args: Vector[String])(using Ox, EitherError[MyAppError]): ExitCode = { + def run(args: Vector[String])(using Ox, EitherError[MyAppError]): ExitCode = doWork().ok() // will close the scope with MyAppError as `doWork` returns a Left ExitCode.Success - } - -} ``` ## Additional configuration All `ox.OxApp` instances can be configured by overriding the `def settings: AppSettings` method. For now `AppSettings` contains only the `gracefulShutdownExitCode` setting that allows one to decide what exit code should be returned by -the application once it gracefully shutdowns after it was interrupted (for example Ctrl + C was pressed by the user). -By default `OxApp` will exit in such scenario with exit code `0` meaning successful graceful shutdown but it can be +the application once it gracefully shutdowns after it was interrupted (for example Ctrl+C was pressed by the user). + +By default `OxApp` will exit in such scenario with exit code `0` meaning successful graceful shutdown, but it can be overridden: ```scala mdoc:compile-only @@ -89,14 +81,12 @@ import ox.* import scala.concurrent.duration.* import OxApp.AppSettings -object MyApp extends OxApp { +object MyApp extends OxApp: override def settings: AppSettings = AppSettings( gracefulShutdownExitCode = ExitCode.Failure(130) ) - def run(args: Vector[String])(using Ox): ExitCode = { + def run(args: Vector[String])(using Ox): ExitCode = sleep(60.seconds) ExitCode.Success - } -} ```