softwaremill / ox

Safe direct style concurrency and resiliency for Scala on the JVM
https://ox.softwaremill.com
Apache License 2.0
338 stars 23 forks source link

Proposal: provide an `OxApp` trait as main entry point for app #152

Open lbialy opened 3 weeks ago

lbialy commented 3 weeks ago

One things that would improve usability of Ox would be a way to define an entry point to the application which would start user's program on a separate virtual thread, block the main thread until user's program finish and translate any errors into app exit with an error code. Moreover, it could handle signals too so that it would be possible to interrupt user's code on SIGTERM as #131 proposes. I guess some inspiration for signal handling could be borrowed from CE: https://github.com/typelevel/cats-effect/blob/1e445fb1750fdf3878f85f9a1bccab7720ce9817/core/jvm/src/main/java/cats/effect/Signal.java

But on JVM CE seems to just use addShutdownHook: https://github.com/typelevel/cats-effect/blob/1e445fb1750fdf3878f85f9a1bccab7720ce9817/core/jvm/src/main/scala/cats/effect/IOApp.scala#L502C1-L511C6

I have built a small draft for myself here:

trait OxApp:
  def main(args: Array[String]): Unit =
    supervised:
      val forkedMain = fork(supervised(run(args.toVector)))
      forkedMain.joinEither() match
        case Left(err) => throw err
        case Right(userEither) =>
          userEither match
            case Left(err) => throw err
            case Right(()) => System.exit(0)

  def run(args: Vector[String])(using Ox): Either[Throwable, Unit]

It's extremely limited, only thing it does is: a) run user's run method on a forked virtual thread with a supervised scope so that user can fork off the main "fiber" b) handle both thrown and returned errors (expected return type from user is Either[Throwable, Unit])

It could, for example, open an either block for the user in run so that ox.either.ok() combinator can be used in the main method. It would, however, make the run signature a bit more cumbersome due to (using Label[Either[Throwable, Unit]], Ox).

lbialy commented 3 weeks ago

Tbh handling signals with interruption seems kinda easy:

//> using scala 3.3.3
//> using dep com.softwaremill.ox::core:0.2.1

import ox.*
import java.lang.Runtime

trait OxApp:
  def main(args: Array[String]): Unit =
    val rt = Runtime.getRuntime()

    unsupervised:
      val cancellableMainFork = forkCancellable(supervised(run(args.toVector)))

      rt.addShutdownHook:
        new Thread(() => {
          println()
          println("shutting down")
          cancellableMainFork.cancel()
          println("interrupted the main fork")
        })

      cancellableMainFork.joinEither() match
        case Left(iex: InterruptedException) => System.exit(0)
        case Left(err) => throw err
        case Right(userEither) =>
          userEither match
            case Left(err) => throw err
            case Right(()) => System.exit(0)

  def run(args: Vector[String])(using Ox): Either[Throwable, Unit] = run

  def run(using Ox): Either[Throwable, Unit]

object Main extends OxApp:
  def run(using Ox): Either[Throwable, Unit] = either:
    try
      Thread.sleep(20000)
      println("what's up?")
    catch
      case iex: InterruptedException =>
        println("main got interrupted")
        throw iex
adamw commented 3 weeks ago

Ah, this would solve #131 - great idea! Let's do it :) I'm not convinced about the def run(using Ox): Either[Throwable, Unit] (specifically, returning an Either - I'd rather say any logical - type-safe errors - should be handled in the app). So maybe we can go with simply the shutdown hook + OxApp + docs for now?

lbialy commented 3 weeks ago

