compose-multiplatform icon indicating copy to clipboard operation
compose-multiplatform copied to clipboard

Can no longer handle exceptions manually at application root / globally

Open haikalpribadi opened this issue 4 years ago • 6 comments

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?

haikalpribadi avatar Jan 29 '22 13:01 haikalpribadi

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.

igordmn avatar Feb 04 '22 09:02 igordmn

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)
    }
}

DareFox avatar Jul 04 '22 20:07 DareFox

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 avatar Mar 06 '24 10:03 hakanai

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 avatar Mar 16 '24 22:03 hakanai

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

mgroth0 avatar Apr 01 '24 21:04 mgroth0