Add new universal crash handlers and error reporting screen. (#5804)

* Add new crash handler and error reporting screen.

* Minor cleanup.

* Word choice.

* Rename `SafeCrashStage` to `CrashHandlingStage`.

* Reviews.

* Reference stack traces in comments for thread and postRunnable exceptions.

* Remove excessive line breaks, superfluous .apply{}.
This commit is contained in:
will-ca
2021-12-20 10:55:58 -08:00
committed by GitHub
parent c9628c7fa7
commit 723aaf779c
7 changed files with 360 additions and 14 deletions

View File

@ -34,6 +34,17 @@ See your stats breakdown!\nEnter the Overview screen (top right corner) >\nClick
Oh no! It looks like something went DISASTROUSLY wrong! This is ABSOLUTELY not supposed to happen! Please send me (yairm210@hotmail.com) an email with the game information (menu -> save game -> copy game info -> paste into email) and I'll try to fix it as fast as I can! =
Oh no! It looks like something went DISASTROUSLY wrong! This is ABSOLUTELY not supposed to happen! Please send us an report and we'll try to fix it as fast as we can! =
# Crash screen
An unrecoverable error has occurred in Unciv: =
If this keeps happening, you can try disabling mods. =
You can also report this on the issue tracker. =
Copy =
Error report copied. =
Open Issue Tracker =
Please copy the error report first. =
Close Unciv =
# Buildings
Unsellable =

View File

@ -0,0 +1,125 @@
package com.unciv
import com.badlogic.gdx.graphics.g2d.Batch
import com.badlogic.gdx.scenes.scene2d.Stage
import com.badlogic.gdx.utils.viewport.Viewport
import com.unciv.ui.utils.*
/** Stage that safely brings the game to a [CrashScreen] if any event handlers throw an exception or an error that doesn't get otherwise handled. */
class CrashHandlingStage: Stage {
constructor(): super()
constructor(viewport: Viewport): super(viewport)
constructor(viewport: Viewport, batch: Batch) : super(viewport, batch)
override fun draw() = { super.draw() }.wrapCrashHandlingUnit()()
override fun act() = { super.act() }.wrapCrashHandlingUnit()()
override fun act(delta: Float) = { super.act(delta) }.wrapCrashHandlingUnit()()
override fun touchDown(screenX: Int, screenY: Int, pointer: Int, button: Int)
= { super.touchDown(screenX, screenY, pointer, button) }.wrapCrashHandling()() ?: true
override fun touchDragged(screenX: Int, screenY: Int, pointer: Int)
= { super.touchDragged(screenX, screenY, pointer) }.wrapCrashHandling()() ?: true
override fun touchUp(screenX: Int, screenY: Int, pointer: Int, button: Int)
= { super.touchUp(screenX, screenY, pointer, button) }.wrapCrashHandling()() ?: true
override fun mouseMoved(screenX: Int, screenY: Int)
= { super.mouseMoved(screenX, screenY) }.wrapCrashHandling()() ?: true
override fun scrolled(amountX: Float, amountY: Float)
= { super.scrolled(amountX, amountY) }.wrapCrashHandling()() ?: true
override fun keyDown(keyCode: Int)
= { super.keyDown(keyCode) }.wrapCrashHandling()() ?: true
override fun keyUp(keyCode: Int)
= { super.keyUp(keyCode) }.wrapCrashHandling()() ?: true
override fun keyTyped(character: Char)
= { super.keyTyped(character) }.wrapCrashHandling()() ?: true
}
// Example Stack traces from unhandled exceptions after a button click on Desktop and on Android are below.
// Another stack trace from an exception after setting TileInfo.naturalWonder to an invalid value is below that.
// Below that are another two exceptions from a lambda given to Gdx.app.postRunnable{} and another to thread{}.
// Stage()'s event handlers seem to be the most universal place to intercept exceptions from events.
// Events and the render loop are the main ways that code gets run with GDX, right? So if we wrap both of those in exception handling, it should hopefully gracefully catch most unhandled exceptions… Threads may be the exception, hence why I put the wrapping as extension functions that can be invoked on the lambdas passed to threads.
// Button click (event):
/*
Exception in thread "main" com.badlogic.gdx.utils.GdxRuntimeException: java.lang.Exception
at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.<init>(Lwjgl3Application.java:122)
at com.unciv.app.desktop.DesktopLauncher.main(DesktopLauncher.kt:61)
Caused by: java.lang.Exception
at com.unciv.MainMenuScreen$newGameButton$1.invoke(MainMenuScreen.kt:107)
at com.unciv.MainMenuScreen$newGameButton$1.invoke(MainMenuScreen.kt:106)
at com.unciv.ui.utils.ExtensionFunctionsKt$onClick$1.invoke(ExtensionFunctions.kt:64)
at com.unciv.ui.utils.ExtensionFunctionsKt$onClick$1.invoke(ExtensionFunctions.kt:64)
at com.unciv.ui.utils.ExtensionFunctionsKt$onClickEvent$1.clicked(ExtensionFunctions.kt:57)
at com.badlogic.gdx.scenes.scene2d.utils.ClickListener.touchUp(ClickListener.java:88)
at com.badlogic.gdx.scenes.scene2d.InputListener.handle(InputListener.java:71)
at com.badlogic.gdx.scenes.scene2d.Stage.touchUp(Stage.java:355)
at com.badlogic.gdx.InputEventQueue.drain(InputEventQueue.java:70)
at com.badlogic.gdx.backends.lwjgl3.DefaultLwjgl3Input.update(DefaultLwjgl3Input.java:189)
at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Window.update(Lwjgl3Window.java:394)
at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.loop(Lwjgl3Application.java:143)
at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.<init>(Lwjgl3Application.java:116)
... 1 more
E/AndroidRuntime: FATAL EXCEPTION: GLThread 299
Process: com.unciv.app, PID: 5910
java.lang.Exception
at com.unciv.MainMenuScreen$newGameButton$1.invoke(MainMenuScreen.kt:107)
at com.unciv.MainMenuScreen$newGameButton$1.invoke(MainMenuScreen.kt:106)
at com.unciv.ui.utils.ExtensionFunctionsKt$onClick$1.invoke(ExtensionFunctions.kt:64)
at com.unciv.ui.utils.ExtensionFunctionsKt$onClick$1.invoke(ExtensionFunctions.kt:64)
at com.unciv.ui.utils.ExtensionFunctionsKt$onClickEvent$1.clicked(ExtensionFunctions.kt:57)
at com.badlogic.gdx.scenes.scene2d.utils.ClickListener.touchUp(ClickListener.java:88)
at com.badlogic.gdx.scenes.scene2d.InputListener.handle(InputListener.java:71)
at com.badlogic.gdx.scenes.scene2d.Stage.touchUp(Stage.java:355)
at com.badlogic.gdx.backends.android.DefaultAndroidInput.processEvents(DefaultAndroidInput.java:425)
at com.badlogic.gdx.backends.android.AndroidGraphics.onDrawFrame(AndroidGraphics.java:469)
at android.opengl.GLSurfaceView$GLThread.guardedRun(GLSurfaceView.java:1522)
at android.opengl.GLSurfaceView$GLThread.run(GLSurfaceView.java:1239)
*/
// Invalid Natural Wonder (rendering):
/*
Exception in thread "main" java.lang.NullPointerException
at com.unciv.logic.map.TileInfo.getNaturalWonder(TileInfo.kt:149)
at com.unciv.logic.map.TileInfo.getTileStats(TileInfo.kt:255)
at com.unciv.logic.map.TileInfo.getTileStats(TileInfo.kt:240)
at com.unciv.ui.worldscreen.bottombar.TileInfoTable.getStatsTable(TileInfoTable.kt:43)
at com.unciv.ui.worldscreen.bottombar.TileInfoTable.updateTileTable$core(TileInfoTable.kt:25)
at com.unciv.ui.worldscreen.WorldScreen.update(WorldScreen.kt:383)
at com.unciv.ui.worldscreen.WorldScreen.render(WorldScreen.kt:828)
at com.badlogic.gdx.Game.render(Game.java:46)
at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Window.update(Lwjgl3Window.java:403)
at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.loop(Lwjgl3Application.java:143)
at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.<init>(Lwjgl3Application.java:116)
at com.unciv.app.desktop.DesktopLauncher.main(DesktopLauncher.kt:61)
*/
// Thread:
/*
Exception in thread "Thread-5" java.lang.Exception
at com.unciv.MainMenuScreen$newGameButton$1$1.invoke(MainMenuScreen.kt:107)
at com.unciv.MainMenuScreen$newGameButton$1$1.invoke(MainMenuScreen.kt:107)
at kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)
*/
// Gdx.app.postRunnable:
/*
Exception in thread "main" com.badlogic.gdx.utils.GdxRuntimeException: java.lang.Exception
at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.<init>(Lwjgl3Application.java:122)
at com.unciv.app.desktop.DesktopLauncher.main(DesktopLauncher.kt:61)
Caused by: java.lang.Exception
at com.unciv.MainMenuScreen$loadGameTable$1.invoke$lambda-0(MainMenuScreen.kt:112)
at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.loop(Lwjgl3Application.java:159)
at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.<init>(Lwjgl3Application.java:116)
... 1 more
*/

View File

@ -0,0 +1,140 @@
package com.unciv
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.ui.Label
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.utils.Align
import com.unciv.models.ruleset.RulesetCache
import com.unciv.ui.utils.*
import java.io.PrintWriter
import java.io.StringWriter
/** Screen to crash to when an otherwise unhandled exception or error is thrown. */
class CrashScreen(message: String): BaseScreen() {
constructor(exception: Throwable): this(exception.stringify())
private companion object {
fun Throwable.stringify(): String {
val out = StringWriter()
this.printStackTrace(PrintWriter(out))
return out.toString()
}
}
val text = generateReportHeader() + message
var copied = false
private set
fun generateReportHeader(): String {
return """
Platform: ${Gdx.app.type}
Version: ${UncivGame.Current.version}
Rulesets: ${RulesetCache.keys}
""".trimIndent()
}
init {
println(text) // Also print to system terminal.
stage.addActor(makeLayoutTable())
}
/** @return A Table containing the layout of the whole screen. */
private fun makeLayoutTable(): Table {
val layoutTable = Table().also {
it.width = stage.width
it.height = stage.height
}
layoutTable.add(makeTitleLabel())
.padBottom(15f)
.width(stage.width)
.row()
layoutTable.add(makeErrorScroll())
.maxWidth(stage.width * 0.7f)
.maxHeight(stage.height * 0.5f)
.minHeight(stage.height * 0.2f)
.row()
layoutTable.add(makeInstructionLabel())
.padTop(15f)
.width(stage.width)
.row()
layoutTable.add(makeActionButtonsTable())
.padTop(10f)
return layoutTable
}
/** @return Label for title at top of screen. */
private fun makeTitleLabel()
= "An unrecoverable error has occurred in Unciv:".toLabel(fontSize = 24)
.apply {
wrap = true
setAlignment(Align.center)
}
/** @return Actor that displays a scrollable view of the error report text. */
private fun makeErrorScroll(): Actor {
val errorLabel = Label(text, skin).apply {
setFontSize(15)
}
val errorTable = Table()
errorTable.add(errorLabel)
.pad(10f)
return AutoScrollPane(errorTable)
.addBorder(4f, Color.DARK_GRAY)
}
/** @return Label to give the user more information and context below the error report. */
private fun makeInstructionLabel()
= "{If this keeps happening, you can try disabling mods.}\n{You can also report this on the issue tracker.}".toLabel()
.apply {
wrap = true
setAlignment(Align.center)
}
/** @return Table that displays decision buttons for the bottom of the screen. */
private fun makeActionButtonsTable(): Table {
val copyButton = "Copy".toButton()
.onClick {
Gdx.app.clipboard.contents = text
copied = true
ToastPopup(
"Error report copied.",
this@CrashScreen
)
}
val reportButton = "Open Issue Tracker".toButton(icon = "OtherIcons/Link")
.onClick {
if (copied) {
Gdx.net.openURI("https://github.com/yairm210/Unciv/issues")
} else {
ToastPopup(
"Please copy the error report first.",
this@CrashScreen
)
}
}
val closeButton = "Close Unciv".toButton()
.onClick {
Gdx.app.exit()
}
val buttonsTable = Table()
buttonsTable.add(copyButton)
.pad(10f)
buttonsTable.add(reportButton)
.pad(10f)
.also {
if (isCrampedPortrait()) {
it.row()
buttonsTable.add()
}
}
buttonsTable.add(closeButton)
.pad(10f)
return buttonsTable
}
}

View File

@ -22,6 +22,8 @@ import com.unciv.ui.worldscreen.WorldScreen
import java.util.*
import kotlin.concurrent.thread
class UncivGame(parameters: UncivGameParameters) : Game() {
// we need this secondary constructor because Java code for iOS can't handle Kotlin lambda parameters
constructor(version: String) : this(UncivGameParameters(version, null))
@ -62,6 +64,10 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
var isInitialized = false
/** A wrapped render() method that crashes to [CrashScreen] on a unhandled exception or error. */
private val wrappedCrashHandlingRender = { super.render() }.wrapCrashHandlingUnit()
// Stored here because I imagine that might be slightly faster than allocating for a new lambda every time, and the render loop is possibly one of the only places where that could have a significant impact.
val translations = Translations()
@ -167,6 +173,8 @@ class UncivGame(parameters: UncivGameParameters) : Game() {
screen.resize(width, height)
}
override fun render() = wrappedCrashHandlingRender()
override fun dispose() {
cancelDiscordEvent?.invoke()
Sounds.clearCache()

View File

@ -11,6 +11,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.*
import com.badlogic.gdx.scenes.scene2d.utils.Drawable
import com.badlogic.gdx.utils.viewport.ExtendViewport
import com.unciv.MainMenuScreen
import com.unciv.CrashHandlingStage
import com.unciv.UncivGame
import com.unciv.models.Tutorial
import com.unciv.ui.tutorials.TutorialController
@ -32,7 +33,7 @@ open class BaseScreen : Screen {
val height = resolutions[1]
/** The ExtendViewport sets the _minimum_(!) world size - the actual world size will be larger, fitted to screen/window aspect ratio. */
stage = Stage(ExtendViewport(height, height), SpriteBatch())
stage = CrashHandlingStage(ExtendViewport(height, height), SpriteBatch())
if (enableSceneDebug) {
stage.setDebugUnderMouse(true)

View File

@ -1,13 +1,13 @@
package com.unciv.ui.utils
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.Actor
import com.badlogic.gdx.scenes.scene2d.InputEvent
import com.badlogic.gdx.scenes.scene2d.Stage
import com.badlogic.gdx.scenes.scene2d.Touchable
import com.badlogic.gdx.scenes.scene2d.*
import com.badlogic.gdx.scenes.scene2d.ui.*
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
import com.unciv.CrashScreen
import com.unciv.UncivGame
import com.unciv.models.UncivSound
import com.unciv.models.translations.tr
import java.text.SimpleDateFormat
@ -93,6 +93,19 @@ fun Actor.addBorder(size:Float, color: Color, expandCell:Boolean = false): Table
return table
}
/** Wrap an [Actor] in a [Group] of a given size */
fun Actor.sizeWrapped(x: Float, y: Float): Group {
val wrapper = Group().apply {
isTransform = false // performance helper - nothing here is rotated or scaled
setSize(x, y)
}
setSize(x, y)
center(wrapper)
wrapper.addActor(this)
return wrapper
}
/** get background Image for a new separator */
private fun getSeparatorImage(color: Color) = ImageGetter.getDot(
if (color.a != 0f) color else BaseScreen.skin.get("color", Color::class.java) //0x334d80
@ -184,6 +197,21 @@ fun Float.toPercent() = 1 + this/100
/** Translate a [String] and make a [TextButton] widget from it */
fun String.toTextButton() = TextButton(this.tr(), BaseScreen.skin)
/** Translate a [String] and make a [Button] widget from it, with control over font size, font colour, and an optional icon. */
fun String.toButton(fontColor: Color = Color.WHITE, fontSize: Int = 24, icon: String? = null): Button {
val button = Button(BaseScreen.skin)
if (icon != null) {
val size = fontSize.toFloat()
button.add(
ImageGetter.getImage(icon).sizeWrapped(size, size)
).padRight(size / 3)
}
button.add(
this.toLabel(fontColor, fontSize)
)
return button
}
/** Translate a [String] and make a [Label] widget from it */
fun String.toLabel() = Label(this.tr(), BaseScreen.skin)
/** Make a [Label] widget containing this [Int] as text */
@ -284,3 +312,43 @@ object UncivDateFormat {
*/
fun String.parseDate(): Date = utcFormat.parse(this)
}
/**
* Returns a wrapped version of a function that safely crashes the game to [CrashScreen] if an exception or error is thrown.
*
* In case an exception or error is thrown, the return will be null. Therefore the return type is always nullable.
*
* @param postToMainThread Whether the [CrashScreen] should be opened by posting a runnable to the main thread, instead of directly. Set this to true if the function is going to run on any thread other than the main loop.
* @return Result from the function, or null if an exception is thrown.
* */
fun <R> (() -> R).wrapCrashHandling(
postToMainThread: Boolean = false
): () -> R?
= {
try {
this()
} catch (e: Throwable) {
if (postToMainThread) {
Gdx.app.postRunnable {
UncivGame.Current.setScreen(CrashScreen(e))
}
} else {
UncivGame.Current.setScreen(CrashScreen(e))
}
null
}
}
/**
* Returns a wrapped a version of a Unit-returning function which safely crashes the game to [CrashScreen] if an exception or error is thrown.
*
* @param postToMainThread Whether the [CrashScreen] should be opened by posting a runnable to the main thread, instead of directly. Set this to true if the function is going to run on any thread other than the main loop.
* */
fun (() -> Unit).wrapCrashHandlingUnit(
postToMainThread: Boolean = false
): () -> Unit {
val wrappedReturning = this.wrapCrashHandling(postToMainThread)
// Don't instantiate a new lambda every time.
return { wrappedReturning() ?: Unit }
}

View File

@ -238,15 +238,8 @@ class TabbedPager(
name = caption // enable finding pages by untranslated caption without needing our own field
if (icon != null) {
if (iconSize != 0f) {
val wrapper = Group().apply {
isTransform =
false // performance helper - nothing here is rotated or scaled
setSize(iconSize, iconSize)
icon.setSize(iconSize, iconSize)
icon.center(this)
addActor(icon)
}
add(wrapper).padRight(headerPadding * 0.5f)
add(icon.sizeWrapped(iconSize, iconSize))
.padRight(headerPadding * 0.5f)
} else {
add(icon)
}