I was personally interested in finding a set of idioms that allow me to program without lying in signatures (so without throwing exceptions other than flow control). If you make it return Unit or ExitCode user using either block in main function will be forced to patmat+throw. Maybe multiple variants should be available? OxEitherApp? Maybe different methods available for override (this is a bit shitty though because there's no way to force user to override just one)?

On Mon 10. Jun 2024 at 10:07, Adam Warski @.***> wrote:

Ah, this would solve #131 https://github.com/softwaremill/ox/issues/131

  • great idea! Let's do it :) I'm not convinced about the def run(using Ox): Either[Throwable, Unit] (specifically, returning an Either - I'd rather say any logical - type-safe errors - should be handled in the app). So maybe we can go with simply the shutdown hook + OxApp + docs for now?

— Reply to this email directly, view it on GitHub https://github.com/softwaremill/ox/issues/152#issuecomment-2157619533, or unsubscribe https://github.com/notifications/unsubscribe-auth/ACBVNUQNMSMY23CLEC7P5GLZGVNFBAVCNFSM6AAAAABJAZBGKOVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDCNJXGYYTSNJTGM . You are receiving this because you authored the thread.Message ID: @.***>

adamw commented 3 weeks ago

I see, though I don't think we'll be able to escape from exceptions. Any I/O method can throw exceptions, do you have an idea how to handle this?

(I'm all for not lying in signatures, though :) And I like ZIO's concept of untyped defects, which in our case are represented as exceptions)

But even if we don't lie in signatures, if the main returns an either, this means that some errors are unhandled by the app? So at the top-level, if any error is unhandled - it's a bug - hence should be thrown as an exception, no?

lbialy commented 3 weeks ago

Hmm, it's the same with IOApp, right? They expect your final IO to be successful and contain an ExitCode (meaning you translated any logical expected errors to a meaningful exit code no and handled informing the user) but if it's a failed IO they just recover to exit code 1 and print stack trace, I think. Ok, so maybe mirror CE:

trait OxApp: def run(args: Vector[String]): ExitCode

object OxApp: trait Simple extends OxApp: def run(args: Vector[String]): Unit

and then we joinEither anyway, handle any exception thrown on main fiber and map it to exit code as recovery? For now there's no need to worry about cross-platform code at least.

I think I'd still prefer my entry point to allow me to use ox.either combinators but if these traits can be extended, it's not a big deal.

On Mon 10. Jun 2024 at 10:23, Adam Warski @.***> wrote:

I see, though I don't think we'll be able to escape from exceptions. Any I/O method can throw exceptions, do you have an idea how to handle this?

(I'm all for not lying in signatures, though :) And I like ZIO's concept of untyped defects, which in our case are represented as exceptions)

But even if we don't lie in signatures, if the main returns an either, this means that some errors are unhandled by the app? So at the top-level, if any error is unhandled - it's a bug - hence should be thrown as an exception, no?

— Reply to this email directly, view it on GitHub https://github.com/softwaremill/ox/issues/152#issuecomment-2157682952, or unsubscribe https://github.com/notifications/unsubscribe-auth/ACBVNUTZOPUW27AFDHHS7RDZGVO6ZAVCNFSM6AAAAABJAZBGKOVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDCNJXGY4DEOJVGI . You are receiving this because you authored the thread.Message ID: @.***>

adamw commented 3 weeks ago

Or going the ZIOApp route you could return an Either[Any, Any] and printout any error that occurs to stdout - not necessarily exceptions only.

So this would bring the variants to 3 ("simple", "regular" and "either"). I'd prefer to have a single variant, but maybe that's essential, not accidental complexity.

lbialy commented 3 weeks ago

Either[Any, Any] sounds like very bad. trait OxEitherApp[E, A]: def handleError(e: E): ExitCode def handleResult(a: A): ExitCode def run(args: Vector[String])(using Label[Either[E, A]], Ox): Either[E, A]

is an option but it's quite complex. But it's would probably be best for my needs tbh.

On Mon 10. Jun 2024 at 10:39, Adam Warski @.***> wrote:

Or going the ZIOApp route you could return an Either[Any, Any] and printout any error that occurs to stdout - not necessarily exceptions only.

So this would bring the variants to 3 ("simple", "regular" and "either"). I'd prefer to have a single variant, but maybe that's essential, not accidental complexity.

— Reply to this email directly, view it on GitHub https://github.com/softwaremill/ox/issues/152#issuecomment-2157719031, or unsubscribe https://github.com/notifications/unsubscribe-auth/ACBVNUVGZSYGMTSDE2RSWDLZGVQ25AVCNFSM6AAAAABJAZBGKOVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDCNJXG4YTSMBTGE . You are receiving this because you authored the thread.Message ID: @.***>

adamw commented 2 weeks ago

So we could have:

trait OxApp:
  def run(using Ox): Unit

  trait WithExitCode:
    def run(using Ox): ExitCode

  trait WithErrors[E]:
    def run(using Ox, Label[Either[E, ExitCode]]): Either[E, ExitCode]

then?

lbialy commented 2 weeks ago

yeah, this would be very nice!

lbialy commented 2 weeks ago

Would you like a PR for that or are you already working on it?

adamw commented 2 weeks ago

@lbialy a PR would be great, I'm back to "normal" work only next week

lbialy commented 1 week ago

implemented in https://github.com/softwaremill/ox/pull/157