From bbf6904ffbc61cd88936661b98bebe1198b0b5ed Mon Sep 17 00:00:00 2001 From: twisti-dev <76837088+twisti-dev@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:19:53 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(block):=20add=20block=20de?= =?UTF-8?q?stroy=20event=20listener=20to=20remove=20blocks=20on=20destruct?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/paper/server/impl/pdc/block/BlockDataListener.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockDataListener.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockDataListener.kt index 40c59613..544b28ce 100644 --- a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockDataListener.kt +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockDataListener.kt @@ -22,6 +22,7 @@ */ package dev.slne.surf.api.paper.server.impl.pdc.block +import com.destroystokyo.paper.event.block.BlockDestroyEvent import dev.slne.surf.api.paper.pdc.block.CustomBlockPersistentDataContainer import dev.slne.surf.api.paper.pdc.block.pdc import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap @@ -73,6 +74,12 @@ object BlockDataListener : Listener { } } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun onBlockDestroy(event: BlockDestroyEvent) { + if (event.isCancelled) return + remove(event) + } + @EventHandler( priority = EventPriority.MONITOR, ignoreCancelled = true From 8026083d1dc93b23ce557642556ce820c08a92e4 Mon Sep 17 00:00:00 2001 From: twisti-dev <76837088+twisti-dev@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:32:42 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20feat(coroutine-util):=20enhance?= =?UTF-8?q?=20coroutine=20scheduling=20with=20improved=20error=20handling?= =?UTF-8?q?=20and=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add logging capabilities for task execution and exception handling - introduce catchExceptions parameter to control exception behavior - update runAtFixedRate and runWithFixedDelay functions to support new logging features - add runUntil function for conditional execution with error handling --- gradle.properties | 2 +- .../surf-api-core/api/surf-api-core.api | 10 +- .../slne/surf/api/core/util/coroutine-util.kt | 306 +++++++++++++++--- 3 files changed, 266 insertions(+), 52 deletions(-) diff --git a/gradle.properties b/gradle.properties index 05012d6e..7323dd41 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,6 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled javaVersion=25 mcVersion=26.2 group=dev.slne.surf.api -version=3.28.0 +version=3.29.0 relocationPrefix=dev.slne.surf.api.libs snapshot=false diff --git a/surf-api-core/surf-api-core/api/surf-api-core.api b/surf-api-core/surf-api-core/api/surf-api-core.api index cdffe3b2..afdb77bd 100644 --- a/surf-api-core/surf-api-core/api/surf-api-core.api +++ b/surf-api-core/surf-api-core/api/surf-api-core.api @@ -9908,11 +9908,17 @@ public final class dev/slne/surf/api/core/util/Caffeine_utilKt { } public final class dev/slne/surf/api/core/util/Coroutine_utilKt { - public static final fun runAtFixedRate-vLdBGDU (Lkotlinx/coroutines/CoroutineScope;JJLkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/Job; + public static final fun runAtFixedRate-FbhrOv8 (Lkotlinx/coroutines/CoroutineScope;JJZLjava/lang/String;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/Job; + public static synthetic fun runAtFixedRate-FbhrOv8$default (Lkotlinx/coroutines/CoroutineScope;JJZLjava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/Job; + public static final synthetic fun runAtFixedRate-vLdBGDU (Lkotlinx/coroutines/CoroutineScope;JJLkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/Job; public static synthetic fun runAtFixedRate-vLdBGDU$default (Lkotlinx/coroutines/CoroutineScope;JJLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/Job; + public static final fun runUntil-G6guFE4 (Lkotlinx/coroutines/CoroutineScope;JJZLkotlin/jvm/functions/Function1;Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/Job; + public static synthetic fun runUntil-G6guFE4$default (Lkotlinx/coroutines/CoroutineScope;JJZLkotlin/jvm/functions/Function1;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/Job; public static final fun runUntil-jKevqZI (Lkotlinx/coroutines/CoroutineScope;JJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/Job; public static synthetic fun runUntil-jKevqZI$default (Lkotlinx/coroutines/CoroutineScope;JJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/Job; - public static final fun runWithFixedDelay-vLdBGDU (Lkotlinx/coroutines/CoroutineScope;JJLkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/Job; + public static final fun runWithFixedDelay-FbhrOv8 (Lkotlinx/coroutines/CoroutineScope;JJZLjava/lang/String;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/Job; + public static synthetic fun runWithFixedDelay-FbhrOv8$default (Lkotlinx/coroutines/CoroutineScope;JJZLjava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/Job; + public static final synthetic fun runWithFixedDelay-vLdBGDU (Lkotlinx/coroutines/CoroutineScope;JJLkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/Job; public static synthetic fun runWithFixedDelay-vLdBGDU$default (Lkotlinx/coroutines/CoroutineScope;JJLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/Job; } diff --git a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/util/coroutine-util.kt b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/util/coroutine-util.kt index 0b41d338..66aaa4ef 100644 --- a/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/util/coroutine-util.kt +++ b/surf-api-core/surf-api-core/src/main/kotlin/dev/slne/surf/api/core/util/coroutine-util.kt @@ -1,9 +1,49 @@ +@file:OptIn(ExperimentalVersionOverloading::class) + package dev.slne.surf.api.core.util import kotlinx.coroutines.* +import net.kyori.adventure.text.logger.slf4j.ComponentLogger import kotlin.time.Duration import kotlin.time.Duration.Companion.nanoseconds +private data class TaskLogging( + val name: String, + val logger: ComponentLogger, +) + +private fun createTaskLogging( + defaultName: String, + taskName: String?, +): TaskLogging { + val normalizedTaskName = taskName?.takeIf { it.isNotBlank() } + val resolvedName = normalizedTaskName ?: defaultName + val logger = getCallerClass(1)?.let(ComponentLogger::logger) ?: ComponentLogger.logger(defaultName) + + return TaskLogging( + name = resolvedName, + logger = logger, + ) +} + +private fun CoroutineScope.handleTaskException( + throwable: Throwable, + logging: TaskLogging, + catchExceptions: Boolean, +) { + if (throwable is CancellationException) { + ensureActive() + logging.logger.warn("A Task tried to cancel itself while the job was still active. Ignoring.") + return + } + + if (catchExceptions) { + logging.logger.error("Exception in ${logging.name}", throwable) + } else { + throw throwable + } +} + /** * Starts a coroutine that executes [block] at a fixed rate. * @@ -12,19 +52,31 @@ import kotlin.time.Duration.Companion.nanoseconds * will attempt to "catch up" without additional delay. * * Execution behavior: - * - Waits for [initialDelay] before the first execution (if > 0). + * - Waits for [initialDelay] before the first execution if it is greater than zero. * - Executes [block] immediately after the initial delay. * - Subsequent executions are aligned to a fixed interval defined by [period]. * * Cancellation: * - The returned [Job] is cancelled when the enclosing [CoroutineScope] is cancelled. * - The loop checks for cancellation via [isActive] and [ensureActive]. + * - If [block] throws a [CancellationException] while the job is still active, the exception + * is treated as a self-cancellation attempt, logged as a warning, and ignored. * * Error handling: - * - If [block] throws an exception, the coroutine is cancelled and no further executions occur. + * - If [catchExceptions] is `true`, non-cancellation exceptions thrown by [block] are logged + * and the next execution is still attempted. + * - If [catchExceptions] is `false`, non-cancellation exceptions thrown by [block] are rethrown + * and the coroutine fails. + * + * Logging: + * - The logger is resolved before the coroutine is launched. + * - The caller class is used as logger when available. + * - If the caller class cannot be resolved, `runAtFixedRate` is used as fallback logger name. * - * @param period the interval between scheduled executions; must be > 0 - * @param initialDelay the delay before the first execution; must be >= 0 + * @param period the interval between scheduled executions; must be greater than zero + * @param initialDelay the delay before the first execution; must not be negative + * @param catchExceptions whether non-cancellation exceptions should be logged and swallowed + * @param taskName optional task name used for logging * @param block the suspending block to execute repeatedly * * @return the [Job] representing the running coroutine @@ -34,26 +86,44 @@ import kotlin.time.Duration.Companion.nanoseconds fun CoroutineScope.runAtFixedRate( period: Duration, initialDelay: Duration = Duration.ZERO, - block: suspend CoroutineScope.() -> Unit -): Job = launch { + @IntroducedAt("3.29.0") catchExceptions: Boolean = true, + @IntroducedAt("3.29.0") taskName: String? = null, + block: suspend CoroutineScope.() -> Unit, +): Job { require(period > Duration.ZERO) { "period must be positive" } require(initialDelay >= Duration.ZERO) { "initialDelay must not be negative" } - if (initialDelay.isPositive()) { - delay(initialDelay) - ensureActive() - } + val logging = createTaskLogging( + defaultName = "runAtFixedRate", + taskName = taskName, + ) - var nextRun = System.nanoTime() - while (isActive) { - nextRun += period.inWholeNanoseconds + return launch { + if (initialDelay.isPositive()) { + delay(initialDelay) + ensureActive() + } - block() - ensureActive() + var nextRun = System.nanoTime() + while (isActive) { + nextRun += period.inWholeNanoseconds + + try { + block() + } catch (throwable: Throwable) { + handleTaskException( + throwable = throwable, + logging = logging, + catchExceptions = catchExceptions, + ) + } + + ensureActive() - val waitNanos = nextRun - System.nanoTime() - if (waitNanos > 0) { - delay(waitNanos.nanoseconds) + val waitNanos = nextRun - System.nanoTime() + if (waitNanos > 0) { + delay(waitNanos.nanoseconds) + } } } } @@ -61,23 +131,35 @@ fun CoroutineScope.runAtFixedRate( /** * Starts a coroutine that executes [block] with a fixed delay between executions. * - * The delay is applied *after* each execution of [block]. This means that the time between + * The delay is applied after each execution of [block]. This means that the time between * the start of consecutive executions depends on how long [block] takes to run. * * Execution behavior: - * - Waits for [initialDelay] before the first execution (if > 0). + * - Waits for [initialDelay] before the first execution if it is greater than zero. * - Executes [block]. * - Waits for [delay] after each execution before starting the next one. * * Cancellation: * - The returned [Job] is cancelled when the enclosing [CoroutineScope] is cancelled. * - The loop checks for cancellation via [isActive] and [ensureActive]. + * - If [block] throws a [CancellationException] while the job is still active, the exception + * is treated as a self-cancellation attempt, logged as a warning, and ignored. * * Error handling: - * - If [block] throws an exception, the coroutine is cancelled and no further executions occur. + * - If [catchExceptions] is `true`, non-cancellation exceptions thrown by [block] are logged + * and the next execution is still attempted. + * - If [catchExceptions] is `false`, non-cancellation exceptions thrown by [block] are rethrown + * and the coroutine fails. * - * @param delay the delay between executions; must be > 0 - * @param initialDelay the delay before the first execution; must be >= 0 + * Logging: + * - The logger is resolved before the coroutine is launched. + * - The caller class is used as logger when available. + * - If the caller class cannot be resolved, `runWithFixedDelay` is used as fallback logger name. + * + * @param delay the delay between executions; must be greater than zero + * @param initialDelay the delay before the first execution; must not be negative + * @param catchExceptions whether non-cancellation exceptions should be logged and swallowed + * @param taskName optional task name used for logging * @param block the suspending block to execute repeatedly * * @return the [Job] representing the running coroutine @@ -87,20 +169,38 @@ fun CoroutineScope.runAtFixedRate( fun CoroutineScope.runWithFixedDelay( delay: Duration, initialDelay: Duration = Duration.ZERO, - block: suspend CoroutineScope.() -> Unit -): Job = launch { + @IntroducedAt("3.29.0") catchExceptions: Boolean = true, + @IntroducedAt("3.29.0") taskName: String? = null, + block: suspend CoroutineScope.() -> Unit, +): Job { require(delay > Duration.ZERO) { "delay must be positive" } require(initialDelay >= Duration.ZERO) { "initialDelay must not be negative" } - if (initialDelay.isPositive()) { - delay(initialDelay) - ensureActive() - } + val logging = createTaskLogging( + defaultName = "runWithFixedDelay", + taskName = taskName, + ) - while (isActive) { - block() - ensureActive() - delay(delay) + return launch { + if (initialDelay.isPositive()) { + delay(initialDelay) + ensureActive() + } + + while (isActive) { + try { + block() + } catch (throwable: Throwable) { + handleTaskException( + throwable = throwable, + logging = logging, + catchExceptions = catchExceptions, + ) + } + + ensureActive() + delay(delay) + } } } @@ -108,23 +208,35 @@ fun CoroutineScope.runWithFixedDelay( * Starts a coroutine that repeatedly executes [block] with a fixed [delay] between executions * as long as [predicate] returns `true`. * + * This overload keeps the original call shape for usages with two lambdas and uses + * `catchExceptions = true`. + * * Execution behavior: - * - Waits for [initialDelay] before the first execution (if > 0). + * - Waits for [initialDelay] before the first execution if it is greater than zero. * - Before each iteration, [predicate] is evaluated. * - If [predicate] returns `true`, [block] is executed. * - After execution, waits for [delay] before the next iteration. - * - Stops when [predicate] returns `false` or the coroutine is cancelled. + * - Stops when [predicate] returns `false`, when [predicate] throws a caught exception, + * or when the coroutine is cancelled. * * Cancellation: * - The returned [Job] is cancelled when the enclosing [CoroutineScope] is cancelled. * - The loop checks for cancellation via [isActive] and [ensureActive]. + * - If [block] throws a [CancellationException] while the job is still active, the exception + * is treated as a self-cancellation attempt, logged as a warning, and ignored. * * Error handling: - * - If [block] or [predicate] throws an exception, the coroutine is cancelled - * and no further executions occur. + * - Non-cancellation exceptions thrown by [block] are logged and the next execution is still attempted. + * - Non-cancellation exceptions thrown by [predicate] are logged and the loop stops, because + * the continuation condition could not be evaluated successfully. * - * @param delay the delay between executions; must be >= 0 - * @param initialDelay the delay before the first execution; must be >= 0 + * Logging: + * - The logger is resolved before the coroutine is launched. + * - The caller class is used as logger when available. + * - If the caller class cannot be resolved, `runUntil` is used as fallback logger name. + * + * @param delay the delay between executions; must not be negative + * @param initialDelay the delay before the first execution; must not be negative * @param predicate condition that controls whether execution should continue * @param block the suspending block to execute repeatedly * @@ -136,19 +248,115 @@ fun CoroutineScope.runUntil( delay: Duration, initialDelay: Duration = Duration.ZERO, predicate: suspend () -> Boolean, - block: suspend CoroutineScope.() -> Unit -): Job = launch { + block: suspend CoroutineScope.() -> Unit, +): Job = runUntil( + delay = delay, + initialDelay = initialDelay, + catchExceptions = true, + predicate = predicate, + taskName = null, + block = block, +) + + +/** + * Starts a coroutine that repeatedly executes [block] with a fixed [delay] between executions + * as long as [predicate] returns `true`. + * + * This overload allows controlling exception handling explicitly. It exists separately because + * version-overloaded parameters do not work reliably with this two-lambda call shape. + * + * Execution behavior: + * - Waits for [initialDelay] before the first execution if it is greater than zero. + * - Before each iteration, [predicate] is evaluated. + * - If [predicate] returns `true`, [block] is executed. + * - After execution, waits for [delay] before the next iteration. + * - Stops when [predicate] returns `false`, when [predicate] throws a caught exception, + * or when the coroutine is cancelled. + * + * Cancellation: + * - The returned [Job] is cancelled when the enclosing [CoroutineScope] is cancelled. + * - The loop checks for cancellation via [isActive] and [ensureActive]. + * - If [block] throws a [CancellationException] while the job is still active, the exception + * is treated as a self-cancellation attempt, logged as a warning, and ignored. + * + * Error handling: + * - If [catchExceptions] is `true`, non-cancellation exceptions thrown by [block] are logged + * and the next execution is still attempted. + * - If [catchExceptions] is `true`, non-cancellation exceptions thrown by [predicate] are logged + * and the loop stops, because the continuation condition could not be evaluated successfully. + * - If [catchExceptions] is `false`, non-cancellation exceptions thrown by [block] or [predicate] + * are rethrown and the coroutine fails. + * + * Logging: + * - The logger is resolved before the coroutine is launched. + * - The caller class is used as logger when available. + * - If the caller class cannot be resolved, `runUntil` is used as fallback logger name. + * + * @param delay the delay between executions; must not be negative + * @param initialDelay the delay before the first execution; must not be negative + * @param catchExceptions whether non-cancellation exceptions should be logged and swallowed + * @param predicate condition that controls whether execution should continue + * @param taskName optional task name used for logging + * @param block the suspending block to execute repeatedly + * + * @return the [Job] representing the running coroutine + * + * @throws IllegalArgumentException if [delay] or [initialDelay] is negative + */ +fun CoroutineScope.runUntil( + delay: Duration, + initialDelay: Duration = Duration.ZERO, + catchExceptions: Boolean, + predicate: suspend () -> Boolean, + taskName: String? = null, + block: suspend CoroutineScope.() -> Unit, +): Job { require(delay >= Duration.ZERO) { "delay must not be negative" } require(initialDelay >= Duration.ZERO) { "initialDelay must not be negative" } - if (initialDelay.isPositive()) { - delay(initialDelay) - ensureActive() - } + val logging = createTaskLogging( + defaultName = "runUntil", + taskName = taskName, + ) - while (isActive && predicate()) { - block() - ensureActive() - delay(delay) + return launch { + if (initialDelay.isPositive()) { + delay(initialDelay) + ensureActive() + } + + while (isActive) { + val shouldRun = try { + predicate() + } catch (t: Throwable) { + handleTaskException( + throwable = t, + logging = logging, + catchExceptions = catchExceptions, + ) + + false + } + + ensureActive() + + if (!shouldRun) { + break + } + + try { + block() + } catch (t: Throwable) { + handleTaskException( + throwable = t, + logging = logging, + catchExceptions = catchExceptions, + ) + } + + ensureActive() + delay(delay) + } } } \ No newline at end of file