From 9bd87507394afe99fab14e2037c1c82b3736b516 Mon Sep 17 00:00:00 2001 From: Timo T Date: Sat, 11 Jun 2022 23:13:49 +0200 Subject: [PATCH] Refactor: Consistent & correct usage of coroutines (#7077) * Refactor: Consistent usage of coroutines * Add usage comments to the different threads * Refactor: Properly separate crash handling into its platform-specific parts * Fix autoSave never finishing * Correctly handle coroutines when the GL thread is not accepting runnables anymore Co-authored-by: Yair Morgenstern --- .../app/CustomFileLocationHelperAndroid.kt | 1 - .../app/PlatformSpecificHelpersAndroid.kt | 6 + core/src/com/unciv/MainMenuScreen.kt | 18 +- core/src/com/unciv/UncivGame.kt | 41 +++-- .../unciv/logic/CustomFileLocationHelper.kt | 6 +- core/src/com/unciv/logic/GameInfo.kt | 7 +- core/src/com/unciv/logic/GameSaver.kt | 27 +-- .../logic/multiplayer/OnlineMultiplayer.kt | 29 ++-- .../multiplayer/OnlineMultiplayerGame.kt | 11 +- .../com/unciv/models/simulation/Simulation.kt | 7 +- core/src/com/unciv/ui/UncivStage.kt | 92 +--------- core/src/com/unciv/ui/audio/SoundPlayer.kt | 4 +- .../ui/cityscreen/CityConstructionsTable.kt | 8 +- .../crashhandling/CrashHandlingExtensions.kt | 34 ++++ .../ui/crashhandling/CrashHandlingThread.kt | 118 ------------- .../com/unciv/ui/crashhandling/CrashScreen.kt | 12 -- .../multiplayer/AddMultiplayerGameScreen.kt | 10 +- .../EditMultiplayerGameInfoScreen.kt | 12 +- .../ui/multiplayer/MultiplayerHelpers.kt | 8 +- .../unciv/ui/newgamescreen/NewGameScreen.kt | 49 +++--- core/src/com/unciv/ui/options/AdvancedTab.kt | 12 +- core/src/com/unciv/ui/options/ModCheckTab.kt | 12 +- .../com/unciv/ui/options/MultiplayerTab.kt | 8 +- core/src/com/unciv/ui/options/SoundTab.kt | 12 +- .../ui/pickerscreens/ModManagementScreen.kt | 42 +++-- .../ui/pickerscreens/TechPickerScreen.kt | 4 +- core/src/com/unciv/ui/popup/ToastPopup.kt | 8 +- core/src/com/unciv/ui/saves/LoadGameScreen.kt | 30 ++-- .../com/unciv/ui/saves/LoadOrSaveScreen.kt | 10 +- core/src/com/unciv/ui/saves/QuickSave.kt | 28 ++-- core/src/com/unciv/ui/saves/SaveGameScreen.kt | 16 +- .../ui/saves/VerticalFileListScrollPane.kt | 8 +- .../utils/GeneralPlatformSpecificHelpers.kt | 9 + .../ui/utils/extensions/Scene2dExtensions.kt | 4 +- .../unciv/ui/worldscreen/PlayerReadyScreen.kt | 4 +- .../unciv/ui/worldscreen/WorldMapHolder.kt | 18 +- .../com/unciv/ui/worldscreen/WorldScreen.kt | 36 ++-- .../mainmenu/WorldScreenMenuPopup.kt | 2 +- .../status/MultiplayerStatusButton.kt | 14 +- .../ui/worldscreen/unit/UnitActionsTable.kt | 4 +- core/src/com/unciv/{ui => }/utils/Log.kt | 0 .../unciv/utils/concurrency/Concurrency.kt | 158 ++++++++++++++++++ .../com/unciv/app/desktop/DesktopLauncher.kt | 4 +- 43 files changed, 479 insertions(+), 464 deletions(-) create mode 100644 core/src/com/unciv/ui/crashhandling/CrashHandlingExtensions.kt delete mode 100644 core/src/com/unciv/ui/crashhandling/CrashHandlingThread.kt rename core/src/com/unciv/{ui => }/utils/Log.kt (100%) create mode 100644 core/src/com/unciv/utils/concurrency/Concurrency.kt diff --git a/android/src/com/unciv/app/CustomFileLocationHelperAndroid.kt b/android/src/com/unciv/app/CustomFileLocationHelperAndroid.kt index 100ddb2f40..6d4d2f4b6e 100644 --- a/android/src/com/unciv/app/CustomFileLocationHelperAndroid.kt +++ b/android/src/com/unciv/app/CustomFileLocationHelperAndroid.kt @@ -7,7 +7,6 @@ import android.provider.DocumentsContract import android.provider.OpenableColumns import androidx.annotation.GuardedBy import com.unciv.logic.CustomFileLocationHelper -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import java.io.InputStream import java.io.OutputStream diff --git a/android/src/com/unciv/app/PlatformSpecificHelpersAndroid.kt b/android/src/com/unciv/app/PlatformSpecificHelpersAndroid.kt index b388835ef1..4956f27432 100644 --- a/android/src/com/unciv/app/PlatformSpecificHelpersAndroid.kt +++ b/android/src/com/unciv/app/PlatformSpecificHelpersAndroid.kt @@ -3,6 +3,7 @@ package com.unciv.app import android.app.Activity import android.content.pm.ActivityInfo import com.unciv.ui.utils.GeneralPlatformSpecificHelpers +import kotlin.concurrent.thread /** See also interface [GeneralPlatformSpecificHelpers]. * @@ -35,4 +36,9 @@ Sources for Info about current orientation in case need: * External is probably on an SD-card or similar which is always accessible by the user. */ override fun shouldPreferExternalStorage(): Boolean = true + + override fun handleUncaughtThrowable(ex: Throwable): Boolean { + thread { throw ex } // this will kill the app but report the exception to the Google Play Console if the user allows it + return true + } } diff --git a/core/src/com/unciv/MainMenuScreen.kt b/core/src/com/unciv/MainMenuScreen.kt index 703348b20b..ed5eace3ac 100644 --- a/core/src/com/unciv/MainMenuScreen.kt +++ b/core/src/com/unciv/MainMenuScreen.kt @@ -17,13 +17,11 @@ import com.unciv.models.metadata.BaseRuleset import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.ruleset.RulesetCache import com.unciv.ui.civilopedia.CivilopediaScreen -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter +import com.unciv.ui.map.TileGroupMap import com.unciv.ui.mapeditor.EditorMapHolder import com.unciv.ui.mapeditor.MapEditorScreen import com.unciv.ui.multiplayer.MultiplayerScreen -import com.unciv.ui.map.TileGroupMap import com.unciv.ui.newgamescreen.NewGameScreen import com.unciv.ui.pickerscreens.ModManagementScreen import com.unciv.ui.popup.ExitGamePopup @@ -44,6 +42,8 @@ import com.unciv.ui.utils.extensions.setFontSize import com.unciv.ui.utils.extensions.surroundWithCircle import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.worldscreen.mainmenu.WorldScreenMenuPopup +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import kotlin.math.min @@ -90,7 +90,7 @@ class MainMenuScreen: BaseScreen() { // will not exist unless we reset the ruleset and images ImageGetter.ruleset = RulesetCache.getVanillaRuleset() - launchCrashHandling("ShowMapBackground") { + Concurrency.run("ShowMapBackground") { var scale = 1f var mapWidth = stage.width / TileGroupMap.groupHorizontalAdvance var mapHeight = stage.height / TileGroupMap.groupSize @@ -110,7 +110,7 @@ class MainMenuScreen: BaseScreen() { waterThreshold = -0.055f // Gives the same level as when waterThreshold was unused in MapType.default }) - postCrashHandlingRunnable { // for GL context + launchOnGLThread { // for GL context ImageGetter.setNewRuleset(mapRuleset) val mapHolder = EditorMapHolder(this@MainMenuScreen, newMap) {} mapHolder.setScale(scale) @@ -210,18 +210,18 @@ class MainMenuScreen: BaseScreen() { private fun quickstartNewGame() { ToastPopup("Working...", this) val errorText = "Cannot start game with the default new game parameters!" - launchCrashHandling("QuickStart") { + Concurrency.run("QuickStart") { val newGame: GameInfo // Can fail when starting the game... try { newGame = GameStarter.startNewGame(GameSetupInfo.fromSettings("Chieftain")) } catch (ex: Exception) { - postCrashHandlingRunnable { ToastPopup(errorText, this@MainMenuScreen) } - return@launchCrashHandling + launchOnGLThread { ToastPopup(errorText, this@MainMenuScreen) } + return@run } // ...or when loading the game - postCrashHandlingRunnable { + launchOnGLThread { try { game.loadGame(newGame) } catch (outOfMemory: OutOfMemoryError) { diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 864322018b..fd5d2c7872 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -20,9 +20,7 @@ import com.unciv.ui.audio.GameSounds import com.unciv.ui.audio.MusicController import com.unciv.ui.audio.MusicMood import com.unciv.ui.audio.SoundPlayer -import com.unciv.ui.crashhandling.closeExecutors -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable +import com.unciv.ui.crashhandling.CrashScreen import com.unciv.ui.crashhandling.wrapCrashHandlingUnit import com.unciv.ui.images.ImageGetter import com.unciv.ui.multiplayer.LoadDeepLinkScreen @@ -32,8 +30,10 @@ import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.extensions.center import com.unciv.ui.worldscreen.PlayerReadyScreen import com.unciv.ui.worldscreen.WorldScreen +import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import com.unciv.utils.debug -import kotlinx.coroutines.runBlocking import java.util.* class UncivGame(parameters: UncivGameParameters) : Game() { @@ -123,7 +123,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { Gdx.graphics.isContinuousRendering = settings.continuousRendering - launchCrashHandling("LoadJSON") { + Concurrency.run("LoadJSON") { RulesetCache.loadRulesets() translations.tryReadTranslationForCurrentLanguage() translations.loadPercentageCompleteOfLanguages() @@ -135,7 +135,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { } // This stuff needs to run on the main thread because it needs the GL context - postCrashHandlingRunnable { + launchOnGLThread { musicController.chooseTrack(suffix = MusicMood.Menu) ImageGetter.ruleset = RulesetCache.getVanillaRuleset() // so that we can enter the map editor without having to load a game first @@ -213,16 +213,16 @@ class UncivGame(parameters: UncivGameParameters) : Game() { setScreen(worldScreen!!) } - private fun tryLoadDeepLinkedGame() = launchCrashHandling("LoadDeepLinkedGame") { - if (deepLinkedMultiplayerGame == null) return@launchCrashHandling + private fun tryLoadDeepLinkedGame() = Concurrency.run("LoadDeepLinkedGame") { + if (deepLinkedMultiplayerGame == null) return@run - postCrashHandlingRunnable { + launchOnGLThread { setScreen(LoadDeepLinkScreen()) } try { onlineMultiplayer.loadGame(deepLinkedMultiplayerGame!!) } catch (ex: Exception) { - postCrashHandlingRunnable { + launchOnGLThread { val mainMenu = MainMenuScreen() setScreen(mainMenu) val popup = Popup(mainMenu) @@ -250,7 +250,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { override fun pause() { val curGameInfo = gameInfo - if (curGameInfo != null) gameSaver.autoSave(curGameInfo) + if (curGameInfo != null) gameSaver.requestAutoSave(curGameInfo) musicController.pause() super.pause() } @@ -261,13 +261,12 @@ class UncivGame(parameters: UncivGameParameters) : Game() { override fun render() = wrappedCrashHandlingRender() - override fun dispose() = runBlocking { + override fun dispose() { Gdx.input.inputProcessor = null // don't allow ANRs when shutting down, that's silly cancelDiscordEvent?.invoke() SoundPlayer.clearCache() if (::musicController.isInitialized) musicController.gracefulShutdown() // Do allow fade-out - closeExecutors() val curGameInfo = gameInfo if (curGameInfo != null) { @@ -275,12 +274,15 @@ class UncivGame(parameters: UncivGameParameters) : Game() { if (autoSaveJob != null && autoSaveJob.isActive) { // auto save is already in progress (e.g. started by onPause() event) // let's allow it to finish and do not try to autosave second time - autoSaveJob.join() + Concurrency.runBlocking { + autoSaveJob.join() + } } else { - gameSaver.autoSaveSingleThreaded(curGameInfo) // NO new thread + gameSaver.autoSave(curGameInfo) // NO new thread } } settings.save() + Concurrency.stopThreadPools() // On desktop this should only be this one and "DestroyJavaVM" logRunningThreads() @@ -295,6 +297,15 @@ class UncivGame(parameters: UncivGameParameters) : Game() { } } + /** Handles an uncaught exception or error. First attempts a platform-specific handler, and if that didn't handle the exception or error, brings the game to a [CrashScreen]. */ + fun handleUncaughtThrowable(ex: Throwable) { + Log.error("Uncaught throwable", ex) + if (platformSpecificHelper?.handleUncaughtThrowable(ex) == true) return + Gdx.app.postRunnable { + setScreen(CrashScreen(ex)) + } + } + /** Returns the [worldScreen] if it is the currently active screen of the game */ fun getWorldScreenIfActive(): WorldScreen? { return if (screen == worldScreen) worldScreen else null diff --git a/core/src/com/unciv/logic/CustomFileLocationHelper.kt b/core/src/com/unciv/logic/CustomFileLocationHelper.kt index 39fd62a078..9d4b2b4917 100644 --- a/core/src/com/unciv/logic/CustomFileLocationHelper.kt +++ b/core/src/com/unciv/logic/CustomFileLocationHelper.kt @@ -2,7 +2,7 @@ package com.unciv.logic import com.unciv.logic.GameSaver.CustomLoadResult import com.unciv.logic.GameSaver.CustomSaveResult -import com.unciv.ui.crashhandling.postCrashHandlingRunnable +import com.unciv.utils.concurrency.Concurrency import java.io.InputStream import java.io.OutputStream @@ -82,14 +82,14 @@ private fun callLoadCallback(loadCompleteCallback: (CustomLoadResult) -> } else { CustomLoadResult(null, exception) } - postCrashHandlingRunnable { + Concurrency.runOnGLThread { loadCompleteCallback(result) } } private fun callSaveCallback(saveCompleteCallback: (CustomSaveResult) -> Unit, location: String? = null, exception: Exception? = null) { - postCrashHandlingRunnable { + Concurrency.runOnGLThread { saveCompleteCallback(CustomSaveResult(location, exception)) } } diff --git a/core/src/com/unciv/logic/GameInfo.kt b/core/src/com/unciv/logic/GameInfo.kt index 7f90496ed0..cd437eae74 100644 --- a/core/src/com/unciv/logic/GameInfo.kt +++ b/core/src/com/unciv/logic/GameInfo.kt @@ -42,11 +42,8 @@ class GameInfo { // Set to false whenever the results still need te be processed var diplomaticVictoryVotesProcessed = false - /**Keep track of a custom location this game was saved to _or_ loaded from - * - * Note this was used as silent autosave destination, but it was decided (#3898) to - * make the custom location feature a one-shot import/export kind of operation. - * The tracking is left in place, however [GameSaver.autoSaveSingleThreaded] no longer uses it + /** + * Keep track of a custom location this game was saved to _or_ loaded from, using it as the default custom location for any further save/load attempts. */ @Volatile var customSaveLocation: String? = null diff --git a/core/src/com/unciv/logic/GameSaver.kt b/core/src/com/unciv/logic/GameSaver.kt index 47cf41642e..83bab4f28a 100644 --- a/core/src/com/unciv/logic/GameSaver.kt +++ b/core/src/com/unciv/logic/GameSaver.kt @@ -10,9 +10,8 @@ import com.unciv.json.json import com.unciv.models.metadata.GameSettings import com.unciv.models.metadata.doMigrations import com.unciv.models.metadata.isMigrationNecessary -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.saves.Gzip +import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.Log import com.unciv.utils.debug import kotlinx.coroutines.Job @@ -191,7 +190,7 @@ class GameSaver( val gameData = try { gameInfoToString(game) } catch (ex: Exception) { - postCrashHandlingRunnable { saveCompletionCallback(CustomSaveResult(exception = ex)) } + Concurrency.runOnGLThread { saveCompletionCallback(CustomSaveResult(exception = ex)) } return } debug("Saving GameInfo %s to custom location %s", game.gameId, saveLocation) @@ -351,25 +350,27 @@ class GameSaver( //region Autosave /** - * Runs autoSave + * Auto-saves a snapshot of the [gameInfo] in a new thread. */ - fun autoSave(gameInfo: GameInfo, postRunnable: () -> Unit = {}) { + fun requestAutoSave(gameInfo: GameInfo): Job { // The save takes a long time (up to a few seconds on large games!) and we can do it while the player continues his game. // On the other hand if we alter the game data while it's being serialized we could get a concurrent modification exception. // So what we do is we clone all the game data and serialize the clone. - autoSaveUnCloned(gameInfo.clone(), postRunnable) + return requestAutoSaveUnCloned(gameInfo.clone()) } - fun autoSaveUnCloned(gameInfo: GameInfo, postRunnable: () -> Unit = {}) { - // This is used when returning from WorldScreen to MainMenuScreen - no clone since UI access to it should be gone - autoSaveJob = launchCrashHandling(AUTOSAVE_FILE_NAME) { - autoSaveSingleThreaded(gameInfo) - // do this on main thread - postCrashHandlingRunnable ( postRunnable ) + /** + * In a new thread, auto-saves the [gameInfo] directly - only use this with [GameInfo] objects that are guaranteed not to be changed while the autosave is in progress! + */ + fun requestAutoSaveUnCloned(gameInfo: GameInfo): Job { + val job = Concurrency.run("autoSaveUnCloned") { + autoSave(gameInfo) } + autoSaveJob = job + return job } - fun autoSaveSingleThreaded(gameInfo: GameInfo) { + fun autoSave(gameInfo: GameInfo) { try { saveGame(gameInfo, AUTOSAVE_FILE_NAME) } catch (oom: OutOfMemoryError) { diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt index 2af8fa71f8..c4e6f30729 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayer.kt @@ -9,15 +9,16 @@ import com.unciv.logic.civilization.PlayerType import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver -import com.unciv.models.metadata.GameSettings -import com.unciv.ui.crashhandling.CRASH_HANDLING_DAEMON_SCOPE -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.utils.extensions.isLargerThan +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.Dispatcher +import com.unciv.utils.concurrency.launchOnGLThread +import com.unciv.utils.concurrency.launchOnThreadPool +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.launch import java.io.FileNotFoundException import java.time.Duration import java.time.Instant @@ -63,7 +64,7 @@ class OnlineMultiplayer { val doNotUpdate = if (currentGame == null) listOf() else listOf(currentGame) throttle(lastAllGamesRefresh, multiplayerSettings.allGameRefreshDelay, {}) { requestUpdate(doNotUpdate = doNotUpdate) } } - }.launchIn(CRASH_HANDLING_DAEMON_SCOPE) + }.launchIn(CoroutineScope(Dispatcher.DAEMON)) } private fun getCurrentGame(): OnlineMultiplayerGame? { @@ -83,14 +84,14 @@ class OnlineMultiplayer { * Fires: [MultiplayerGameUpdateStarted], [MultiplayerGameUpdated], [MultiplayerGameUpdateUnchanged], [MultiplayerGameUpdateFailed] */ fun requestUpdate(forceUpdate: Boolean = false, doNotUpdate: List = listOf()) { - launchCrashHandling("Update all multiplayer games") { + Concurrency.run("Update all multiplayer games") { val fileThrottleInterval = if (forceUpdate) Duration.ZERO else FILE_UPDATE_THROTTLE_PERIOD // An exception only happens here if the files can't be listed, should basically never happen throttle(lastFileUpdate, fileThrottleInterval, {}, action = ::updateSavesFromFiles) for (game in savedGames.values) { if (game in doNotUpdate) continue - launch { + launchOnThreadPool { game.requestUpdate(forceUpdate) } } @@ -155,7 +156,7 @@ class OnlineMultiplayer { private fun addGame(fileHandle: FileHandle, preview: GameInfoPreview = gameSaver.loadGamePreviewFromFile(fileHandle)) { val game = OnlineMultiplayerGame(fileHandle, preview, Instant.now()) savedGames[fileHandle] = game - postCrashHandlingRunnable { EventBus.send(MultiplayerGameAdded(game.name)) } + Concurrency.runOnGLThread { EventBus.send(MultiplayerGameAdded(game.name)) } } fun getGameByName(name: String): OnlineMultiplayerGame? { @@ -219,7 +220,7 @@ class OnlineMultiplayer { * @throws FileStorageRateLimitReached if the file storage backend can't handle any additional actions for a time * @throws FileNotFoundException if the file can't be found */ - suspend fun loadGame(gameId: String) { + suspend fun loadGame(gameId: String) = coroutineScope { val gameInfo = downloadGame(gameId) val preview = gameInfo.asPreview() val onlineGame = getGameByGameId(gameId) @@ -229,18 +230,18 @@ class OnlineMultiplayer { } else if (onlinePreview != null && hasNewerGameState(preview, onlinePreview)){ onlineGame.doManualUpdate(preview) } - postCrashHandlingRunnable { UncivGame.Current.loadGame(gameInfo) } + launchOnGLThread { UncivGame.Current.loadGame(gameInfo) } } /** * Checks if the given game is current and loads it, otherwise loads the game from the server */ - suspend fun loadGame(gameInfo: GameInfo) { + suspend fun loadGame(gameInfo: GameInfo) = coroutineScope { val gameId = gameInfo.gameId val preview = onlineGameSaver.tryDownloadGamePreview(gameId) if (hasLatestGameState(gameInfo, preview)) { gameInfo.isUpToDate = true - postCrashHandlingRunnable { UncivGame.Current.loadGame(gameInfo) } + launchOnGLThread { UncivGame.Current.loadGame(gameInfo) } } else { loadGame(gameId) } @@ -272,7 +273,7 @@ class OnlineMultiplayer { if (game == null) return savedGames.remove(game.fileHandle) - postCrashHandlingRunnable { EventBus.send(MultiplayerGameDeleted(game.name)) } + Concurrency.runOnGLThread { EventBus.send(MultiplayerGameDeleted(game.name)) } } /** diff --git a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt index d828f49325..ccfcc60d6b 100644 --- a/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt +++ b/core/src/com/unciv/logic/multiplayer/OnlineMultiplayerGame.kt @@ -1,14 +1,13 @@ package com.unciv.logic.multiplayer import com.badlogic.gdx.files.FileHandle -import com.unciv.Constants import com.unciv.UncivGame import com.unciv.logic.GameInfoPreview import com.unciv.logic.event.EventBus import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.logic.multiplayer.storage.OnlineMultiplayerGameSaver -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.utils.extensions.isLargerThan +import com.unciv.utils.concurrency.Concurrency import java.io.FileNotFoundException import java.time.Duration import java.time.Instant @@ -69,7 +68,9 @@ class OnlineMultiplayerGame( error = e GameUpdateResult.FAILURE } - postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdateStarted(name)) } + Concurrency.runOnGLThread { + EventBus.send(MultiplayerGameUpdateStarted(name)) + } val throttleInterval = if (forceUpdate) Duration.ZERO else getUpdateThrottleInterval() val updateResult = if (forceUpdate || needsUpdate()) { attemptAction(lastOnlineUpdate, onUnchanged, onError, ::update) @@ -85,7 +86,7 @@ class OnlineMultiplayerGame( GameUpdateResult.FAILURE -> MultiplayerGameUpdateFailed(name, error!!) GameUpdateResult.UNCHANGED -> MultiplayerGameUpdateUnchanged(name, preview!!) } - postCrashHandlingRunnable { EventBus.send(updateEvent) } + Concurrency.runOnGLThread { EventBus.send(updateEvent) } } private suspend fun update(): GameUpdateResult { @@ -101,7 +102,7 @@ class OnlineMultiplayerGame( lastOnlineUpdate.set(Instant.now()) error = null preview = gameInfo - postCrashHandlingRunnable { EventBus.send(MultiplayerGameUpdated(name, gameInfo)) } + Concurrency.runOnGLThread { EventBus.send(MultiplayerGameUpdated(name, gameInfo)) } } override fun equals(other: Any?): Boolean = other is OnlineMultiplayerGame && fileHandle == other.fileHandle diff --git a/core/src/com/unciv/models/simulation/Simulation.kt b/core/src/com/unciv/models/simulation/Simulation.kt index 5f91907a29..86a4f487c0 100644 --- a/core/src/com/unciv/models/simulation/Simulation.kt +++ b/core/src/com/unciv/models/simulation/Simulation.kt @@ -5,11 +5,12 @@ import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameStarter import com.unciv.models.metadata.GameSetupInfo -import com.unciv.ui.crashhandling.launchCrashHandling +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlin.time.Duration import kotlin.math.max +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime @@ -48,7 +49,7 @@ class Simulation( startTime = System.currentTimeMillis() val jobs: ArrayList = ArrayList() for (threadId in 1..threadsNumber) { - jobs.add(launchCrashHandling("simulation-${threadId}") { + jobs.add(launch(CoroutineName("simulation-${threadId}")) { for (i in 1..simulationsPerThread) { val gameInfo = GameStarter.startNewGame(GameSetupInfo(newGameInfo)) gameInfo.simulateMaxTurns = maxTurns diff --git a/core/src/com/unciv/ui/UncivStage.kt b/core/src/com/unciv/ui/UncivStage.kt index 06a1944346..2326055cb8 100644 --- a/core/src/com/unciv/ui/UncivStage.kt +++ b/core/src/com/unciv/ui/UncivStage.kt @@ -8,7 +8,7 @@ import com.unciv.ui.crashhandling.wrapCrashHandling import com.unciv.ui.crashhandling.wrapCrashHandlingUnit -/** Main stage for the game. Safely brings the game to a [CrashScreen] if any event handlers throw an exception or an error that doesn't get otherwise handled. */ +/** Main stage for the game. Catches all exceptions or errors thrown by event handlers, calling [com.unciv.UncivGame.handleUncaughtThrowable] with the thrown exception or error. */ class UncivStage(viewport: Viewport, batch: Batch) : Stage(viewport, batch) { /** @@ -61,93 +61,3 @@ class UncivStage(viewport: Viewport, batch: Batch) : Stage(viewport, batch) { { 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 thread{} and another given to Gdx.app.postRunnable{}. - -// 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, as in crashHandlingThread and postCrashHandlingRunnable. - - -// Button click (event): - -/* -Exception in thread "main" com.badlogic.gdx.utils.GdxRuntimeException: java.lang.Exception - at com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application.(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.(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.(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.(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.(Lwjgl3Application.java:116) - ... 1 more - */ diff --git a/core/src/com/unciv/ui/audio/SoundPlayer.kt b/core/src/com/unciv/ui/audio/SoundPlayer.kt index cd533797fd..df25ab200a 100644 --- a/core/src/com/unciv/ui/audio/SoundPlayer.kt +++ b/core/src/com/unciv/ui/audio/SoundPlayer.kt @@ -6,7 +6,7 @@ import com.badlogic.gdx.audio.Sound import com.badlogic.gdx.files.FileHandle import com.unciv.UncivGame import com.unciv.models.UncivSound -import com.unciv.ui.crashhandling.launchCrashHandling +import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.debug import kotlinx.coroutines.delay import java.io.File @@ -171,7 +171,7 @@ object SoundPlayer { val initialDelay = if (isFresh && Gdx.app.type == Application.ApplicationType.Android) 40 else 0 if (initialDelay > 0 || resource.play(volume) == -1L) { - launchCrashHandling("DelayedSound") { + Concurrency.run("DelayedSound") { delay(initialDelay.toLong()) while (resource.play(volume) == -1L) { delay(20L) diff --git a/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt b/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt index 66a4e070d9..0a6beac103 100644 --- a/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt +++ b/core/src/com/unciv/ui/cityscreen/CityConstructionsTable.kt @@ -21,8 +21,6 @@ import com.unciv.models.ruleset.unit.BaseUnit import com.unciv.models.stats.Stat import com.unciv.models.translations.tr import com.unciv.ui.audio.SoundPlayer -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.popup.Popup import com.unciv.ui.popup.YesNoPopup @@ -43,6 +41,8 @@ import com.unciv.ui.utils.extensions.packIfNeeded import com.unciv.ui.utils.extensions.surroundWithCircle import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import kotlin.math.max import kotlin.math.min import com.unciv.ui.utils.AutoScrollPane as ScrollPane @@ -225,10 +225,10 @@ class CityConstructionsTable(private val cityScreen: CityScreen) { availableConstructionsTable.add(Constants.loading.toLabel()).pad(10f) } - launchCrashHandling("Construction info gathering - ${cityScreen.city.name}") { + Concurrency.run("Construction info gathering - ${cityScreen.city.name}") { // Since this can be a heavy operation and leads to many ANRs on older phones we put the metadata-gathering in another thread. val constructionButtonDTOList = getConstructionButtonDTOs() - postCrashHandlingRunnable { + launchOnGLThread { val units = ArrayList() val buildableWonders = ArrayList
() val buildableNationalWonders = ArrayList
() diff --git a/core/src/com/unciv/ui/crashhandling/CrashHandlingExtensions.kt b/core/src/com/unciv/ui/crashhandling/CrashHandlingExtensions.kt new file mode 100644 index 0000000000..36e010a475 --- /dev/null +++ b/core/src/com/unciv/ui/crashhandling/CrashHandlingExtensions.kt @@ -0,0 +1,34 @@ +package com.unciv.ui.crashhandling + +import com.unciv.UncivGame +import com.unciv.utils.concurrency.Concurrency + + +/** + * Returns a wrapped version of a function that automatically handles an uncaught exception or error. In case of an uncaught exception or error, the return will be null. + * + * [com.unciv.ui.UncivStage], [UncivGame.render] and [Concurrency] already use this to wrap nearly everything that can happen during the lifespan of the Unciv application. + * Therefore, it usually shouldn't be necessary to manually use this. + */ +fun (() -> R).wrapCrashHandling( +): () -> R? + = { + try { + this() + } catch (e: Throwable) { + UncivGame.Current.handleUncaughtThrowable(e) + null + } +} + +/** + * Returns a wrapped version of a function that automatically handles an uncaught exception or error. + * + * [com.unciv.ui.UncivStage], [UncivGame.render] and [Concurrency] already use this to wrap nearly everything that can happen during the lifespan of the Unciv application. + * Therefore, it usually shouldn't be necessary to manually use this. + */ +fun (() -> Unit).wrapCrashHandlingUnit(): () -> Unit { + val wrappedReturning = this.wrapCrashHandling() + // Don't instantiate a new lambda every time the return get called. + return { wrappedReturning() ?: Unit } +} diff --git a/core/src/com/unciv/ui/crashhandling/CrashHandlingThread.kt b/core/src/com/unciv/ui/crashhandling/CrashHandlingThread.kt deleted file mode 100644 index 9497ee71a3..0000000000 --- a/core/src/com/unciv/ui/crashhandling/CrashHandlingThread.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.unciv.ui.crashhandling - -import com.badlogic.gdx.Gdx -import com.unciv.UncivGame -import kotlinx.coroutines.* -import java.util.concurrent.Executors -import java.util.concurrent.ThreadFactory -import kotlin.concurrent.thread - -private val DAEMON_EXECUTOR = Executors.newCachedThreadPool(object : ThreadFactory { - var n = 0 - override fun newThread(r: java.lang.Runnable): Thread = - crashHandlingThread(name = "crash-handling-daemon-${n++}", start = false, isDaemon = true, block = r::run) -}).asCoroutineDispatcher() -/** - * Coroutine Scope that runs coroutines in separate daemon threads. - * - * Brings the main game loop to a [com.unciv.CrashScreen] if an exception happens. - */ -val CRASH_HANDLING_DAEMON_SCOPE = CoroutineScope(DAEMON_EXECUTOR) - -private val EXECUTOR = Executors.newCachedThreadPool(object : ThreadFactory { - var n = 0 - override fun newThread(r: java.lang.Runnable): Thread = - crashHandlingThread(name = "crash-handling-${n++}", start = false, isDaemon = false, block = r::run) -}).asCoroutineDispatcher() -/** - * Coroutine Scope that runs coroutines in separate threads that are not started as daemons. - * - * Brings the main game loop to a [com.unciv.CrashScreen] if an exception happens. - */ -val CRASH_HANDLING_SCOPE = CoroutineScope(EXECUTOR) - -/** - * Must be called only in [com.unciv.UncivGame.dispose] to not have any threads running that prevent JVM shutdown. - */ -fun closeExecutors() { - EXECUTOR.close() - DAEMON_EXECUTOR.close() -} - -/** Wrapped version of [kotlin.concurrent.thread], that brings the main game loop to a [com.unciv.CrashScreen] if an exception happens. */ -private fun crashHandlingThread( - start: Boolean = true, - isDaemon: Boolean = false, - contextClassLoader: ClassLoader? = null, - name: String? = null, - priority: Int = -1, - block: () -> Unit -) = thread( - start = start, - isDaemon = isDaemon, - contextClassLoader = contextClassLoader, - name = name, - priority = priority, - block = block.wrapCrashHandlingUnit(true) - ) - -/** Wrapped version of Gdx.app.postRunnable ([com.badlogic.gdx.Application.postRunnable]), that brings the game loop to a [com.unciv.CrashScreen] if an exception occurs. */ -fun postCrashHandlingRunnable(runnable: () -> Unit) { - Gdx.app.postRunnable(runnable.wrapCrashHandlingUnit()) -} - -/** - * [launch]es a new coroutine that brings the game loop to a [com.unciv.CrashScreen] if an exception occurs. - * @see crashHandlingThread - */ -fun launchCrashHandling(name: String, runAsDaemon: Boolean = true, - flowBlock: suspend CoroutineScope.() -> Unit): Job { - return getCoroutineContext(runAsDaemon).launch(CoroutineName(name)) { flowBlock(this) } -} - -private fun getCoroutineContext(runAsDaemon: Boolean): CoroutineScope { - return if (runAsDaemon) CRASH_HANDLING_DAEMON_SCOPE else CRASH_HANDLING_SCOPE -} -/** - * 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. - * - * The game loop, threading, and event systems already use this to wrap nearly everything that can happen during the lifespan of the Unciv application. - * - * Therefore, it usually shouldn't be necessary to manually use this. See the note at the top of [CrashScreen].kt for details. - * - * @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).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. - * - * The game loop, threading, and event systems already use this to wrap nearly everything that can happen during the lifespan of the Unciv application. - * - * Therefore, it usually shouldn't be necessary to manually use this. See the note at the top of [CrashScreen].kt for details. - * - * @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 the return get called. - return { wrappedReturning() ?: Unit } -} diff --git a/core/src/com/unciv/ui/crashhandling/CrashScreen.kt b/core/src/com/unciv/ui/crashhandling/CrashScreen.kt index e28f83e437..7b370db1c2 100644 --- a/core/src/com/unciv/ui/crashhandling/CrashScreen.kt +++ b/core/src/com/unciv/ui/crashhandling/CrashScreen.kt @@ -24,16 +24,6 @@ import java.io.PrintWriter import java.io.StringWriter import kotlin.concurrent.thread -/* -Crashes are now handled from: -- Event listeners, by [UncivStage]. -- The main rendering loop, by [UncivGame.render]. -- Threads, by [crashHandlingThread]. -- Main loop runnables, by [postCrashHandlingRunnable]. - -Altogether, I *think* that should cover 90%-99% of all potential crashes. - */ - /** Screen to crash to when an otherwise unhandled exception or error is thrown. */ class CrashScreen(val exception: Throwable): BaseScreen() { @@ -113,8 +103,6 @@ class CrashScreen(val exception: Throwable): BaseScreen() { } init { - Log.error(text) // Also print to system terminal. - thread { throw exception } // this is so the GPC logs catch the exception stage.addActor(makeLayoutTable()) } diff --git a/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt b/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt index 0a87d4f6f7..c926ea9015 100644 --- a/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/AddMultiplayerGameScreen.kt @@ -5,8 +5,6 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.TextField import com.unciv.logic.IdChecker import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.popup.Popup import com.unciv.ui.popup.ToastPopup @@ -14,6 +12,8 @@ import com.unciv.ui.utils.extensions.enable import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import java.util.* class AddMultiplayerGameScreen(backScreen: MultiplayerScreen) : PickerScreen() { @@ -55,16 +55,16 @@ class AddMultiplayerGameScreen(backScreen: MultiplayerScreen) : PickerScreen() { popup.addGoodSizedLabel("Working...") popup.open() - launchCrashHandling("AddMultiplayerGame") { + Concurrency.run("AddMultiplayerGame") { try { game.onlineMultiplayer.addGame(gameIDTextField.text.trim(), gameNameTextField.text.trim()) - postCrashHandlingRunnable { + launchOnGLThread { popup.close() game.setScreen(backScreen) } } catch (ex: Exception) { val message = MultiplayerHelpers.getLoadExceptionMessage(ex) - postCrashHandlingRunnable { + launchOnGLThread { popup.reuseWith(message, true) } } diff --git a/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt b/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt index 609b91aa0a..fb6de39755 100644 --- a/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt +++ b/core/src/com/unciv/ui/multiplayer/EditMultiplayerGameInfoScreen.kt @@ -4,8 +4,6 @@ import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.ui.TextField import com.unciv.logic.multiplayer.OnlineMultiplayerGame import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.popup.Popup import com.unciv.ui.popup.ToastPopup @@ -15,6 +13,8 @@ import com.unciv.ui.utils.extensions.enable import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread /** Subscreen of MultiplayerScreen to edit and delete saves * backScreen is used for getting back to the MultiplayerScreen so it doesn't have to be created over and over again */ @@ -85,23 +85,23 @@ class EditMultiplayerGameInfoScreen(val multiplayerGame: OnlineMultiplayerGame, popup.addGoodSizedLabel("Working...").row() popup.open() - launchCrashHandling("Resign", runAsDaemon = false) { + Concurrency.runOnNonDaemonThreadPool("Resign") { try { val resignSuccess = game.onlineMultiplayer.resign(multiplayerGame) if (resignSuccess) { - postCrashHandlingRunnable { + launchOnGLThread { popup.close() //go back to the MultiplayerScreen game.setScreen(backScreen) } } else { - postCrashHandlingRunnable { + launchOnGLThread { popup.reuseWith("You can only resign if it's your turn", true) } } } catch (ex: Exception) { val message = MultiplayerHelpers.getLoadExceptionMessage(ex) - postCrashHandlingRunnable { + launchOnGLThread { popup.reuseWith(message, true) } } diff --git a/core/src/com/unciv/ui/multiplayer/MultiplayerHelpers.kt b/core/src/com/unciv/ui/multiplayer/MultiplayerHelpers.kt index e51f39125b..7a3a115955 100644 --- a/core/src/com/unciv/ui/multiplayer/MultiplayerHelpers.kt +++ b/core/src/com/unciv/ui/multiplayer/MultiplayerHelpers.kt @@ -7,12 +7,12 @@ import com.unciv.logic.multiplayer.OnlineMultiplayer import com.unciv.logic.multiplayer.OnlineMultiplayerGame import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.popup.Popup import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.extensions.formatShort import com.unciv.ui.utils.extensions.toCheckBox +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import java.io.FileNotFoundException import java.time.Duration import java.time.Instant @@ -30,12 +30,12 @@ object MultiplayerHelpers { loadingGamePopup.addGoodSizedLabel("Loading latest game state...") loadingGamePopup.open() - launchCrashHandling("JoinMultiplayerGame") { + Concurrency.run("JoinMultiplayerGame") { try { UncivGame.Current.onlineMultiplayer.loadGame(selectedGame) } catch (ex: Exception) { val message = getLoadExceptionMessage(ex) - postCrashHandlingRunnable { + launchOnGLThread { loadingGamePopup.reuseWith(message, true) } } diff --git a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt index 8414d42b7d..60e60a7f04 100644 --- a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt @@ -19,8 +19,6 @@ import com.unciv.logic.multiplayer.storage.FileStorageRateLimitReached import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.ruleset.RulesetCache import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.popup.Popup @@ -37,6 +35,9 @@ import com.unciv.ui.utils.extensions.pad import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread +import kotlinx.coroutines.coroutineScope import java.net.URL import java.util.* import com.unciv.ui.utils.AutoScrollPane as ScrollPane @@ -155,13 +156,11 @@ class NewGameScreen( val mapSize = gameSetupInfo.mapParameters.mapSize val message = mapSize.fixUndesiredSizes(gameSetupInfo.mapParameters.worldWrap) if (message != null) { - postCrashHandlingRunnable { - ToastPopup( message, UncivGame.Current.screen!!, 4000 ) - with (mapOptionsTable.generatedMapOptionsTable) { - customMapSizeRadius.text = mapSize.radius.toString() - customMapWidth.text = mapSize.width.toString() - customMapHeight.text = mapSize.height.toString() - } + ToastPopup( message, UncivGame.Current.screen!!, 4000 ) + with (mapOptionsTable.generatedMapOptionsTable) { + customMapSizeRadius.text = mapSize.radius.toString() + customMapWidth.text = mapSize.width.toString() + customMapHeight.text = mapSize.height.toString() } game.setScreen(this) // to get the input back return@onClick @@ -172,7 +171,7 @@ class NewGameScreen( rightSideButton.setText("Working...".tr()) // Creating a new game can take a while and we don't want ANRs - launchCrashHandling("NewGame", runAsDaemon = false) { + Concurrency.runOnNonDaemonThreadPool("NewGame") { startNewGame() } } @@ -236,9 +235,9 @@ class NewGameScreen( } } - private suspend fun startNewGame() { - val popup = Popup(this) - postCrashHandlingRunnable { + private suspend fun startNewGame() = coroutineScope { + val popup = Popup(this@NewGameScreen) + launchOnGLThread { popup.addGoodSizedLabel("Working...").row() popup.open() } @@ -248,7 +247,7 @@ class NewGameScreen( newGame = GameStarter.startNewGame(gameSetupInfo) } catch (exception: Exception) { exception.printStackTrace() - postCrashHandlingRunnable { + launchOnGLThread { popup.apply { reuseWith("It looks like we can't make a map with the parameters you requested!") row() @@ -259,35 +258,35 @@ class NewGameScreen( rightSideButton.enable() rightSideButton.setText("Start game!".tr()) } - return + return@coroutineScope } if (gameSetupInfo.gameParameters.isOnlineMultiplayer) { newGame.isUpToDate = true // So we don't try to download it from dropbox the second after we upload it - the file is not yet ready for loading! try { game.onlineMultiplayer.createGame(newGame) - game.gameSaver.autoSave(newGame) + game.gameSaver.requestAutoSave(newGame) } catch (ex: FileStorageRateLimitReached) { - postCrashHandlingRunnable { + launchOnGLThread { popup.reuseWith("Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds", true) + rightSideButton.enable() + rightSideButton.setText("Start game!".tr()) } Gdx.input.inputProcessor = stage - rightSideButton.enable() - rightSideButton.setText("Start game!".tr()) - return + return@coroutineScope } catch (ex: Exception) { Log.error("Error while creating game", ex) - postCrashHandlingRunnable { + launchOnGLThread { popup.reuseWith("Could not upload game!", true) + rightSideButton.enable() + rightSideButton.setText("Start game!".tr()) } Gdx.input.inputProcessor = stage - rightSideButton.enable() - rightSideButton.setText("Start game!".tr()) - return + return@coroutineScope } } - postCrashHandlingRunnable { + launchOnGLThread { val worldScreen = game.loadGame(newGame) if (newGame.gameParameters.isOnlineMultiplayer) { // Save gameId to clipboard because you have to do it anyway. diff --git a/core/src/com/unciv/ui/options/AdvancedTab.kt b/core/src/com/unciv/ui/options/AdvancedTab.kt index 2029e148b5..80c4a39283 100644 --- a/core/src/com/unciv/ui/options/AdvancedTab.kt +++ b/core/src/com/unciv/ui/options/AdvancedTab.kt @@ -13,8 +13,6 @@ import com.unciv.UncivGame import com.unciv.models.metadata.GameSettings import com.unciv.models.translations.TranslationFileWriter import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.popup.YesNoPopup import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.FontFamilyData @@ -27,6 +25,8 @@ import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.setFontColor import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import java.util.* fun advancedTab( @@ -115,14 +115,14 @@ fun addFontFamilySelect(table: Table, settings: GameSettings, selectBoxMinWidth: } } - launchCrashHandling("Add Font Select") { + Concurrency.run("Add Font Select") { // This is a heavy operation and causes ANRs val fonts = Array().apply { add(FontFamilyData.default) for (font in Fonts.getAvailableFontFamilyNames()) add(font) } - postCrashHandlingRunnable { loadFontSelect(fonts, selectCell) } + launchOnGLThread { loadFontSelect(fonts, selectCell) } } } @@ -146,9 +146,9 @@ private fun addTranslationGeneration(table: Table, optionsPopup: OptionsPopup) { val generateAction: () -> Unit = { optionsPopup.tabs.selectPage("Advanced") generateTranslationsButton.setText("Working...".tr()) - launchCrashHandling("WriteTranslations") { + Concurrency.run("WriteTranslations") { val result = TranslationFileWriter.writeNewTranslationFiles() - postCrashHandlingRunnable { + launchOnGLThread { // notify about completion generateTranslationsButton.setText(result.tr()) generateTranslationsButton.disable() diff --git a/core/src/com/unciv/ui/options/ModCheckTab.kt b/core/src/com/unciv/ui/options/ModCheckTab.kt index d418b05826..e010de7f81 100644 --- a/core/src/com/unciv/ui/options/ModCheckTab.kt +++ b/core/src/com/unciv/ui/options/ModCheckTab.kt @@ -10,8 +10,6 @@ import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.unique.Unique import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.newgamescreen.TranslatedSelectBox import com.unciv.ui.popup.ToastPopup @@ -24,6 +22,8 @@ import com.unciv.ui.utils.extensions.surroundWithCircle import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import com.unciv.utils.debug @@ -90,7 +90,7 @@ class ModCheckTab( modCheckResultTable.add("Checking mods for errors...".toLabel()).row() modCheckBaseSelect!!.isDisabled = true - launchCrashHandling("ModChecker") { + Concurrency.run("ModChecker") { for (mod in RulesetCache.values.sortedBy { it.name }) { if (base != MOD_CHECK_WITHOUT_BASE && mod.modOptions.isBaseRuleset) continue @@ -102,10 +102,10 @@ class ModCheckTab( if (modLinks.isNotEmpty()) modLinks += Ruleset.RulesetError("", Ruleset.RulesetErrorSeverity.OK) if (noProblem) modLinks += Ruleset.RulesetError("No problems found.".tr(), Ruleset.RulesetErrorSeverity.OK) - postCrashHandlingRunnable { + launchOnGLThread { // When the options popup is already closed before this postRunnable is run, // Don't add the labels, as otherwise the game will crash - if (stage == null) return@postCrashHandlingRunnable + if (stage == null) return@launchOnGLThread // Don't just render text, since that will make all the conditionals in the mod replacement messages move to the end, which makes it unreadable // Don't use .toLabel() either, since that activates translations as well, which is what we're trying to avoid, // Instead, some manual work needs to be put in. @@ -149,7 +149,7 @@ class ModCheckTab( } // done with all mods! - postCrashHandlingRunnable { + launchOnGLThread { modCheckResultTable.removeActor(modCheckResultTable.children.last()) modCheckBaseSelect!!.isDisabled = false } diff --git a/core/src/com/unciv/ui/options/MultiplayerTab.kt b/core/src/com/unciv/ui/options/MultiplayerTab.kt index 28e3e9604e..f9a5c7e51e 100644 --- a/core/src/com/unciv/ui/options/MultiplayerTab.kt +++ b/core/src/com/unciv/ui/options/MultiplayerTab.kt @@ -12,8 +12,6 @@ import com.unciv.models.metadata.GameSetting import com.unciv.models.metadata.GameSettings import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.popup.Popup import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.extensions.format @@ -23,6 +21,8 @@ import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.toGdxArray import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import java.time.Duration import java.time.temporal.ChronoUnit @@ -198,9 +198,9 @@ private fun addTurnCheckerOptions( } private fun successfullyConnectedToServer(settings: GameSettings, action: (Boolean, String, Int?) -> Unit) { - launchCrashHandling("TestIsAlive") { + Concurrency.run("TestIsAlive") { SimpleHttp.sendGetRequest("${settings.multiplayer.server}/isalive") { success, result, code -> - postCrashHandlingRunnable { + launchOnGLThread { action(success, result, code) } } diff --git a/core/src/com/unciv/ui/options/SoundTab.kt b/core/src/com/unciv/ui/options/SoundTab.kt index de076413ef..318bc74c9d 100644 --- a/core/src/com/unciv/ui/options/SoundTab.kt +++ b/core/src/com/unciv/ui/options/SoundTab.kt @@ -8,8 +8,6 @@ import com.unciv.models.metadata.GameSettings import com.unciv.models.translations.tr import com.unciv.ui.audio.MusicController import com.unciv.ui.audio.MusicTrackChooserFlags -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.UncivSlider import com.unciv.ui.utils.WrappableLabel @@ -17,6 +15,8 @@ import com.unciv.ui.utils.extensions.disable import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import kotlin.math.floor fun soundTab( @@ -52,15 +52,15 @@ private fun addDownloadMusic(table: Table, optionsPopup: OptionsPopup) { errorTable.add("Downloading...".toLabel()) // So the whole game doesn't get stuck while downloading the file - launchCrashHandling("MusicDownload") { + Concurrency.run("MusicDownload") { try { UncivGame.Current.musicController.downloadDefaultFile() - postCrashHandlingRunnable { + launchOnGLThread { optionsPopup.tabs.replacePage("Sound", soundTab(optionsPopup)) UncivGame.Current.musicController.chooseTrack(flags = MusicTrackChooserFlags.setPlayDefault) } } catch (ex: Exception) { - postCrashHandlingRunnable { + launchOnGLThread { errorTable.clear() errorTable.add("Could not download music!".toLabel(Color.RED)) } @@ -144,7 +144,7 @@ private fun addMusicCurrentlyPlaying(table: Table, music: MusicController) { label.wrap = true table.add(label).padTop(20f).colspan(2).fillX().row() music.onChange { - postCrashHandlingRunnable { + Concurrency.runOnGLThread { label.setText("Currently playing: [$it]".tr()) } } diff --git a/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt b/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt index 2f827dfe45..e23ce025a9 100644 --- a/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt +++ b/core/src/com/unciv/ui/pickerscreens/ModManagementScreen.kt @@ -4,7 +4,12 @@ import com.badlogic.gdx.Gdx import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.scenes.scene2d.Touchable -import com.badlogic.gdx.scenes.scene2d.ui.* +import com.badlogic.gdx.scenes.scene2d.ui.Button +import com.badlogic.gdx.scenes.scene2d.ui.Label +import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane +import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.TextArea +import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.badlogic.gdx.utils.Align import com.unciv.MainMenuScreen import com.unciv.json.fromJsonFile @@ -13,20 +18,29 @@ import com.unciv.models.ruleset.ModOptions import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.pickerscreens.ModManagementOptions.SortType import com.unciv.ui.popup.Popup import com.unciv.ui.popup.ToastPopup import com.unciv.ui.popup.YesNoPopup -import com.unciv.ui.utils.* -import com.unciv.ui.utils.extensions.* +import com.unciv.ui.utils.AutoScrollPane +import com.unciv.ui.utils.ExpanderTab +import com.unciv.ui.utils.KeyCharAndCode +import com.unciv.ui.utils.WrappableLabel import com.unciv.ui.utils.extensions.UncivDateFormat.formatDate import com.unciv.ui.utils.extensions.UncivDateFormat.parseDate +import com.unciv.ui.utils.extensions.addSeparator +import com.unciv.ui.utils.extensions.disable +import com.unciv.ui.utils.extensions.enable +import com.unciv.ui.utils.extensions.isEnabled +import com.unciv.ui.utils.extensions.onClick +import com.unciv.ui.utils.extensions.toCheckBox +import com.unciv.ui.utils.extensions.toLabel +import com.unciv.ui.utils.extensions.toTextButton +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import kotlinx.coroutines.Job import kotlinx.coroutines.isActive -import java.util.* import kotlin.math.max /** @@ -190,23 +204,23 @@ class ModManagementScreen( * calls itself for the next page of search results */ private fun tryDownloadPage(pageNum: Int) { - runningSearchJob = launchCrashHandling("GitHubSearch") { + runningSearchJob = Concurrency.run("GitHubSearch") { val repoSearch: Github.RepoSearch try { repoSearch = Github.tryGetGithubReposWithTopic(amountPerPage, pageNum)!! } catch (ex: Exception) { - postCrashHandlingRunnable { + launchOnGLThread { ToastPopup("Could not download mod list", this@ModManagementScreen) } runningSearchJob = null - return@launchCrashHandling + return@run } if (!isActive) { - return@launchCrashHandling + return@run } - postCrashHandlingRunnable { addModInfoFromRepoSearch(repoSearch, pageNum) } + launchOnGLThread { addModInfoFromRepoSearch(repoSearch, pageNum) } runningSearchJob = null } } @@ -394,13 +408,13 @@ class ModManagementScreen( /** Download and install a mod in the background, called both from the right-bottom button and the URL entry popup */ private fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) { - launchCrashHandling("DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions + Concurrency.run("DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions try { val modFolder = Github.downloadAndExtract(repo.html_url, repo.default_branch, Gdx.files.local("mods")) ?: throw Exception() // downloadAndExtract returns null for 404 errors and the like -> display something! Github.rewriteModOptions(repo, modFolder) - postCrashHandlingRunnable { + launchOnGLThread { ToastPopup("[${repo.name}] Downloaded!", this@ModManagementScreen) RulesetCache.loadRulesets() RulesetCache[repo.name]?.let { @@ -412,7 +426,7 @@ class ModManagementScreen( postAction() } } catch (ex: Exception) { - postCrashHandlingRunnable { + launchOnGLThread { ToastPopup("Could not download [${repo.name}]", this@ModManagementScreen) postAction() } diff --git a/core/src/com/unciv/ui/pickerscreens/TechPickerScreen.kt b/core/src/com/unciv/ui/pickerscreens/TechPickerScreen.kt index 69efab2f9b..e86f00ada3 100644 --- a/core/src/com/unciv/ui/pickerscreens/TechPickerScreen.kt +++ b/core/src/com/unciv/ui/pickerscreens/TechPickerScreen.kt @@ -14,7 +14,6 @@ import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.translations.tr import com.unciv.ui.civilopedia.CivilopediaCategories import com.unciv.ui.civilopedia.CivilopediaScreen -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.popup.ToastPopup import com.unciv.ui.utils.Fonts @@ -24,6 +23,7 @@ import com.unciv.ui.utils.extensions.darken import com.unciv.ui.utils.extensions.disable import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.toLabel +import com.unciv.utils.concurrency.Concurrency class TechPickerScreen( @@ -301,7 +301,7 @@ class TechPickerScreen( } private fun centerOnTechnology(tech: Technology) { - postCrashHandlingRunnable { + Concurrency.runOnGLThread { techNameToButton[tech.name]?.let { scrollPane.scrollTo(it.x, it.y, it.width, it.height, true, true) scrollPane.updateVisualScroll() diff --git a/core/src/com/unciv/ui/popup/ToastPopup.kt b/core/src/com/unciv/ui/popup/ToastPopup.kt index 56d88b3ea6..ca07331870 100644 --- a/core/src/com/unciv/ui/popup/ToastPopup.kt +++ b/core/src/com/unciv/ui/popup/ToastPopup.kt @@ -1,10 +1,10 @@ package com.unciv.ui.popup import com.badlogic.gdx.scenes.scene2d.Stage -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.extensions.onClick +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import kotlinx.coroutines.delay /** @@ -28,9 +28,9 @@ class ToastPopup (message: String, stage: Stage, val time: Long = 2000) : Popup( } private fun startTimer(){ - launchCrashHandling("ResponsePopup") { + Concurrency.run("ResponsePopup") { delay(time) - postCrashHandlingRunnable { this@ToastPopup.close() } + launchOnGLThread { this@ToastPopup.close() } } } diff --git a/core/src/com/unciv/ui/saves/LoadGameScreen.kt b/core/src/com/unciv/ui/saves/LoadGameScreen.kt index 95550ced98..5b7d4e6e7b 100644 --- a/core/src/com/unciv/ui/saves/LoadGameScreen.kt +++ b/core/src/com/unciv/ui/saves/LoadGameScreen.kt @@ -12,8 +12,6 @@ import com.unciv.logic.MissingModsException import com.unciv.logic.UncivShowableException import com.unciv.models.ruleset.RulesetCache import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.pickerscreens.Github import com.unciv.ui.popup.Popup import com.unciv.ui.popup.ToastPopup @@ -26,6 +24,8 @@ import com.unciv.ui.utils.extensions.isEnabled import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import java.io.FileNotFoundException class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { @@ -77,17 +77,17 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { val loadingPopup = Popup( this) loadingPopup.addGoodSizedLabel(Constants.loading) loadingPopup.open() - launchCrashHandling(loadGame) { + Concurrency.run(loadGame) { try { // This is what can lead to ANRs - reading the file and setting the transients, that's why this is in another thread val loadedGame = game.gameSaver.loadGameByName(selectedSave) - postCrashHandlingRunnable { game.loadGame(loadedGame) } + launchOnGLThread { game.loadGame(loadedGame) } } catch (ex: Exception) { - postCrashHandlingRunnable { + launchOnGLThread { loadingPopup.close() if (ex is MissingModsException) { handleLoadGameException("Could not load game", ex) - return@postCrashHandlingRunnable + return@launchOnGLThread } val cantLoadGamePopup = Popup(this@LoadGameScreen) cantLoadGamePopup.addGoodSizedLabel("It looks like your saved game can't be loaded!").row() @@ -114,13 +114,13 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { private fun getLoadFromClipboardButton(): TextButton { val pasteButton = loadFromClipboard.toTextButton() val pasteHandler: ()->Unit = { - launchCrashHandling(loadFromClipboard) { + Concurrency.run(loadFromClipboard) { try { val clipboardContentsString = Gdx.app.clipboard.contents.trim() val loadedGame = GameSaver.gameInfoFromString(clipboardContentsString) - postCrashHandlingRunnable { game.loadGame(loadedGame) } + launchOnGLThread { game.loadGame(loadedGame) } } catch (ex: Exception) { - postCrashHandlingRunnable { handleLoadGameException("Could not load game from clipboard!", ex) } + launchOnGLThread { handleLoadGameException("Could not load game from clipboard!", ex) } } } } @@ -138,7 +138,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { errorLabel.isVisible = false loadFromCustomLocation.setText(Constants.loading.tr()) loadFromCustomLocation.disable() - launchCrashHandling(Companion.loadFromCustomLocation) { + Concurrency.run(Companion.loadFromCustomLocation) { game.gameSaver.loadGameFromCustomLocation { result -> if (result.isError()) { handleLoadGameException("Could not load game from custom location!", result.exception) @@ -154,7 +154,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { private fun getCopyExistingSaveToClipboardButton(): TextButton { val copyButton = copyExistingSaveToClipboard.toTextButton() val copyHandler: ()->Unit = { - launchCrashHandling(copyExistingSaveToClipboard) { + Concurrency.run(copyExistingSaveToClipboard) { try { val gameText = game.gameSaver.getSave(selectedSave).readString() Gdx.app.clipboard.contents = if (gameText[0] == '{') Gzip.zip(gameText) else gameText @@ -185,7 +185,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { var errorText = primaryText.tr() if (ex is UncivShowableException) errorText += "\n${ex.localizedMessage}" ex?.printStackTrace() - postCrashHandlingRunnable { + Concurrency.runOnGLThread { errorLabel.setText(errorText) errorLabel.isVisible = true if (ex is MissingModsException) { @@ -198,7 +198,7 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { private fun loadMissingMods() { loadMissingModsButton.isEnabled = false descriptionLabel.setText(Constants.loading.tr()) - launchCrashHandling(downloadMissingMods, runAsDaemon = false) { + Concurrency.runOnNonDaemonThreadPool(downloadMissingMods) { try { val mods = missingModsToLoad.replace(' ', '-').lowercase().splitToSequence(",-") for (modName in mods) { @@ -215,9 +215,9 @@ class LoadGameScreen(previousScreen:BaseScreen) : LoadOrSaveScreen() { val labelText = descriptionLabel.text // Surprise - a StringBuilder labelText.appendLine() labelText.append("[${repo.name}] Downloaded!".tr()) - postCrashHandlingRunnable { descriptionLabel.setText(labelText) } + launchOnGLThread { descriptionLabel.setText(labelText) } } - postCrashHandlingRunnable { + launchOnGLThread { RulesetCache.loadRulesets() missingModsToLoad = "" loadMissingModsButton.isVisible = false diff --git a/core/src/com/unciv/ui/saves/LoadOrSaveScreen.kt b/core/src/com/unciv/ui/saves/LoadOrSaveScreen.kt index bf1a23c0ff..f82576bf66 100644 --- a/core/src/com/unciv/ui/saves/LoadOrSaveScreen.kt +++ b/core/src/com/unciv/ui/saves/LoadOrSaveScreen.kt @@ -6,8 +6,6 @@ import com.badlogic.gdx.scenes.scene2d.ui.CheckBox import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.Constants import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.pickerscreens.PickerScreen import com.unciv.ui.utils.Fonts import com.unciv.ui.utils.KeyCharAndCode @@ -20,7 +18,9 @@ import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.pad import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton -import java.util.Date +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread +import java.util.* abstract class LoadOrSaveScreen( @@ -101,7 +101,7 @@ abstract class LoadOrSaveScreen( private fun showSaveInfo(saveGameFile: FileHandle) { descriptionLabel.setText(Constants.loading.tr()) - launchCrashHandling("LoadMetaData") { // Even loading the game to get its metadata can take a long time on older phones + Concurrency.run("LoadMetaData") { // Even loading the game to get its metadata can take a long time on older phones val textToSet = try { val savedAt = Date(saveGameFile.lastModified()) val game = game.gameSaver.loadGamePreviewFromFile(saveGameFile) @@ -118,7 +118,7 @@ abstract class LoadOrSaveScreen( "\n{Could not load game}!" } - postCrashHandlingRunnable { + launchOnGLThread { descriptionLabel.setText(textToSet.tr()) } } diff --git a/core/src/com/unciv/ui/saves/QuickSave.kt b/core/src/com/unciv/ui/saves/QuickSave.kt index 23d5d47d70..89d8643cbc 100644 --- a/core/src/com/unciv/ui/saves/QuickSave.kt +++ b/core/src/com/unciv/ui/saves/QuickSave.kt @@ -4,12 +4,12 @@ import com.unciv.Constants import com.unciv.MainMenuScreen import com.unciv.UncivGame import com.unciv.logic.GameInfo -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.multiplayer.MultiplayerHelpers import com.unciv.ui.popup.Popup import com.unciv.ui.popup.ToastPopup import com.unciv.ui.worldscreen.WorldScreen +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import com.unciv.utils.Log @@ -19,9 +19,9 @@ object QuickSave { fun save(gameInfo: GameInfo, screen: WorldScreen) { val gameSaver = UncivGame.Current.gameSaver val toast = ToastPopup("Quicksaving...", screen) - launchCrashHandling("QuickSaveGame", runAsDaemon = false) { + Concurrency.runOnNonDaemonThreadPool("QuickSaveGame") { gameSaver.saveGame(gameInfo, "QuickSave") { - postCrashHandlingRunnable { + launchOnGLThread { toast.close() if (it != null) ToastPopup("Could not save game!", screen) @@ -35,16 +35,16 @@ object QuickSave { fun load(screen: WorldScreen) { val gameSaver = UncivGame.Current.gameSaver val toast = ToastPopup("Quickloading...", screen) - launchCrashHandling("QuickLoadGame") { + Concurrency.run("QuickLoadGame") { try { val loadedGame = gameSaver.loadGameByName("QuickSave") - postCrashHandlingRunnable { + launchOnGLThread { toast.close() UncivGame.Current.loadGame(loadedGame) ToastPopup("Quickload successful.", screen) } } catch (ex: Exception) { - postCrashHandlingRunnable { + launchOnGLThread { toast.close() ToastPopup("Could not load game!", screen) } @@ -56,10 +56,10 @@ object QuickSave { val loadingPopup = Popup(screen) loadingPopup.addGoodSizedLabel(Constants.loading) loadingPopup.open() - launchCrashHandling("autoLoadGame") { + Concurrency.run("autoLoadGame") { // Load game from file to class on separate thread to avoid ANR... fun outOfMemory() { - postCrashHandlingRunnable { + launchOnGLThread { loadingPopup.close() ToastPopup("Not enough memory on phone to load game!", screen) } @@ -70,14 +70,14 @@ object QuickSave { savedGame = screen.game.gameSaver.loadLatestAutosave() } catch (oom: OutOfMemoryError) { outOfMemory() - return@launchCrashHandling + return@run } catch (ex: Exception) { Log.error("Could not autoload game", ex) - postCrashHandlingRunnable { + launchOnGLThread { loadingPopup.close() ToastPopup("Cannot resume game!", screen) } - return@launchCrashHandling + return@run } if (savedGame.gameParameters.isOnlineMultiplayer) { @@ -88,13 +88,13 @@ object QuickSave { } catch (ex: Exception) { val message = MultiplayerHelpers.getLoadExceptionMessage(ex) Log.error("Could not autoload game", ex) - postCrashHandlingRunnable { + launchOnGLThread { loadingPopup.close() ToastPopup(message, screen) } } } else { - postCrashHandlingRunnable { /// ... and load it into the screen on main thread for GL context + launchOnGLThread { /// ... and load it into the screen on main thread for GL context try { screen.game.loadGame(savedGame) } catch (oom: OutOfMemoryError) { diff --git a/core/src/com/unciv/ui/saves/SaveGameScreen.kt b/core/src/com/unciv/ui/saves/SaveGameScreen.kt index 4eed922a3b..43be3acdfa 100644 --- a/core/src/com/unciv/ui/saves/SaveGameScreen.kt +++ b/core/src/com/unciv/ui/saves/SaveGameScreen.kt @@ -9,8 +9,6 @@ import com.unciv.UncivGame import com.unciv.logic.GameInfo import com.unciv.logic.GameSaver import com.unciv.models.translations.tr -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.popup.ToastPopup import com.unciv.ui.popup.YesNoPopup import com.unciv.ui.utils.KeyCharAndCode @@ -20,6 +18,8 @@ import com.unciv.ui.utils.extensions.enable import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.toLabel import com.unciv.ui.utils.extensions.toTextButton +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") { @@ -70,13 +70,15 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") } private fun copyToClipboardHandler() { - launchCrashHandling("Copy game to clipboard") { + Concurrency.run("Copy game to clipboard") { // the Gzip rarely leads to ANRs try { Gdx.app.clipboard.contents = GameSaver.gameInfoToString(gameInfo, forceZip = true) } catch (ex: Throwable) { ex.printStackTrace() - ToastPopup("Could not save game to clipboard!", this@SaveGameScreen) + launchOnGLThread { + ToastPopup("Could not save game to clipboard!", this@SaveGameScreen) + } } } } @@ -89,7 +91,7 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") errorLabel.setText("") saveToCustomLocation.setText("Saving...".tr()) saveToCustomLocation.disable() - launchCrashHandling("Save to custom location", runAsDaemon = false) { + Concurrency.runOnNonDaemonThreadPool("Save to custom location") { game.gameSaver.saveGameToCustomLocation(gameInfo, gameNameTextField.text) { result -> if (result.isError()) { errorLabel.setText("Could not save game to custom location!".tr()) @@ -107,9 +109,9 @@ class SaveGameScreen(val gameInfo: GameInfo) : LoadOrSaveScreen("Current saves") private fun saveGame() { rightSideButton.setText("Saving...".tr()) - launchCrashHandling("SaveGame", runAsDaemon = false) { + Concurrency.runOnNonDaemonThreadPool("SaveGame") { game.gameSaver.saveGame(gameInfo, gameNameTextField.text) { - postCrashHandlingRunnable { + launchOnGLThread { if (it != null) ToastPopup("Could not save game!", this@SaveGameScreen) else UncivGame.Current.resetToWorldScreen() } diff --git a/core/src/com/unciv/ui/saves/VerticalFileListScrollPane.kt b/core/src/com/unciv/ui/saves/VerticalFileListScrollPane.kt index 35c698b05a..0b538041e3 100644 --- a/core/src/com/unciv/ui/saves/VerticalFileListScrollPane.kt +++ b/core/src/com/unciv/ui/saves/VerticalFileListScrollPane.kt @@ -8,13 +8,13 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.badlogic.gdx.scenes.scene2d.ui.TextButton import com.badlogic.gdx.utils.Align import com.unciv.logic.GameSaver -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.utils.AutoScrollPane import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.KeyPressDispatcher import com.unciv.ui.utils.extensions.onClick +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread //todo key auto-repeat for navigation keys? @@ -67,11 +67,11 @@ class VerticalFileListScrollPane( // Apparently, even just getting the list of saves can cause ANRs - // not sure how many saves these guys had but Google Play reports this to have happened hundreds of times - launchCrashHandling("GetSaves") { + Concurrency.run("GetSaves") { // .toList() materializes the result of the sequence val saves = files.toList() - postCrashHandlingRunnable { + launchOnGLThread { loadAnimation.reset() existingSavesTable.clear() for (saveGameFile in saves) { diff --git a/core/src/com/unciv/ui/utils/GeneralPlatformSpecificHelpers.kt b/core/src/com/unciv/ui/utils/GeneralPlatformSpecificHelpers.kt index fa3325f850..3cdc3d8249 100644 --- a/core/src/com/unciv/ui/utils/GeneralPlatformSpecificHelpers.kt +++ b/core/src/com/unciv/ui/utils/GeneralPlatformSpecificHelpers.kt @@ -1,6 +1,9 @@ package com.unciv.ui.utils +import com.badlogic.gdx.Gdx +import com.unciv.UncivGame import com.unciv.models.metadata.GameSettings +import com.unciv.ui.crashhandling.CrashScreen /** Interface to support various platform-specific tools */ interface GeneralPlatformSpecificHelpers { @@ -23,4 +26,10 @@ interface GeneralPlatformSpecificHelpers { * otherwise uses [com.badlogic.gdx.Files.getLocalStoragePath] */ fun shouldPreferExternalStorage(): Boolean + + /** + * Handle an uncaught throwable. + * @return true if the throwable was handled. + */ + fun handleUncaughtThrowable(ex: Throwable): Boolean = false } diff --git a/core/src/com/unciv/ui/utils/extensions/Scene2dExtensions.kt b/core/src/com/unciv/ui/utils/extensions/Scene2dExtensions.kt index b08328d46a..53465008ac 100644 --- a/core/src/com/unciv/ui/utils/extensions/Scene2dExtensions.kt +++ b/core/src/com/unciv/ui/utils/extensions/Scene2dExtensions.kt @@ -21,11 +21,11 @@ import com.unciv.Constants import com.unciv.models.UncivSound import com.unciv.models.translations.tr import com.unciv.ui.audio.SoundPlayer -import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.images.IconCircleGroup import com.unciv.ui.images.ImageGetter import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.Fonts +import com.unciv.utils.concurrency.Concurrency /** * Collection of extension functions mostly for libGdx widgets @@ -74,7 +74,7 @@ fun Actor.center(parent: Stage) { centerX(parent); centerY(parent) } fun Actor.onClickEvent(sound: UncivSound = UncivSound.Click, function: (event: InputEvent?, x: Float, y: Float) -> Unit) { this.addListener(object : ClickListener() { override fun clicked(event: InputEvent?, x: Float, y: Float) { - launchCrashHandling("Sound") { SoundPlayer.play(sound) } + Concurrency.run("Sound") { SoundPlayer.play(sound) } function(event, x, y) } }) diff --git a/core/src/com/unciv/ui/worldscreen/PlayerReadyScreen.kt b/core/src/com/unciv/ui/worldscreen/PlayerReadyScreen.kt index 05344fe566..0f468b0289 100644 --- a/core/src/com/unciv/ui/worldscreen/PlayerReadyScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/PlayerReadyScreen.kt @@ -3,11 +3,11 @@ package com.unciv.ui.worldscreen import com.badlogic.gdx.scenes.scene2d.Touchable import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.Constants -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.toLabel +import com.unciv.utils.concurrency.Concurrency class PlayerReadyScreen(worldScreen: WorldScreen) : BaseScreen() { init { @@ -19,7 +19,7 @@ class PlayerReadyScreen(worldScreen: WorldScreen) : BaseScreen() { table.add("[$curCiv] ready?".toLabel(curCiv.nation.getInnerColor(), Constants.headingFontSize)) table.onClick { - postCrashHandlingRunnable { // To avoid ANRs on Android when the creation of the worldscreen takes more than 500ms + Concurrency.runOnGLThread { // To avoid ANRs on Android when the creation of the worldscreen takes more than 500ms game.setScreen(worldScreen) } } diff --git a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt index 526f3401cb..528cfcebc0 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt @@ -34,8 +34,6 @@ import com.unciv.models.helpers.MapArrowType import com.unciv.models.helpers.MiscArrowTypes import com.unciv.ui.UncivStage import com.unciv.ui.audio.SoundPlayer -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.map.TileGroupMap import com.unciv.ui.tilegroups.TileGroup @@ -50,6 +48,8 @@ import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.surroundWithCircle import com.unciv.ui.utils.extensions.toLabel import com.unciv.utils.Log +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread class WorldMapHolder( @@ -153,7 +153,7 @@ class WorldMapHolder( override fun clicked(event: InputEvent?, x: Float, y: Float) { val unit = worldScreen.bottomUnitTable.selectedUnit ?: return - launchCrashHandling("WorldScreenClick") { + Concurrency.run("WorldScreenClick") { onTileRightClicked(unit, tileGroup.tileInfo) } } @@ -261,7 +261,7 @@ class WorldMapHolder( val selectedUnit = selectedUnits.first() - launchCrashHandling("TileToMoveTo") { + Concurrency.run("TileToMoveTo") { // these are the heavy parts, finding where we want to go // Since this runs in a different thread, even if we check movement.canReach() // then it might change until we get to the getTileToMoveTo, so we just try/catch it @@ -270,10 +270,10 @@ class WorldMapHolder( tileToMoveTo = selectedUnit.movement.getTileToMoveToThisTurn(targetTile) } catch (ex: Exception) { Log.error("Exception in getTileToMoveToThisTurn", ex) - return@launchCrashHandling + return@run } // can't move here - postCrashHandlingRunnable { + launchOnGLThread { try { // Because this is darned concurrent (as it MUST be to avoid ANRs), // there are edge cases where the canReach is true, @@ -315,7 +315,7 @@ class WorldMapHolder( } private fun addTileOverlaysWithUnitMovement(selectedUnits: List, tileInfo: TileInfo) { - launchCrashHandling("TurnsToGetThere") { + Concurrency.run("TurnsToGetThere") { /** LibGdx sometimes has these weird errors when you try to edit the UI layout from 2 separate threads. * And so, all UI editing will be done on the main thread. * The only "heavy lifting" that needs to be done is getting the turns to get there, @@ -346,12 +346,12 @@ class WorldMapHolder( unitToTurnsToTile[unit] = turnsToGetThere } - postCrashHandlingRunnable { + launchOnGLThread { val unitsWhoCanMoveThere = HashMap(unitToTurnsToTile.filter { it.value != 0 }) if (unitsWhoCanMoveThere.isEmpty()) { // give the regular tile overlays with no unit movement addTileOverlays(tileInfo) worldScreen.shouldUpdate = true - return@postCrashHandlingRunnable + return@launchOnGLThread } val turnsToGetThere = unitsWhoCanMoveThere.values.maxOrNull()!! diff --git a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt index 50f045b9bc..369556ea10 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt @@ -32,8 +32,6 @@ import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.translations.tr import com.unciv.ui.cityscreen.CityScreen import com.unciv.ui.civilopedia.CivilopediaScreen -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.multiplayer.MultiplayerHelpers import com.unciv.ui.overviewscreen.EmpireOverviewScreen @@ -77,8 +75,12 @@ import com.unciv.ui.worldscreen.status.NextTurnButton import com.unciv.ui.worldscreen.status.StatusButtons import com.unciv.ui.worldscreen.unit.UnitActionsTable import com.unciv.ui.worldscreen.unit.UnitTable +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread +import com.unciv.utils.concurrency.launchOnThreadPool import com.unciv.utils.debug import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope /** * Unciv's world screen @@ -218,7 +220,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas if (isNextTurnUpdateRunning() || game.onlineMultiplayer.hasLatestGameState(gameInfo, it.preview)) { return@receive } - launchCrashHandling("Load latest multiplayer state") { + Concurrency.run("Load latest multiplayer state") { loadLatestMultiplayerState() } } @@ -327,9 +329,9 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } - private suspend fun loadLatestMultiplayerState() { - val loadingGamePopup = Popup(this) - postCrashHandlingRunnable { + private suspend fun loadLatestMultiplayerState(): Unit = coroutineScope { + val loadingGamePopup = Popup(this@WorldScreen) + launchOnGLThread { loadingGamePopup.addGoodSizedLabel("Loading latest game state...") loadingGamePopup.open() } @@ -343,20 +345,20 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas if (viewingCiv.civName == latestGame.currentPlayer || viewingCiv.civName == Constants.spectator) { game.platformSpecificHelper?.notifyTurnStarted() } - postCrashHandlingRunnable { + launchOnGLThread { loadingGamePopup.close() if (game.gameInfo!!.gameId == gameInfo.gameId) { // game could've been changed during download game.setScreen(createNewWorldScreen(latestGame)) } } } catch (ex: Throwable) { - postCrashHandlingRunnable { + launchOnGLThread { val message = MultiplayerHelpers.getLoadExceptionMessage(ex) loadingGamePopup.innerTable.clear() loadingGamePopup.addGoodSizedLabel("Couldn't download the latest game state!").colspan(2).row() loadingGamePopup.addGoodSizedLabel(message).colspan(2).row() loadingGamePopup.addButtonInRow("Retry") { - launchCrashHandling("Load latest multiplayer state after error") { + launchOnThreadPool("Load latest multiplayer state after error") { loadLatestMultiplayerState() } }.right() @@ -629,7 +631,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas shouldUpdate = true // on a separate thread so the user can explore their world while we're passing the turn - nextTurnUpdateJob = launchCrashHandling("NextTurn", runAsDaemon = false) { + nextTurnUpdateJob = Concurrency.runOnNonDaemonThreadPool("NextTurn") { debug("Next turn starting") val startTime = System.currentTimeMillis() val originalGameInfo = gameInfo @@ -646,7 +648,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas is FileStorageRateLimitReached -> "Server limit reached! Please wait for [${ex.limitRemainingSeconds}] seconds" else -> "Could not upload game!" } - postCrashHandlingRunnable { // Since we're changing the UI, that should be done on the main thread + launchOnGLThread { // Since we're changing the UI, that should be done on the main thread val cantUploadNewGamePopup = Popup(this@WorldScreen) cantUploadNewGamePopup.addGoodSizedLabel(message).row() cantUploadNewGamePopup.addCloseButton() @@ -654,12 +656,12 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas } this@WorldScreen.isPlayersTurn = true // Since we couldn't push the new game clone, then it's like we never clicked the "next turn" button this@WorldScreen.shouldUpdate = true - return@launchCrashHandling + return@runOnNonDaemonThreadPool } } if (game.gameInfo != originalGameInfo) // while this was turning we loaded another game - return@launchCrashHandling + return@runOnNonDaemonThreadPool this@WorldScreen.game.gameInfo = gameInfoClone debug("Next turn took %sms", System.currentTimeMillis() - startTime) @@ -668,7 +670,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas // create a new WorldScreen to show the new stuff we've changed, and switch out the current screen. // do this on main thread - it's the only one that has a GL context to create images from - postCrashHandlingRunnable { + launchOnGLThread { val newWorldScreen = createNewWorldScreen(gameInfoClone) if (gameInfoClone.currentPlayerCiv.civName != viewingCiv.civName && !gameInfoClone.gameParameters.isOnlineMultiplayer) { @@ -680,7 +682,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas if (shouldAutoSave) { newWorldScreen.waitingForAutosave = true newWorldScreen.shouldUpdate = true - game.gameSaver.autoSave(gameInfoClone) { + game.gameSaver.requestAutoSave(gameInfoClone).invokeOnCompletion { // only enable the user to next turn once we've saved the current one newWorldScreen.waitingForAutosave = false newWorldScreen.shouldUpdate = true @@ -802,10 +804,10 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas viewingCiv.hasMovedAutomatedUnits = true isPlayersTurn = false // Disable state changes nextTurnButton.disable() - launchCrashHandling("Move automated units") { + Concurrency.run("Move automated units") { for (unit in viewingCiv.getCivUnits()) unit.doAction() - postCrashHandlingRunnable { + launchOnGLThread { shouldUpdate = true isPlayersTurn = true //Re-enable state changes nextTurnButton.enable() diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt index a056609f42..5e6839ef31 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt @@ -16,7 +16,7 @@ class WorldScreenMenuPopup(val worldScreen: WorldScreen) : Popup(worldScreen) { defaults().fillX() addButton("Main menu") { - worldScreen.game.gameSaver.autoSaveUnCloned(worldScreen.gameInfo) + worldScreen.game.gameSaver.requestAutoSaveUnCloned(worldScreen.gameInfo) // Can save gameInfo directly because the user can't modify it on the MainMenuScreen worldScreen.game.setScreen(MainMenuScreen()) } addButton("Civilopedia") { diff --git a/core/src/com/unciv/ui/worldscreen/status/MultiplayerStatusButton.kt b/core/src/com/unciv/ui/worldscreen/status/MultiplayerStatusButton.kt index 3a3905d9ab..f64617b074 100644 --- a/core/src/com/unciv/ui/worldscreen/status/MultiplayerStatusButton.kt +++ b/core/src/com/unciv/ui/worldscreen/status/MultiplayerStatusButton.kt @@ -20,12 +20,12 @@ import com.unciv.logic.multiplayer.MultiplayerGameUpdateStarted import com.unciv.logic.multiplayer.MultiplayerGameUpdated import com.unciv.logic.multiplayer.OnlineMultiplayerGame import com.unciv.logic.multiplayer.isUsersTurn -import com.unciv.ui.crashhandling.launchCrashHandling -import com.unciv.ui.crashhandling.postCrashHandlingRunnable import com.unciv.ui.images.ImageGetter import com.unciv.ui.utils.BaseScreen import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.utils.extensions.setSize +import com.unciv.utils.concurrency.Concurrency +import com.unciv.utils.concurrency.launchOnGLThread import kotlinx.coroutines.delay import java.time.Duration import java.time.Instant @@ -55,7 +55,7 @@ class MultiplayerStatusButton( } else { gameNamesWithCurrentTurn.remove(it.name) } - if (shouldUpdate) postCrashHandlingRunnable { + if (shouldUpdate) Concurrency.runOnGLThread { updateTurnIndicator() } } @@ -96,9 +96,9 @@ class MultiplayerStatusButton( } else { Duration.ZERO } - launchCrashHandling("Hide loading indicator") { + Concurrency.run("Hide loading indicator") { delay(waitFor.toMillis()) - postCrashHandlingRunnable { + launchOnGLThread { loadingImage.clearActions() loadingImage.isVisible = false multiplayerImage.color.a = 1f @@ -175,9 +175,9 @@ private class TurnIndicator : HorizontalGroup() { if (alternations == 0) return gameAmount.color = nextColor image.color = nextColor - launchCrashHandling("StatusButton color flash") { + Concurrency.run("StatusButton color flash") { delay(500) - postCrashHandlingRunnable { + launchOnGLThread { flash(alternations - 1, nextColor, curColor) } } diff --git a/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt b/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt index 16785fef6c..82017d2d94 100644 --- a/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt +++ b/core/src/com/unciv/ui/worldscreen/unit/UnitActionsTable.kt @@ -7,7 +7,6 @@ import com.unciv.UncivGame import com.unciv.logic.map.MapUnit import com.unciv.models.UnitAction import com.unciv.ui.audio.SoundPlayer -import com.unciv.ui.crashhandling.launchCrashHandling import com.unciv.ui.images.IconTextButton import com.unciv.ui.utils.KeyCharAndCode import com.unciv.ui.utils.KeyPressDispatcher.Companion.keyboardAvailable @@ -15,6 +14,7 @@ import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip import com.unciv.ui.utils.extensions.disable import com.unciv.ui.utils.extensions.onClick import com.unciv.ui.worldscreen.WorldScreen +import com.unciv.utils.concurrency.Concurrency class UnitActionsTable(val worldScreen: WorldScreen) : Table() { @@ -46,7 +46,7 @@ class UnitActionsTable(val worldScreen: WorldScreen) : Table() { actionButton.onClick(unitAction.uncivSound, action) if (key != KeyCharAndCode.UNKNOWN) worldScreen.keyPressDispatcher[key] = { - launchCrashHandling("UnitSound") { SoundPlayer.play(unitAction.uncivSound) } + Concurrency.run("UnitSound") { SoundPlayer.play(unitAction.uncivSound) } action() worldScreen.mapHolder.removeUnitActionOverlay() } diff --git a/core/src/com/unciv/ui/utils/Log.kt b/core/src/com/unciv/utils/Log.kt similarity index 100% rename from core/src/com/unciv/ui/utils/Log.kt rename to core/src/com/unciv/utils/Log.kt diff --git a/core/src/com/unciv/utils/concurrency/Concurrency.kt b/core/src/com/unciv/utils/concurrency/Concurrency.kt new file mode 100644 index 0000000000..b924235bc3 --- /dev/null +++ b/core/src/com/unciv/utils/concurrency/Concurrency.kt @@ -0,0 +1,158 @@ +package com.unciv.utils.concurrency + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.LifecycleListener +import com.unciv.UncivGame +import com.unciv.ui.crashhandling.wrapCrashHandlingUnit +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.util.concurrent.CancellationException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory +import kotlin.coroutines.CoroutineContext + +/** + * Created to make handling multiple threads as simple as possible. Everything is based upon [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-guide.html), + * so fully understanding this code requires familiarity with that. + * + * However, the simple usage guide: + * - Use the `run...` functions within code that does not use any concurrency yet. + * - Then, use the `launch...` functions within `run...` code blocks. + * - Within `suspend` functions, use [kotlinx.coroutines.coroutineScope] to gain access to the `launch...` functions. + * + * All methods in this file automatically wrap the given code blocks to catch all uncaught exceptions, calling [UncivGame.handleUncaughtThrowable]. + */ +object Concurrency { + + /** + * See [kotlinx.coroutines.runBlocking]. Runs on a non-daemon thread pool by default. + * + * @return null if an uncaught exception occured + */ + fun runBlocking( + name: String? = null, + context: CoroutineContext = Dispatcher.NON_DAEMON, + block: suspend CoroutineScope.() -> T + ): T? { + return kotlinx.coroutines.runBlocking(addName(context, name)) { + try { + block(this) + } catch (ex: Throwable) { + UncivGame.Current.handleUncaughtThrowable(ex) + null + } + } + } + + /** Non-blocking version of [runBlocking]. Runs on a daemon thread pool by default. Use this for code that does not necessarily need to finish executing. */ + fun run( + name: String? = null, + scope: CoroutineScope = CoroutineScope(Dispatcher.DAEMON), + block: suspend CoroutineScope.() -> Unit + ): Job { + return scope.launchCrashHandling(scope.coroutineContext, name, block) + } + + /** Non-blocking version of [runBlocking]. Runs on a non-daemon thread pool. Use this if you do something that should always finish if possible, like saving the game. */ + fun runOnNonDaemonThreadPool(name: String? = null, block: suspend CoroutineScope.() -> Unit) = run(name, CoroutineScope(Dispatcher.NON_DAEMON), block) + + /** Non-blocking version of [runBlocking]. Runs on the GDX GL thread. Use this for all code that manipulates the GDX UI classes. */ + fun runOnGLThread(name: String? = null, block: suspend CoroutineScope.() -> Unit) = run(name, CoroutineScope(Dispatcher.GL), block) + + /** Must only be called in [com.unciv.UncivGame.dispose] to not have any threads running that prevent JVM shutdown. */ + fun stopThreadPools() = EXECUTORS.forEach(ExecutorService::shutdown) +} + +/** See [launch] */ +// This method is not called `launch` (with a default DAEMON dispatcher) to prevent ambiguity between our `launch` methods and kotlin coroutine `launch` methods. +fun CoroutineScope.launchCrashHandling( + context: CoroutineContext, + name: String? = null, + block: suspend CoroutineScope.() -> Unit +): Job { + return launch(addName(context, name)) { + try { + block(this) + } catch (ex: Throwable) { + UncivGame.Current.handleUncaughtThrowable(ex) + } + } +} + +/** See [launch]. Runs on a daemon thread pool. Use this for code that does not necessarily need to finish executing. */ +fun CoroutineScope.launchOnThreadPool(name: String? = null, block: suspend CoroutineScope.() -> Unit) = launchCrashHandling(Dispatcher.DAEMON, name, block) +/** See [launch]. Runs on a non-daemon thread pool. Use this if you do something that should always finish if possible, like saving the game. */ +fun CoroutineScope.launchOnNonDaemonThreadPool(name: String? = null, block: suspend CoroutineScope.() -> Unit) = launchCrashHandling(Dispatcher.NON_DAEMON, name, block) +/** See [launch]. Runs on the GDX GL thread. Use this for all code that manipulates the GDX UI classes. */ +fun CoroutineScope.launchOnGLThread(name: String? = null, block: suspend CoroutineScope.() -> Unit) = launchCrashHandling(Dispatcher.GL, name, block) + + +/** + * All dispatchers here bring the main game loop to a [com.unciv.CrashScreen] if an exception happens. + */ +object Dispatcher { + /** Runs coroutines on a daemon thread pool. */ + val DAEMON: CoroutineDispatcher = createThreadpoolDispatcher("threadpool-daemon-", isDaemon = true) + + /** Runs coroutines on a non-daemon thread pool. */ + val NON_DAEMON: CoroutineDispatcher = createThreadpoolDispatcher("threadpool-nondaemon-", isDaemon = false) + + /** Runs coroutines on the GDX GL thread. */ + val GL: CoroutineDispatcher = CrashHandlingDispatcher(GLDispatcher()) +} + +private fun addName(context: CoroutineContext, name: String?) = if (name != null) context + CoroutineName(name) else context + +private val EXECUTORS = mutableListOf() + +private class GLDispatcher : CoroutineDispatcher(), LifecycleListener { + var isDisposed = false + + init { + Gdx.app.addLifecycleListener(this) + } + + override fun dispatch(context: CoroutineContext, block: Runnable) { + if (isDisposed) { + context.cancel(CancellationException("GDX GL thread is not handling runnables anymore")) + Dispatcher.DAEMON.dispatch(context, block) // dispatch contract states that block has to be invoked + return + } + Gdx.app.postRunnable(block) + } + + override fun dispose() { + isDisposed = true + } + override fun pause() {} + override fun resume() {} +} + +private fun createThreadpoolDispatcher(threadPrefix: String, isDaemon: Boolean): CrashHandlingDispatcher { + val executor = Executors.newCachedThreadPool(object : ThreadFactory { + var n = 0 + override fun newThread(r: Runnable): Thread { + val thread = Thread(r, "${threadPrefix}${n++}") + thread.isDaemon = isDaemon + return thread + } + }) + EXECUTORS.add(executor) + return CrashHandlingDispatcher(executor.asCoroutineDispatcher()) +} + +class CrashHandlingDispatcher( + private val decoratedDispatcher: CoroutineDispatcher +) : CoroutineDispatcher() { + + override fun dispatch(context: CoroutineContext, block: Runnable) { + decoratedDispatcher.dispatch(context, block::run.wrapCrashHandlingUnit()) + } +} diff --git a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt index 7c2282d2cf..3141d58fe0 100644 --- a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt +++ b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt @@ -9,10 +9,10 @@ import com.badlogic.gdx.graphics.glutils.HdpiMode import com.sun.jna.Native import com.unciv.UncivGame import com.unciv.UncivGameParameters -import com.unciv.utils.Log -import com.unciv.utils.debug import com.unciv.logic.GameSaver import com.unciv.ui.utils.Fonts +import com.unciv.utils.Log +import com.unciv.utils.debug import java.util.* import kotlin.concurrent.timer