JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
15.5k stars 1.12k forks source link

Can no longer handle exceptions manually at application root / globally #1764

Open haikalpribadi opened 2 years ago

haikalpribadi commented 2 years ago

Before the official 1.0.0 release, we had this exception handling logic at the root of our system (i.e. main() function), and it was working flawlessly and provided very useful information with stack-trace logging, etc.

fun main(args: Array<String>) {
    val logger = logger {}
    try {
        ...
        application { MainWindow(it) }
    } catch (exception: Exception) {
        application { ErrorWindow(exception, it) }
    } finally {
        logger.debug { Label.CLOSING_TYPEDB_STUDIO }
        exitProcess(0)
    }
}

Whenever there was an unhandled error in MainWindow() application, we're able to provide thorough information in ErrorWindow() application, decorated as we see fit. However, since the 1.0 release, Compose Desktop framework now seems to override this and just throws its own exception in its own window -- undecorated and uninformative. It only shows the 1 line error message string, which is rarely enough information.

How can we re-enable the old behaviour where we handle the exceptions ourselves at the root of the application? Is there a way to turn off the current automated error handling behaviour? I can't find any options to configure this in androidx.compose.ui.window.application() and androidx.compose.ui.window.Window(). Perhaps you can provide an option to turn it off through their method arguments?

igordmn commented 2 years ago

In 1.1.0-alpha03 you can override it:

import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.LocalWindowExceptionHandlerFactory
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowExceptionHandler
import androidx.compose.ui.window.WindowExceptionHandlerFactory
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import androidx.compose.ui.window.singleWindowApplication
import java.awt.Window
import java.awt.event.WindowEvent
import kotlin.system.exitProcess

@OptIn(ExperimentalComposeUiApi::class)
fun main() {
    var lastError: Throwable? by mutableStateOf(null)

    application(exitProcessOnExit = false) {
        CompositionLocalProvider(
            LocalWindowExceptionHandlerFactory provides object : WindowExceptionHandlerFactory {
                override fun exceptionHandler(window: Window) = WindowExceptionHandler {
                    lastError = it
                    window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))
                    throw it
                }
            }
        ) {
            Window(onCloseRequest = ::exitApplication) {
                throw RuntimeException()
            }
        }
    }

    if (lastError != null) {
        singleWindowApplication(
            state = WindowState(width = 200.dp, height = Dp.Unspecified),
            exitProcessOnExit = false
        ) {
            Text(lastError?.message ?: "Unknown error", Modifier.padding(8.dp))
        }

        exitProcess(1)
    } else {
        exitProcess(0)
    }
}

But it doesn't catch errors in application block. Catching errors in application block in pre-1.0.0 versions was "accidental" feature of which I was not aware. It seems a good feature, we will look how it can be properly implemented.

DareFox commented 2 years ago

I agree with @haikalpribadi about this. Current API with LocalWindowExceptionHandlerFactory is very bulky and old API with root-catching would be more convenient to use.

P.S. Also, @igordmn your snippet doesn't work if exceptionHandler throws error again after window closing call. The program doesn't get to the code with the error window and just terminates.

@OptIn(ExperimentalComposeUiApi::class)
fun main() {
    var lastError: Throwable? by mutableStateOf(null)

    application(exitProcessOnExit = false) {
        CompositionLocalProvider(
            LocalWindowExceptionHandlerFactory provides object : WindowExceptionHandlerFactory {
                override fun exceptionHandler(window: Window) = WindowExceptionHandler {
                    lastError = it
                    window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING))
                    // throw it
                }
            }
        ) {
            Window(onCloseRequest = ::exitApplication) {
                throw RuntimeException()
            }
        }
    }

    if (lastError != null) {
        singleWindowApplication(
            state = WindowState(width = 200.dp, height = Dp.Unspecified),
            exitProcessOnExit = false
        ) {
            Text(lastError?.message ?: "Unknown error", Modifier.padding(8.dp))
        }

        exitProcess(1)
    } else {
        exitProcess(0)
    }
}
hakanai commented 4 months ago

I've been trying to get this to work for the better part of 3 hours now, and this is my progress.

Up in my main composable:

@Composable
fun MainUi() {
    val appState = remember { AppState() }

    AppTheme(appState.themeOption) {
        BetterErrorHandling {
            MainAppContent(appState)
        }
    }
}

BetterErrorHandling is:

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun BetterErrorHandling(content: @Composable () -> Unit) {
    val errorDialogState = remember { ErrorDialogState() }

    CompositionLocalProvider(LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory {
        WindowExceptionHandler { cause ->
            errorDialogState.showError(error = cause, source = ErrorDialogState.Source.ACTION)
        }
    }) {
        content()
    }

    BetterErrorDialog(errorDialogState)
}

BetterErrorDialog is not actually a better error dialog but just a placeholder:

@Composable
internal fun BetterErrorDialog(state: ErrorDialogState) {
    if (state.isVisible) {
        Dialog(onDismissRequest = { state.dismissError() }) {
            Text(text = "*** YOU HAVE AN ERROR ***")
        }
    }
}

Despite replacing LocalWindowExceptionHandlerFactory, the replacement doesn't get used. I can breakpoint in DefaultWindowExceptionHandlerFactory and still see it getting all the attention even though I'm trying to bypass it.

hakanai commented 4 months ago

Trying again on v1.6.1 - the behaviour is slightly different to before. My exception handler is still not called, but now the default one doesn't seem to be called either.

I have three unit tests where a synthetic exception is thrown from three different possible places:

  1. Directly from the composition
  2. From a button's onClick callback
  3. From inside a coroutine spawned from a button onClick callback

All three tests seem to fail in the same way - the exception becomes the test failure. This in itself is a little interesting, because it implies the exception thrown from inside the onClick handler did get handled somewhere, but it was handled by propagating the exception into my test code, rather than by the handler I was trying to assign!

Cut down code sample

mgroth0 commented 3 months ago

I too cannot get LocalWindowExceptionHandlerFactory to work. It is simply using DefaultWindowExceptionHandlerFactory and ignoring my provided replacement.

Frank997 commented 2 months ago

Trying again on v1.6.1 - the behaviour is slightly different to before. My exception handler is still not called, but now the default one doesn't seem to be called either.

I have three unit tests where a synthetic exception is thrown from three different possible places:

  1. Directly from the composition
  2. From a button's onClick callback
  3. From inside a coroutine spawned from a button onClick callback

All three tests seem to fail in the same way - the exception becomes the test failure. This in itself is a little interesting, because it implies the exception thrown from inside the onClick handler did get handled somewhere, but it was handled by propagating the exception into my test code, rather than by the handler I was trying to assign!

Cut down code sample

@hakanai hi, maybe you can try this, its worked for me: https://stackoverflow.com/questions/76061623/how-to-restart-looper-when-exception-throwed-in-jetpack-compose

okushnikov commented 1 week ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.