From dcf8f24baa118797de29c23517bc2c5f07595526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=A8=E8=91=89=20Scarlet?= <93977077+mukjepscarlet@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:23:04 +0800 Subject: [PATCH] fix(coroutines): swallowed exception in global scopes --- .../liquidbounce/api/core/HttpClient.kt | 18 ++---- .../module/modules/render/ModuleMobOwners.kt | 43 ++++++-------- .../liquidbounce/utils/block/ChunkScanner.kt | 11 ++-- .../utils/kotlin/ConcurrentExtensions.kt | 3 - .../utils/kotlin/CoroutineExtensions.kt | 58 +++++++++++++++++++ 5 files changed, 85 insertions(+), 48 deletions(-) create mode 100644 src/main/kotlin/net/ccbluex/liquidbounce/utils/kotlin/CoroutineExtensions.kt diff --git a/src/main/kotlin/net/ccbluex/liquidbounce/api/core/HttpClient.kt b/src/main/kotlin/net/ccbluex/liquidbounce/api/core/HttpClient.kt index cfec54012cb..b7fdcc1576a 100644 --- a/src/main/kotlin/net/ccbluex/liquidbounce/api/core/HttpClient.kt +++ b/src/main/kotlin/net/ccbluex/liquidbounce/api/core/HttpClient.kt @@ -19,7 +19,6 @@ package net.ccbluex.liquidbounce.api.core import com.mojang.blaze3d.platform.NativeImage -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -31,11 +30,10 @@ import net.ccbluex.liquidbounce.api.interceptors.DefaultHeaderInterceptor import net.ccbluex.liquidbounce.api.thirdparty.mojang.MojangApiClient import net.ccbluex.liquidbounce.config.gson.interopGson import net.ccbluex.liquidbounce.config.gson.util.readJson -import net.ccbluex.liquidbounce.utils.client.error.ErrorHandler import net.ccbluex.liquidbounce.utils.client.logger import net.ccbluex.liquidbounce.utils.client.mc +import net.ccbluex.liquidbounce.utils.kotlin.CommonCoroutineExceptionHandler import net.ccbluex.liquidbounce.utils.render.readNativeImage -import net.minecraft.ReportedException import okhttp3.Cache import okhttp3.Call import okhttp3.Dispatcher @@ -61,22 +59,14 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit val renderScope = CoroutineScope( - Dispatchers.Main + SupervisorJob() + CoroutineExceptionHandler { _, throwable -> - if (throwable is ReportedException) { - ErrorHandler.fatal(throwable, additionalMessage = "Render scope") - } - } + Dispatchers.Main + SupervisorJob() + CommonCoroutineExceptionHandler.Default ) val ioScope = CoroutineScope( - Dispatchers.IO + SupervisorJob() + CoroutineExceptionHandler { _, throwable -> - if (throwable is ReportedException) { - ErrorHandler.fatal(throwable, additionalMessage = "IO scope") - } - } + Dispatchers.IO + SupervisorJob() + CommonCoroutineExceptionHandler.Default ) -fun withScope(block: suspend CoroutineScope.() -> Unit) = ioScope.launch { block() } +fun withScope(block: suspend CoroutineScope.() -> Unit) = ioScope.launch(block = block) object HttpClient { diff --git a/src/main/kotlin/net/ccbluex/liquidbounce/features/module/modules/render/ModuleMobOwners.kt b/src/main/kotlin/net/ccbluex/liquidbounce/features/module/modules/render/ModuleMobOwners.kt index b9bdbd58c87..06693f6340a 100644 --- a/src/main/kotlin/net/ccbluex/liquidbounce/features/module/modules/render/ModuleMobOwners.kt +++ b/src/main/kotlin/net/ccbluex/liquidbounce/features/module/modules/render/ModuleMobOwners.kt @@ -19,20 +19,21 @@ package net.ccbluex.liquidbounce.features.module.modules.render import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import net.ccbluex.liquidbounce.api.core.HttpClient -import net.ccbluex.liquidbounce.api.core.withScope +import net.ccbluex.liquidbounce.event.eventListenerScope import net.ccbluex.liquidbounce.features.module.ClientModule import net.ccbluex.liquidbounce.features.module.ModuleCategories import net.ccbluex.liquidbounce.utils.kotlin.toUndashedString +import net.ccbluex.liquidbounce.utils.text.withFormat import net.minecraft.ChatFormatting import net.minecraft.network.chat.Style import net.minecraft.util.FormattedCharSequence import net.minecraft.world.entity.Entity -import net.minecraft.world.entity.TamableAnimal -import net.minecraft.world.entity.animal.equine.AbstractHorse +import net.minecraft.world.entity.OwnableEntity import net.minecraft.world.entity.projectile.Projectile import java.util.UUID -import java.util.concurrent.ConcurrentHashMap /** * MobOwners module @@ -44,7 +45,7 @@ object ModuleMobOwners : ClientModule("MobOwners", ModuleCategories.RENDER) { private val projectiles by boolean("Projectiles", false) - private val uuidNameCache = ConcurrentHashMap() + private val uuidNameCache = hashMapOf() fun getOwnerInfoText(entity: Entity?): FormattedCharSequence? { if (entity == null || !this.running) { @@ -52,44 +53,36 @@ object ModuleMobOwners : ClientModule("MobOwners", ModuleCategories.RENDER) { } val ownerId = when (entity) { - is TamableAnimal -> entity.ownerReference?.uuid - is AbstractHorse -> entity.ownerReference?.uuid + is OwnableEntity -> entity.ownerReference?.uuid is Projectile if projectiles -> entity.owner?.uuid else -> null } ?: return null - return world.getPlayerByUUID(ownerId) - ?.let { FormattedCharSequence.forward(it.scoreboardName, Style.EMPTY) } + return world.getPlayerByUUID(ownerId)?.scoreboardName?.withFormat() ?: getFromMojangApi(ownerId) } - private val LOADING_TEXT = FormattedCharSequence.forward( - "Loading...", - Style.EMPTY.withItalic(true) - ) + private val LOADING_TEXT = "Loading..." + .withFormat(Style.EMPTY.withItalic(true)) - private val FAILED_TEXT = FormattedCharSequence.forward( - "Failed to query Mojang API", - Style.EMPTY.withItalic(true).withColor(ChatFormatting.RED) - ) + private val FAILED_TEXT = "Failed to query Mojang API" + .withFormat(Style.EMPTY.withItalic(true).withColor(ChatFormatting.RED)) - private val CANCELED_TEXT = FormattedCharSequence.forward( - "Query is canceled", - Style.EMPTY.withItalic(true).withColor(ChatFormatting.YELLOW) - ) + private val CANCELED_TEXT = "Query is canceled" + .withFormat(Style.EMPTY.withItalic(true).withColor(ChatFormatting.YELLOW)) @Suppress("SwallowedException") private fun getFromMojangApi(ownerId: UUID): FormattedCharSequence { return uuidNameCache.putIfAbsent(ownerId, LOADING_TEXT) ?: run { // The job will still run even if the module is disabled - withScope { + eventListenerScope.launch(Dispatchers.Main) { uuidNameCache[ownerId] = try { val profile = HttpClient.mojangApiClient.mcServicesApi .lookupNameByUuid(ownerId.toUndashedString()) - FormattedCharSequence.forward(profile.name, Style.EMPTY) - } catch (e: CancellationException) { + profile.name.withFormat() + } catch (_: CancellationException) { CANCELED_TEXT - } catch (e: Exception) { + } catch (_: Exception) { FAILED_TEXT } } diff --git a/src/main/kotlin/net/ccbluex/liquidbounce/utils/block/ChunkScanner.kt b/src/main/kotlin/net/ccbluex/liquidbounce/utils/block/ChunkScanner.kt index 438d7feef78..7c1980e44b6 100644 --- a/src/main/kotlin/net/ccbluex/liquidbounce/utils/block/ChunkScanner.kt +++ b/src/main/kotlin/net/ccbluex/liquidbounce/utils/block/ChunkScanner.kt @@ -35,7 +35,8 @@ import net.ccbluex.liquidbounce.event.events.PacketEvent import net.ccbluex.liquidbounce.event.events.WorldChangeEvent import net.ccbluex.liquidbounce.event.handler import net.ccbluex.liquidbounce.features.module.MinecraftShortcuts -import net.ccbluex.liquidbounce.utils.client.logger +import net.ccbluex.liquidbounce.utils.client.clientLogger +import net.ccbluex.liquidbounce.utils.kotlin.CommonCoroutineExceptionHandler import net.ccbluex.liquidbounce.utils.kotlin.EventPriorityConvention.FIRST_PRIORITY import net.ccbluex.liquidbounce.utils.kotlin.EventPriorityConvention.READ_FINAL_STATE import net.ccbluex.liquidbounce.utils.kotlin.joinAll @@ -54,6 +55,8 @@ import kotlin.time.measureTime object ChunkScanner : EventListener, MinecraftShortcuts { + private val logger = clientLogger("ChunkScanner") + private val loadedChunks = LongOpenHashSet() private val threadLocalBlockPos = ThreadLocal.withInitial(BlockPos::MutableBlockPos) @@ -146,11 +149,7 @@ object ChunkScanner : EventListener, MinecraftShortcuts { */ private val worldJob = SupervisorJob() - val scope = CoroutineScope(dispatcher + worldJob + CoroutineExceptionHandler { context, throwable -> - if (throwable !is CancellationException) { - logger.warn("Chunk update error", throwable) - } - }) + private val scope = CoroutineScope(dispatcher + worldJob + CommonCoroutineExceptionHandler { logger }) /** * Cancel all existing enqueue(emit) jobs and scanner jobs diff --git a/src/main/kotlin/net/ccbluex/liquidbounce/utils/kotlin/ConcurrentExtensions.kt b/src/main/kotlin/net/ccbluex/liquidbounce/utils/kotlin/ConcurrentExtensions.kt index dcdf798a58c..daaca1b2b7d 100644 --- a/src/main/kotlin/net/ccbluex/liquidbounce/utils/kotlin/ConcurrentExtensions.kt +++ b/src/main/kotlin/net/ccbluex/liquidbounce/utils/kotlin/ConcurrentExtensions.kt @@ -20,7 +20,6 @@ package net.ccbluex.liquidbounce.utils.kotlin -import kotlinx.coroutines.Job import net.ccbluex.liquidbounce.utils.client.mc import net.minecraft.server.packs.resources.PreparableReloadListener import net.minecraft.util.Util @@ -32,8 +31,6 @@ inline operator fun ThreadLocal.getValue(receiver: Any?, property: KPrope inline operator fun ThreadLocal.setValue(receiver: Any?, property: KProperty<*>, value: T) = set(value) -suspend inline fun Array.joinAll() = forEach { it.join() } - /** * A [PreparableReloadListener] with a simplified async reload entry point. * diff --git a/src/main/kotlin/net/ccbluex/liquidbounce/utils/kotlin/CoroutineExtensions.kt b/src/main/kotlin/net/ccbluex/liquidbounce/utils/kotlin/CoroutineExtensions.kt new file mode 100644 index 00000000000..c6f4948783f --- /dev/null +++ b/src/main/kotlin/net/ccbluex/liquidbounce/utils/kotlin/CoroutineExtensions.kt @@ -0,0 +1,58 @@ +/* + * This file is part of LiquidBounce (https://github.com/CCBlueX/LiquidBounce) + * + * Copyright (c) 2015 - 2026 CCBlueX + * + * LiquidBounce is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiquidBounce is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LiquidBounce. If not, see . + */ + +package net.ccbluex.liquidbounce.utils.kotlin + +import com.google.common.base.Suppliers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Job +import net.ccbluex.liquidbounce.utils.client.error.ErrorHandler +import net.ccbluex.liquidbounce.utils.client.logger +import net.minecraft.ReportedException +import org.apache.logging.log4j.Logger +import java.util.function.Supplier +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException + +suspend inline fun Array.joinAll() = forEach { it.join() } + +internal class CommonCoroutineExceptionHandler( + private val logger: Supplier, +): CoroutineExceptionHandler { + constructor(logger: Logger): this(Suppliers.ofInstance(logger)) + + private fun coroutineName(context: CoroutineContext) = + context[CoroutineName]?.toString() ?: context.toString() + + override fun handleException(context: CoroutineContext, exception: Throwable) { + when (exception) { + is CancellationException -> return + is ReportedException -> ErrorHandler.fatal(exception, additionalMessage = coroutineName(context)) + else -> logger.get().error("Unhandled exception caught for ${coroutineName(context)}", exception) + } + } + + override val key: CoroutineContext.Key<*> + get() = CoroutineExceptionHandler.Key + + companion object { + val Default = CommonCoroutineExceptionHandler(logger) + } +}